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


src/subtitles.ts (3005 bytes)

1 // Pure SRT and WebVTT parser, and the decoder of subtitle files. No DOM.
2 //
3 // Why not a library: the `subtitle` package imports Node `stream` at
4 // module load, so it does not run in a browser bundle. The subset we need
5 // (timed cues, plain text) fits in a few lines.
6 
7 export interface Cue {
8   /** Start time in seconds. */
9   start: number;
10   /** End time in seconds. */
11   end: number;
12   text: string;
13 }
14 
15 const TIME = /(?:(\d+):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/;
16 const ARROW = '-->';
17 
18 function parseTime(s: string): number | null {
19   const m = TIME.exec(s);
20   if (!m) return null;
21   const h = m[1] ? Number(m[1]) : 0;
22   const min = Number(m[2]);
23   const sec = Number(m[3]);
24   const ms = Number(m[4]!.padEnd(3, '0'));
25   return h * 3600 + min * 60 + sec + ms / 1000;
26 }
27 
28 /** Parse SRT or WebVTT text. Blocks without a time line are skipped. */
29 export function parseSubtitles(input: string): Cue[] {
30   const text = input.replace(/^/, '').replace(/\r\n?/g, '\n');
31   const cues: Cue[] = [];
32   for (const block of text.split(/\n{2,}/)) {
33     const lines = block.split('\n');
34     const timeLine = lines.findIndex((l) => l.includes(ARROW));
35     if (timeLine < 0) continue;
36     const [left, right] = lines[timeLine]!.split(ARROW);
37     const start = parseTime(left ?? '');
38     const end = parseTime(right ?? '');
39     if (start === null || end === null) continue;
40     const body = lines
41       .slice(timeLine + 1)
42       .join('\n')
43       .replace(/<[^>]*>/g, '')
44       .trim();
45     if (body.length === 0) continue;
46     cues.push({ start, end, text: body });
47   }
48   cues.sort((x, y) => x.start - y.start);
49   return cues;
50 }
51 
52 /**
53  * Decodes the bytes of a subtitle file to text, and removes the BOM.
54  *
55  * - A UTF-8 BOM, or bytes that are valid UTF-8: UTF-8.
56  * - A UTF-16 BOM: UTF-16, little endian or big endian as the BOM tells.
57  * - Other bytes: Shift_JIS, the usual encoding of old Japanese files.
58  *   Japanese text in Shift_JIS is almost never valid UTF-8.
59  */
60 export function decodeSubtitles(bytes: ArrayBuffer): string {
61   const b = new Uint8Array(bytes);
62   // TextDecoder removes the BOM of its own encoding.
63   if (b[0] === 0xef && b[1] === 0xbb && b[2] === 0xbf) return new TextDecoder('utf-8').decode(b);
64   if (b[0] === 0xff && b[1] === 0xfe) return new TextDecoder('utf-16le').decode(b);
65   if (b[0] === 0xfe && b[1] === 0xff) return new TextDecoder('utf-16be').decode(b);
66   try {
67     return new TextDecoder('utf-8', { fatal: true }).decode(b);
68   } catch {
69     return new TextDecoder('shift_jis').decode(b);
70   }
71 }
72 
73 /**
74  * Index of the last cue that starts at or before time t, or -1. In the
75  * silence after a cue, this is still that cue. Cues must be sorted by start.
76  */
77 export function lastCueAt(cues: readonly Pick<Cue, 'start'>[], t: number): number {
78   let lo = 0;
79   let hi = cues.length - 1;
80   let found = -1;
81   while (lo <= hi) {
82     const mid = (lo + hi) >> 1;
83     if (cues[mid]!.start <= t) {
84       found = mid;
85       lo = mid + 1;
86     } else {
87       hi = mid - 1;
88     }
89   }
90   return found;
91 }