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, '&').replace(/</g, '<').replace(/>/g, '>'); 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 });