Recently Written · git

scan-subread

PDF reader that follows an audiobook with the SubRead suite

git clone https://github.com/equwal/scan-subread

Log | Files | Refs


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);