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


test/subtitles.test.ts (4363 bytes)

1 import fc from 'fast-check';
2 import { describe, expect, it } from 'vitest';
3 import { decodeSubtitles, lastCueAt, parseSubtitles } from '../src/subtitles';
4 
5 const SRT = `1
6 00:00:01,000 --> 00:00:03,500
7 The morning sun
8 
9 2
10 00:00:03,500 --> 00:00:06,000
11 was clear and bright,
12 over the hills.
13 `;
14 
15 const VTT = `WEBVTT
16 
17 NOTE a comment block
18 
19 00:01.000 --> 00:03.500 align:start
20 <v Narrator>The morning sun</v>
21 
22 id-2
23 00:03.500 --> 00:06.000
24 was clear and bright
25 `;
26 
27 describe('parseSubtitles', () => {
28   it('parses SRT with multi-line text', () => {
29     expect(parseSubtitles(SRT)).toEqual([
30       { start: 1, end: 3.5, text: 'The morning sun' },
31       { start: 3.5, end: 6, text: 'was clear and bright,\nover the hills.' },
32     ]);
33   });
34 
35   it('parses WebVTT, skips the header and notes, strips tags', () => {
36     expect(parseSubtitles(VTT)).toEqual([
37       { start: 1, end: 3.5, text: 'The morning sun' },
38       { start: 3.5, end: 6, text: 'was clear and bright' },
39     ]);
40   });
41 
42   it('accepts CRLF and a BOM', () => {
43     const text = '' + SRT.replace(/\n/g, '\r\n');
44     expect(parseSubtitles(text)).toHaveLength(2);
45   });
46 
47   it('parses hours', () => {
48     const cues = parseSubtitles('1\n01:02:03,004 --> 01:02:04,000\nx\n');
49     expect(cues[0]?.start).toBeCloseTo(3723.004, 6);
50   });
51 });
52 
53 const NEKO = '吾輩は猫である。';
54 
55 /** NEKO in Shift_JIS. .NET made these bytes with Encoding.GetEncoding(932). */
56 const NEKO_SHIFT_JIS = [
57   0x8c, 0xe1, 0x94, 0x79, 0x82, 0xcd, 0x94, 0x4c, 0x82, 0xc5, 0x82, 0xa0, 0x82, 0xe9, 0x81, 0x42,
58 ];
59 
60 function utf8(text: string): ArrayBuffer {
61   const bytes = new TextEncoder().encode(text);
62   return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
63 }
64 
65 function utf16(text: string, littleEndian: boolean): ArrayBuffer {
66   const view = new DataView(new ArrayBuffer(text.length * 2));
67   for (let i = 0; i < text.length; i++) view.setUint16(2 * i, text.charCodeAt(i), littleEndian);
68   return view.buffer;
69 }
70 
71 describe('decodeSubtitles', () => {
72   it('decodes UTF-8 without a BOM', () => {
73     expect(decodeSubtitles(utf8(NEKO))).toBe(NEKO);
74   });
75 
76   it('decodes UTF-8 with a BOM, and removes the BOM', () => {
77     expect(decodeSubtitles(utf8('' + NEKO))).toBe(NEKO);
78   });
79 
80   it('keeps UTF-8 for a file with a UTF-8 BOM and a bad byte', () => {
81     const bytes = new Uint8Array([...new Uint8Array(utf8('' + NEKO)), 0xff]);
82     expect(decodeSubtitles(bytes.buffer)).toBe(NEKO + '�');
83   });
84 
85   it('decodes UTF-16 with a BOM, and removes the BOM', () => {
86     expect(decodeSubtitles(utf16('' + NEKO, true))).toBe(NEKO);
87     expect(decodeSubtitles(utf16('' + NEKO, false))).toBe(NEKO);
88   });
89 
90   it('decodes Shift_JIS', () => {
91     expect(decodeSubtitles(new Uint8Array(NEKO_SHIFT_JIS).buffer)).toBe(NEKO);
92   });
93 
94   it('gives the cues of a Shift_JIS file', () => {
95     const time = new TextEncoder().encode('1\r\n00:00:00,000 --> 00:00:02,500\r\n');
96     const file = new Uint8Array([...time, ...NEKO_SHIFT_JIS, 0x0d, 0x0a]);
97     expect(parseSubtitles(decodeSubtitles(file.buffer))).toEqual([
98       { start: 0, end: 2.5, text: NEKO },
99     ]);
100   });
101 
102   it('gives back each text that is encoded as UTF-8', () => {
103     fc.assert(
104       fc.property(fc.string({ unit: 'binary' }), (text) => {
105         // A BOM at the start is not text, so the decoder removes it.
106         expect(decodeSubtitles(utf8(text))).toBe(text.replace(/^/, ''));
107       }),
108     );
109   });
110 });
111 
112 describe('lastCueAt', () => {
113   const cues = [
114     { start: 1, end: 2 },
115     { start: 3, end: 4 },
116   ];
117 
118   it('gives the last cue that started, also in the silence after it', () => {
119     expect(lastCueAt(cues, 0.5)).toBe(-1);
120     expect(lastCueAt(cues, 1)).toBe(0);
121     expect(lastCueAt(cues, 2.5)).toBe(0);
122     expect(lastCueAt(cues, 3)).toBe(1);
123     expect(lastCueAt(cues, 99)).toBe(1);
124     expect(lastCueAt([], 1)).toBe(-1);
125   });
126 
127   it('agrees with a linear scan', () => {
128     const starts = fc
129       .array(fc.nat({ max: 100 }), { maxLength: 30 })
130       .map((s) => s.sort((a, b) => a - b));
131     fc.assert(
132       fc.property(starts, fc.integer({ min: -5, max: 110 }), (sorted, t) => {
133         const list = sorted.map((start) => ({ start }));
134         let linear = -1;
135         list.forEach((c, i) => {
136           if (c.start <= t) linear = i;
137         });
138         expect(lastCueAt(list, t)).toBe(linear);
139       }),
140     );
141   });
142 });