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 }