commit 2728fb119e09ed2e15615403ab07ddc71367751a truex <truex@equwal.com> 2026-09-26 14:15:34 -0700 Decode subtitle files in UTF-8, UTF-16 or Shift_JIS The reader read each subtitle file as UTF-8. A Shift_JIS copy of sample-jpn.srt showed its cues as mojibake, and no cue matched the book. decodeSubtitles reads the bytes of the file. A UTF-8 BOM or valid UTF-8 gives UTF-8. A UTF-16 BOM gives UTF-16. Other bytes give Shift_JIS. The text has no BOM.
src/subtitles.ts | 23 ++++++++++++++++++- test/subtitles.test.ts | 61 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 2 deletions(-)
diff --git a/src/subtitles.ts b/src/subtitles.ts index a67e2c8..bf1a658 100644 --- a/src/subtitles.ts +++ b/src/subtitles.ts @@ -1,4 +1,4 @@ -// Pure SRT and WebVTT parser. No DOM. +// Pure SRT and WebVTT parser, and the decoder of subtitle files. No DOM. // // Why not a library: the `subtitle` package imports Node `stream` at // module load, so it does not run in a browser bundle. The subset we need @@ -49,6 +49,27 @@ export function parseSubtitles(input: string): Cue[] { return cues; } +/** + * Decodes the bytes of a subtitle file to text, and removes the BOM. + * + * - A UTF-8 BOM, or bytes that are valid UTF-8: UTF-8. + * - A UTF-16 BOM: UTF-16, little endian or big endian as the BOM tells. + * - Other bytes: Shift_JIS, the usual encoding of old Japanese files. + * Japanese text in Shift_JIS is almost never valid UTF-8. + */ +export function decodeSubtitles(bytes: ArrayBuffer): string { + const b = new Uint8Array(bytes); + // TextDecoder removes the BOM of its own encoding. + 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 { + return new TextDecoder('shift_jis').decode(b); + } +} + /** Index of the cue active at time t, or -1. Cues must be sorted by start. */ export function cueIndexAt(cues: readonly Cue[], t: number): number { let lo = 0; diff --git a/test/subtitles.test.ts b/test/subtitles.test.ts index 4a39c19..bd12de2 100644 --- a/test/subtitles.test.ts +++ b/test/subtitles.test.ts @@ -1,6 +1,6 @@ import fc from 'fast-check'; import { describe, expect, it } from 'vitest'; -import { cueIndexAt, parseSubtitles } from '../src/subtitles'; +import { cueIndexAt, decodeSubtitles, parseSubtitles } from '../src/subtitles'; const SRT = `1 00:00:01,000 --> 00:00:03,500 @@ -50,6 +50,65 @@ describe('parseSubtitles', () => { }); }); +const NEKO = '吾輩は猫である。'; + +/** NEKO in Shift_JIS. .NET made these bytes with Encoding.GetEncoding(932). */ +const NEKO_SHIFT_JIS = [ + 0x8c, 0xe1, 0x94, 0x79, 0x82, 0xcd, 0x94, 0x4c, 0x82, 0xc5, 0x82, 0xa0, 0x82, 0xe9, 0x81, 0x42, +]; + +function utf8(text: string): ArrayBuffer { + const bytes = new TextEncoder().encode(text); + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); +} + +function utf16(text: string, littleEndian: boolean): ArrayBuffer { + const view = new DataView(new ArrayBuffer(text.length * 2)); + for (let i = 0; i < text.length; i++) view.setUint16(2 * i, text.charCodeAt(i), littleEndian); + return view.buffer; +} + +describe('decodeSubtitles', () => { + it('decodes UTF-8 without a BOM', () => { + expect(decodeSubtitles(utf8(NEKO))).toBe(NEKO); + }); + + it('decodes UTF-8 with a BOM, and removes the BOM', () => { + expect(decodeSubtitles(utf8('' + NEKO))).toBe(NEKO); + }); + + it('keeps UTF-8 for a file with a UTF-8 BOM and a bad byte', () => { + const bytes = new Uint8Array([...new Uint8Array(utf8('' + NEKO)), 0xff]); + expect(decodeSubtitles(bytes.buffer)).toBe(NEKO + '�'); + }); + + it('decodes UTF-16 with a BOM, and removes the BOM', () => { + expect(decodeSubtitles(utf16('' + NEKO, true))).toBe(NEKO); + expect(decodeSubtitles(utf16('' + NEKO, false))).toBe(NEKO); + }); + + it('decodes Shift_JIS', () => { + expect(decodeSubtitles(new Uint8Array(NEKO_SHIFT_JIS).buffer)).toBe(NEKO); + }); + + it('gives the cues of a Shift_JIS file', () => { + const time = new TextEncoder().encode('1\r\n00:00:00,000 --> 00:00:02,500\r\n'); + const file = new Uint8Array([...time, ...NEKO_SHIFT_JIS, 0x0d, 0x0a]); + expect(parseSubtitles(decodeSubtitles(file.buffer))).toEqual([ + { start: 0, end: 2.5, text: NEKO }, + ]); + }); + + it('gives back each text that is encoded as UTF-8', () => { + fc.assert( + fc.property(fc.string({ unit: 'binary' }), (text) => { + // A BOM at the start is not text, so the decoder removes it. + expect(decodeSubtitles(utf8(text))).toBe(text.replace(/^/, '')); + }), + ); + }); +}); + describe('cueIndexAt', () => { const cues = parseSubtitles(SRT);