fix(import): the browser-side file pickers read the same encodings

Stories -> Import read the picked file with File.text(), which decodes
UTF-8 only, so a UTF-16 script came back NUL-riddled and a Windows-1252
one as replacement characters. Dub -> Paste translation -> Load file used
FileReader.readAsText(), which handles a UTF-16 BOM but still turned
Windows-1252 accents, dashes and quotes into replacement characters.

Both now go through readTextFile, which applies decode_text_upload's
rule with TextDecoder: a BOM names the encoding, valid UTF-8 stays
UTF-8, anything else is Windows-1252.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kevin9327
2026-09-14 08:20:02 +09:00
co-authored by Claude Opus 5
parent 9d30de27b4
commit 41c50ed9c8
6 changed files with 119 additions and 4 deletions
+2 -1
View File
@@ -56,6 +56,7 @@ import {
} from '../utils/storyTokens';
import { parseScript } from '../utils/parseScript';
import { importToText } from '../utils/importStory';
import { readTextFile } from '../utils/readTextFile';
import { generateSpeech, audioUrl } from '../api/generate';
import { playBlobAudio } from '../utils/media';
import { downloadMedia } from '../utils/mediaDownload';
@@ -312,7 +313,7 @@ export default function StoriesEditor({ profiles = [] }) {
e.target.value = '';
if (!file) return;
try {
const text = importToText(file.name, await file.text());
const text = importToText(file.name, await readTextFile(file));
setSplitText(text);
setSplitOpen(true);
} catch (err) {
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { Dialog, Button, Textarea, Badge } from '../../ui';
import { buildPastePlan, detectPasteMode } from '../../utils/pasteTranslations';
import { dubParseSubtitleText } from '../../api/dub';
import { readTextFile } from '../../utils/readTextFile';
/**
* DubPasteTranslationDialog — paste a translation produced somewhere else
@@ -88,9 +89,8 @@ export default function DubPasteTranslationDialog({ open, segments = [], onApply
const readFile = useCallback((file) => {
if (!file) return;
const reader = new FileReader();
reader.onload = () => setText(String(reader.result || ''));
reader.readAsText(file);
// As before, a file that cannot be read leaves the text as it was.
readTextFile(file).then(setText, () => {});
}, []);
const apply = () => {
@@ -0,0 +1,48 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, fireEvent, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import '../i18n';
import { ENCODED, SAMPLE } from './encodedText';
// Stories → Import reads a .txt/.srt the user picked. Windows tools save those
// as UTF-16 (Notepad's "Unicode") or in the Windows-1252 code page, and the
// imported script must be the text the file holds, not replacement characters.
vi.mock('../api/generate', () => ({ generateSpeech: vi.fn(), audioUrl: (path) => path }));
vi.mock('../utils/media', () => ({ playBlobAudio: vi.fn(() => Promise.resolve()) }));
vi.mock('../api/hooks', () => ({ useArchetypes: vi.fn(() => ({ data: undefined })) }));
vi.mock('../api/archetypes', () => ({ useArchetypeAsProfile: vi.fn() }));
import StoriesEditor from '../components/StoriesEditor';
import { useAppStore } from '../store';
describe('StoriesEditor import', () => {
beforeEach(() => {
window.localStorage.clear();
window.HTMLElement.prototype.scrollIntoView = vi.fn();
useAppStore.setState({
cast: [{ id: 'narrator', name: 'Narrator', color: '#b8bb26', profileId: null }],
storyTracks: [],
storyProjects: [],
currentProjectId: null,
});
});
it.each(Object.keys(ENCODED))('imports a %s file with its text intact', async (encoding) => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<StoriesEditor profiles={[]} />
</QueryClientProvider>,
);
const input = document.querySelector('input[name="story-import-file"]');
fireEvent.change(input, {
target: { files: [new File([ENCODED[encoding](SAMPLE)], 'story.txt')] },
});
await waitFor(() =>
expect(document.querySelector('.stories-split-panel textarea')).toHaveValue(SAMPLE),
);
});
});
@@ -7,6 +7,7 @@ import {
buildPastePlan,
matchByOverlap,
} from '../utils/pasteTranslations';
import { ENCODED, SAMPLE } from './encodedText';
// "Paste translation from an external source": the user transcribes once,
// translates elsewhere (ChatGPT / DeepL / a human), and pastes the result
@@ -406,6 +407,20 @@ describe('DubPasteTranslationDialog', () => {
expect(rows.every((r) => r.getAttribute('data-matched') === 'true')).toBe(true);
});
// Windows tools save subtitle files as UTF-16 (Notepad's "Unicode") or in
// the Windows-1252 code page; a loaded file must read as the text it holds.
it.each(Object.keys(ENCODED))('loads a %s file with its text intact', async (encoding) => {
render(
<DubPasteTranslationDialog open segments={SEGMENTS} onApply={vi.fn()} onClose={vi.fn()} />,
);
const input = document.querySelector('input[type="file"]');
fireEvent.change(input, {
target: { files: [new File([ENCODED[encoding](SAMPLE)], 'translation.txt')] },
});
await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue(SAMPLE));
});
it('surfaces a backend parse failure instead of applying a silent no-op', async () => {
dubApi.dubParseSubtitleText.mockRejectedValue(new Error('No timed cues found'));
render(
+25
View File
@@ -0,0 +1,25 @@
// Text encoded the ways Windows tools save a subtitle or script file, for
// tests of the code that reads such a file back.
export const SAMPLE = 'Café crème — its late.';
// The non-ASCII characters SAMPLE uses, at their Windows-1252 byte values.
const CP1252 = { é: 0xe9, è: 0xe8, '—': 0x97, '': 0x92 };
const utf16 = (text, littleEndian) => {
const bytes = [];
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
const pair = [code & 0xff, code >> 8];
bytes.push(...(littleEndian ? pair : pair.reverse()));
}
return bytes;
};
export const ENCODED = {
'UTF-8': (text) => new TextEncoder().encode(text),
'UTF-8 with BOM': (text) => new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode(text)]),
'UTF-16 LE': (text) => new Uint8Array([0xff, 0xfe, ...utf16(text, true)]),
'UTF-16 BE': (text) => new Uint8Array([0xfe, 0xff, ...utf16(text, false)]),
'Windows-1252': (text) => new Uint8Array([...text].map((ch) => CP1252[ch] ?? ch.charCodeAt(0))),
};
+26
View File
@@ -0,0 +1,26 @@
/**
* Read a user-picked text file (subtitles, a script) as the text it holds.
*
* `File.text()` decodes UTF-8 only and `FileReader.readAsText()` has no
* Windows-1252 fallback, but Windows tools save these files as UTF-16 with a
* byte-order mark (Notepad's "Unicode", many subtitle editors) or in the
* Windows-1252 code page. Same rule as the backend's decode_text_upload: a BOM
* names the encoding, valid UTF-8 stays UTF-8, anything else is Windows-1252.
*/
export function decodeTextBytes(bytes) {
const b = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
// TextDecoder drops the BOM of the encoding it was built for.
if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) return new TextDecoder('utf-8').decode(b);
if (b[0] === 0xff && b[1] === 0xfe) return new TextDecoder('utf-16le').decode(b);
if (b[0] === 0xfe && b[1] === 0xff) return new TextDecoder('utf-16be').decode(b);
try {
return new TextDecoder('utf-8', { fatal: true }).decode(b);
} catch {
// The WHATWG windows-1252 decoder maps every byte, so this never throws.
return new TextDecoder('windows-1252').decode(b);
}
}
export async function readTextFile(file) {
return decodeTextBytes(new Uint8Array(await file.arrayBuffer()));
}