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


fixtures/sample-text.ts (4378 bytes)

1 // Ground truth for the synthetic fixtures.
2 // scripts/make-fixtures.ts renders these lines to page images and to
3 // text-layer PDFs. test/e2e checks OCR, the text layer and the alignment
4 // against this layout.
5 
6 export interface Fixture {
7   name: string;
8   /** tesseract language code. */
9   lang: string;
10   /** Font family list for the SVG renderer. */
11   font: string;
12   /** A TrueType file for the text-layer PDF. Absent: Helvetica. */
13   ttf?: string;
14   /** Pages, each a list of text lines. */
15   pages: string[][];
16   /** The subtitle cues, in order. */
17   cues: string[];
18 }
19 
20 /** Page geometry in image pixels (A4 at 150 dpi). */
21 export const PAGE = {
22   width: 1240,
23   height: 1754,
24   marginLeft: 120,
25   firstBaseline: 260,
26   lineHeight: 80,
27   fontSize: 40,
28 };
29 
30 /** Each cue lasts this long, one after the other. */
31 export const CUE_SECONDS = 2.5;
32 
33 /** One cue per line. */
34 function lineCues(pages: string[][]): string[] {
35   return pages.flat();
36 }
37 
38 /**
39  * One cue per line, except that the last line of each page and the first
40  * line of the next page form one cue. That cue crosses the page boundary.
41  */
42 function crossingCues(pages: string[][]): string[] {
43   const cues: string[] = [];
44   pages.forEach((lines, p) => {
45     const first = p > 0 ? 1 : 0;
46     const last = p < pages.length - 1 ? lines.length - 1 : lines.length;
47     cues.push(...lines.slice(first, last));
48     if (p < pages.length - 1) cues.push(`${lines[lines.length - 1]} ${pages[p + 1]![0]}`);
49   });
50   return cues;
51 }
52 
53 const ENG_PAGE_1 = [
54   'The morning sun rose over the quiet hills,',
55   'and the village below began to stir.',
56   'A baker opened his shutters with a yawn.',
57   'Two children raced along the river path,',
58   'their laughter carried on the cool wind.',
59   'Far away, a bell rang nine slow times.',
60   'The day had started, as days always do,',
61   'with nothing more than light and sound.',
62 ];
63 
64 const ENG_PAGE_2 = [
65   'It rolled across the fields and woke the geese,',
66   'who answered with a chorus of their own.',
67   'By noon the market square was full of carts,',
68   'and every stall had something bright to sell.',
69   'An old man sat beneath the chestnut tree',
70   'and told the children stories of the sea.',
71   'When evening came the lamps were lit again,',
72   'and the hills grew quiet, as they always do.',
73 ];
74 
75 const JPN_PAGE = [
76   '吾輩は猫である。名前はまだ無い。',
77   'どこで生れたかとんと見当がつかぬ。',
78   '何でも薄暗いじめじめした所で',
79   'ニャーニャー泣いていた事だけは記憶している。',
80   '吾輩はここで始めて人間というものを見た。',
81   'しかもあとで聞くとそれは書生という',
82   '人間中で一番獰悪な種族であったそうだ。',
83 ];
84 
85 export const ENG: Fixture = {
86   name: 'sample-eng',
87   lang: 'eng',
88   font: 'Georgia, Times New Roman, serif',
89   pages: [ENG_PAGE_1],
90   cues: lineCues([ENG_PAGE_1]),
91 };
92 
93 export const JPN: Fixture = {
94   name: 'sample-jpn',
95   lang: 'jpn',
96   font: 'Yu Mincho, MS Mincho, Meiryo, sans-serif',
97   ttf: 'C:\\Windows\\Fonts\\yumin.ttf',
98   pages: [JPN_PAGE],
99   cues: lineCues([JPN_PAGE]),
100 };
101 
102 /** Two pages. One cue crosses from page 1 to page 2. */
103 export const ENG2: Fixture = {
104   name: 'sample-eng-2p',
105   lang: 'eng',
106   font: ENG.font,
107   pages: [ENG_PAGE_1, ENG_PAGE_2],
108   cues: crossingCues([ENG_PAGE_1, ENG_PAGE_2]),
109 };
110 
111 export const FIXTURES: Fixture[] = [ENG, JPN, ENG2];
112 
113 /** Vertical pixel band that line `i` of a page occupies. */
114 export function lineBand(i: number): { top: number; bottom: number } {
115   const top = PAGE.firstBaseline + i * PAGE.lineHeight - PAGE.lineHeight * 0.75;
116   return { top, bottom: top + PAGE.lineHeight };
117 }
118 
119 function srtTime(seconds: number): string {
120   const ms = Math.round(seconds * 1000);
121   const h = Math.floor(ms / 3600000);
122   const m = Math.floor((ms % 3600000) / 60000);
123   const s = Math.floor((ms % 60000) / 1000);
124   const frac = ms % 1000;
125   const pad = (n: number, w: number) => String(n).padStart(w, '0');
126   return `${pad(h, 2)}:${pad(m, 2)}:${pad(s, 2)},${pad(frac, 3)}`;
127 }
128 
129 /** SRT text with one cue per entry of `cues`, CUE_SECONDS each, starting at 0. */
130 export function toSrt(fixture: Fixture): string {
131   return fixture.cues
132     .map((line, i) => {
133       const start = i * CUE_SECONDS;
134       return `${i + 1}\n${srtTime(start)} --> ${srtTime(start + CUE_SECONDS)}\n${line}\n`;
135     })
136     .join('\n');
137 }