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


scripts/make-fixtures.ts (5413 bytes)

1 // Build the synthetic fixtures in fixtures/:
2 //   <name>.png, <name>-<n>.png  scanned-like page images (SVG text rendered with sharp)
3 //   <name>.pdf        image-only PDF, one page per image, as a scanner would produce
4 //   <name>-text.pdf   the same text as a text layer (pdf-lib, Helvetica or a TrueType file)
5 //   <name>.srt        the subtitle cues
6 //   silence.wav       silent audio long enough for all cues
7 // Run: npm run fixtures
8 
9 import fontkit from '@pdf-lib/fontkit';
10 import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
11 import path from 'node:path';
12 import { PDFDocument, StandardFonts, type PDFFont } from 'pdf-lib';
13 import sharp from 'sharp';
14 import { CUE_SECONDS, FIXTURES, PAGE, toSrt, type Fixture } from '../fixtures/sample-text';
15 
16 const OUT = path.resolve('fixtures');
17 
18 /** Text-layer page geometry in points (A4). */
19 const TEXT_PAGE = {
20   width: 595,
21   height: 842,
22   marginLeft: 60,
23   firstBaseline: 100,
24   lineHeight: 30,
25   fontSize: 14,
26 };
27 
28 function escapeXml(s: string): string {
29   return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
30 }
31 
32 function pageSvg(fixture: Fixture, lines: string[]): string {
33   const text = lines
34     .map(
35       (line, i) =>
36         `<text x="${PAGE.marginLeft}" y="${PAGE.firstBaseline + i * PAGE.lineHeight}">${escapeXml(line)}</text>`,
37     )
38     .join('\n');
39   return `<svg xmlns="http://www.w3.org/2000/svg" width="${PAGE.width}" height="${PAGE.height}">
40   <rect width="100%" height="100%" fill="#f4f1ea"/>
41   <g font-family="${fixture.font}" font-size="${PAGE.fontSize}" fill="#1a1a1a">
42 ${text}
43   </g>
44 </svg>`;
45 }
46 
47 async function renderPng(fixture: Fixture, lines: string[]): Promise<Buffer> {
48   // A light blur and a small rotation make the page look scanned.
49   return sharp(Buffer.from(pageSvg(fixture, lines)))
50     .rotate(0.4, { background: '#f4f1ea' })
51     .resize(PAGE.width, PAGE.height, { fit: 'cover' })
52     .blur(0.6)
53     .png()
54     .toBuffer();
55 }
56 
57 async function toPdf(pngs: Buffer[]): Promise<Uint8Array> {
58   const doc = await PDFDocument.create();
59   for (const png of pngs) {
60     const image = await doc.embedPng(png);
61     // 150 dpi image on a 72 dpi page.
62     const page = doc.addPage([PAGE.width * 0.48, PAGE.height * 0.48]);
63     page.drawImage(image, { x: 0, y: 0, width: page.getWidth(), height: page.getHeight() });
64   }
65   return doc.save();
66 }
67 
68 /** The same text as a text layer. Returns null when the TrueType file is not on this machine. */
69 async function toTextPdf(fixture: Fixture): Promise<Uint8Array | null> {
70   const doc = await PDFDocument.create();
71   let font: PDFFont;
72   if (fixture.ttf) {
73     try {
74       await access(fixture.ttf);
75     } catch {
76       return null;
77     }
78     doc.registerFontkit(fontkit);
79     font = await doc.embedFont(await readFile(fixture.ttf), { subset: true });
80   } else {
81     font = await doc.embedFont(StandardFonts.Helvetica);
82   }
83   for (const lines of fixture.pages) {
84     const page = doc.addPage([TEXT_PAGE.width, TEXT_PAGE.height]);
85     lines.forEach((line, i) => {
86       page.drawText(line, {
87         x: TEXT_PAGE.marginLeft,
88         y: TEXT_PAGE.height - TEXT_PAGE.firstBaseline - i * TEXT_PAGE.lineHeight,
89         size: TEXT_PAGE.fontSize,
90         font,
91       });
92     });
93   }
94   return doc.save();
95 }
96 
97 /** Silent 8 kHz, 8-bit, mono PCM WAV. */
98 function silentWav(seconds: number): Buffer {
99   const rate = 8000;
100   const samples = Math.ceil(seconds * rate);
101   const buf = Buffer.alloc(44 + samples);
102   buf.write('RIFF', 0);
103   buf.writeUInt32LE(36 + samples, 4);
104   buf.write('WAVE', 8);
105   buf.write('fmt ', 12);
106   buf.writeUInt32LE(16, 16); // fmt chunk size
107   buf.writeUInt16LE(1, 20); // PCM
108   buf.writeUInt16LE(1, 22); // mono
109   buf.writeUInt32LE(rate, 24);
110   buf.writeUInt32LE(rate, 28); // byte rate
111   buf.writeUInt16LE(1, 32); // block align
112   buf.writeUInt16LE(8, 34); // bits per sample
113   buf.write('data', 36);
114   buf.writeUInt32LE(samples, 40);
115   buf.fill(128, 44); // 8-bit PCM silence is 128
116   return buf;
117 }
118 
119 /** The image file of page `p` of a fixture: `<name>.png` for one page, `<name>-<p+1>.png` for more. */
120 export function pngName(fixture: Fixture, p: number): string {
121   return fixture.pages.length === 1 ? `${fixture.name}.png` : `${fixture.name}-${p + 1}.png`;
122 }
123 
124 async function main(): Promise<void> {
125   await mkdir(OUT, { recursive: true });
126   let maxSeconds = 0;
127   for (const fixture of FIXTURES) {
128     const pngs: Buffer[] = [];
129     for (const [p, lines] of fixture.pages.entries()) {
130       const png = await renderPng(fixture, lines);
131       pngs.push(png);
132       await writeFile(path.join(OUT, pngName(fixture, p)), png);
133     }
134     await writeFile(path.join(OUT, `${fixture.name}.pdf`), await toPdf(pngs));
135     await writeFile(path.join(OUT, `${fixture.name}.srt`), toSrt(fixture));
136     const text = await toTextPdf(fixture);
137     if (text) await writeFile(path.join(OUT, `${fixture.name}-text.pdf`), text);
138     else console.log(`skipped ${fixture.name}-text.pdf: ${fixture.ttf} is not on this machine`);
139     maxSeconds = Math.max(maxSeconds, fixture.cues.length * CUE_SECONDS);
140     console.log(
141       `wrote ${fixture.name}: ${fixture.pages.length} page(s), ${fixture.cues.length} cues${text ? ', text layer' : ''}`,
142     );
143   }
144   await writeFile(path.join(OUT, 'silence.wav'), silentWav(maxSeconds + 1));
145   console.log(`wrote silence.wav (${maxSeconds + 1} s)`);
146 }
147 
148 main().catch((err) => {
149   console.error(err);
150   process.exit(1);
151 });