diff --git a/AGENTS.md b/AGENTS.md index 7d6647f..06f67cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -302,6 +302,7 @@ For detailed instructions on adding new features, including step-by-step develop - **Build on the target architecture**: sharp, `@napi-rs/canvas` and the Prisma engine ship as prebuilt binaries that `npm ci` and `prisma generate` install for the host only, and electron-builder does not fetch those of another arch. Packaging `--x64` on an arm64 machine produces an app that crashes at startup, so `release.yml` runs one job per arch on a native runner. - **`npm run verify:package`** (`scripts/verify-package-arch.mjs`) checks an unpacked package: binary architectures, required modules, and, when the host can run the package, a real load test in the packaged Electron. Pass `-- --platform --arch ` for another target. CI fails on any problem. - **New native dependency**: add it to `requiredModules` in the verify script and to `scripts/package-load-check.cjs`. +- **PDF rendering**: go through `renderPdfPages` (`src/core/infrastructure/adapters/split/PDFPageRenderer.ts`), never `pdf-to-png-converter` directly. The converter looks for the pdf.js CMaps and standard fonts under `process.cwd()`, which only works in `npm run dev`; without them CJK text silently disappears from the page images. The renderer overrides those paths through a non-public file of the converter, so the converter's version is pinned: when upgrading, run the integration test and `npm run verify:package`. Both render `scripts/fixtures/pdf-render-check.pdf` from a directory without `node_modules`. - **Windows is x64 only**: Prisma has no Windows arm64 engine, and an arm64 process can't load the x64 one. Windows on Arm runs the x64 build under emulation. - **`latest-mac.yml`**: each mac job writes its own, and the `upload-manifests` job in `release.yml` merges them, x64 first. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 1cd8d26..88f363c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -7,6 +7,16 @@ export default defineConfig({ plugins: [externalizeDepsPlugin()], build: { outDir: "dist/main", + rollupOptions: { + input: { + index: resolve(__dirname, "src/main/index.ts"), + // Imported by scripts/package-load-check.cjs to render a sample in the packaged app. + "pdf-page-renderer": resolve( + __dirname, + "src/core/infrastructure/adapters/split/PDFPageRenderer.ts", + ), + }, + }, }, }, preload: { diff --git a/package-lock.json b/package-lock.json index d0f5f8e..270c05b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "electron-updater": "^6.7.3", "katex": "^0.16.21", "pdf-lib": "^1.17.1", - "pdf-to-png-converter": "^3.11.0", + "pdf-to-png-converter": "3.11.0", "prisma": "^6.5.0", "prismjs": "^1.30.0", "react": "^18.2.0", diff --git a/package.json b/package.json index 2e8ffd7..9cf6e26 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,7 @@ "electron-updater": "^6.7.3", "katex": "^0.16.21", "pdf-lib": "^1.17.1", - "pdf-to-png-converter": "^3.11.0", + "pdf-to-png-converter": "3.11.0", "prisma": "^6.5.0", "prismjs": "^1.30.0", "react": "^18.2.0", diff --git a/scripts/fixtures/build-pdf-render-check.py b/scripts/fixtures/build-pdf-render-check.py new file mode 100644 index 0000000..e4b2e48 --- /dev/null +++ b/scripts/fixtures/build-pdf-render-check.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Builds pdf-render-check.pdf, the sample behind the PDF rendering checks (needs fonttools). + +The embedded font's only glyph is a solid box and every CID maps to it, so whether text was drawn +is a pixel count that depends on neither the system's fonts nor the characters used. + + Page 1 embedded font, predefined CMap UniGB-UCS2-H needs pdfjs-dist/cmaps + Page 2 embedded font, predefined CMap GBK-EUC-H needs pdfjs-dist/cmaps + Page 3 embedded font, Identity-H, Identity ordering needs no CMap file + Page 4 embedded font, Identity-H, Adobe-GB1 ordering needs pdfjs-dist/cmaps (Adobe-GB1-UCS2) + Page 5 Helvetica, not embedded needs pdfjs-dist/standard_fonts + +Usage: python3 scripts/fixtures/build-pdf-render-check.py +""" + +import io +import zlib +from pathlib import Path + +from fontTools.fontBuilder import FontBuilder +from fontTools.pens.ttGlyphPen import TTGlyphPen + +UNITS_PER_EM = 1000 +# Larger than any CID in the Adobe-GB1 character collection. +CID_COUNT = 32768 +BOX_GID = 1 +TEXT = "中文" + + +def build_box_font(): + pen = TTGlyphPen(None) + pen.moveTo((0, 0)) + pen.lineTo((0, UNITS_PER_EM)) + pen.lineTo((UNITS_PER_EM, UNITS_PER_EM)) + pen.lineTo((UNITS_PER_EM, 0)) + pen.closePath() + box = pen.glyph() + + builder = FontBuilder(UNITS_PER_EM, isTTF=True) + builder.setupGlyphOrder([".notdef", "box"]) + builder.setupCharacterMap({0x25A0: "box"}) + builder.setupGlyf({".notdef": TTGlyphPen(None).glyph(), "box": box}) + builder.setupHorizontalMetrics({".notdef": (UNITS_PER_EM, 0), "box": (UNITS_PER_EM, 0)}) + builder.setupHorizontalHeader(ascent=UNITS_PER_EM, descent=0) + builder.setupNameTable({"familyName": "RenderCheckBox", "styleName": "Regular"}) + builder.setupOS2(sTypoAscender=UNITS_PER_EM, sTypoDescender=0, usWinAscent=UNITS_PER_EM, usWinDescent=0) + builder.setupPost() + # Fixed timestamps keep the output reproducible. + builder.font["head"].created = builder.font["head"].modified = 0 + output = io.BytesIO() + builder.font.recalcTimestamp = False + builder.save(output) + return output.getvalue() + + +class Pdf: + def __init__(self): + self.objects = [] + + def add(self, body): + self.objects.append(body if isinstance(body, bytes) else body.encode()) + return len(self.objects) + + def add_stream(self, data, extra=""): + compressed = zlib.compress(data, 9) + header = f"<< /Length {len(compressed)} /Filter /FlateDecode {extra}>>\nstream\n" + return self.add(header.encode() + compressed + b"\nendstream") + + def serialize(self, root): + out = bytearray(b"%PDF-1.5\n%\xe2\xe3\xcf\xd3\n") + offsets = [] + for number, body in enumerate(self.objects, 1): + offsets.append(len(out)) + out += f"{number} 0 obj\n".encode() + body + b"\nendobj\n" + xref = len(out) + out += f"xref\n0 {len(offsets) + 1}\n0000000000 65535 f \n".encode() + for offset in offsets: + out += f"{offset:010d} 00000 n \n".encode() + out += f"trailer\n<< /Size {len(offsets) + 1} /Root {root} 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode() + return bytes(out) + + +def main(): + pdf = Pdf() + font_data = build_box_font() + font_file = pdf.add_stream(font_data, f"/Length1 {len(font_data)} ") + descriptor = pdf.add( + "<< /Type /FontDescriptor /FontName /RenderCheckBox /Flags 4 " + f"/FontBBox [0 0 {UNITS_PER_EM} {UNITS_PER_EM}] /ItalicAngle 0 /Ascent {UNITS_PER_EM} /Descent 0 " + f"/CapHeight {UNITS_PER_EM} /StemV 80 /FontFile2 {font_file} 0 R >>" + ) + cid_to_gid = pdf.add_stream(BOX_GID.to_bytes(2, "big") * CID_COUNT) + + def composite_font(encoding, ordering="GB1", widths=""): + descendant = pdf.add( + "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /RenderCheckBox " + f"/CIDSystemInfo << /Registry (Adobe) /Ordering ({ordering}) /Supplement 0 >> " + f"/FontDescriptor {descriptor} 0 R /DW {UNITS_PER_EM} {widths}/CIDToGIDMap {cid_to_gid} 0 R >>" + ) + return pdf.add( + f"<< /Type /Font /Subtype /Type0 /BaseFont /RenderCheckBox /Encoding /{encoding} " + f"/DescendantFonts [{descendant} 0 R] >>" + ) + + helvetica = pdf.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + pages = [ + (composite_font("UniGB-UCS2-H"), TEXT.encode("utf-16-be").hex()), + (composite_font("GBK-EUC-H"), TEXT.encode("gbk").hex()), + # Identity-H codes are CIDs. 0001 is not .notdef, and maps to the box like the rest. + (composite_font("Identity-H", ordering="Identity"), "00010002"), + # pdf.js caches fonts by a hash that ignores the ordering. Without its own /W this page + # would reuse the font of the previous one and never load Adobe-GB1-UCS2. + (composite_font("Identity-H", widths=f"/W [1 [{UNITS_PER_EM}]] "), "00010002"), + (helvetica, "Standard font".encode("ascii").hex()), + ] + + pages_number = len(pdf.objects) + 2 * len(pages) + 1 + kids = [] + for font, text in pages: + size = 20 if font == helvetica else 40 + content = pdf.add_stream(f"BT /F1 {size} Tf 20 30 Td <{text}> Tj ET".encode()) + kids.append( + pdf.add( + f"<< /Type /Page /Parent {pages_number} 0 R /MediaBox [0 0 200 100] /Contents {content} 0 R " + f"/Resources << /Font << /F1 {font} 0 R >> >> >>" + ) + ) + pages_object = pdf.add( + f"<< /Type /Pages /Kids [{' '.join(f'{kid} 0 R' for kid in kids)}] /Count {len(kids)} >>" + ) + assert pages_object == pages_number + root = pdf.add(f"<< /Type /Catalog /Pages {pages_object} 0 R >>") + + target = Path(__file__).with_name("pdf-render-check.pdf") + target.write_bytes(pdf.serialize(root)) + print(f"Wrote {target} ({target.stat().st_size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/scripts/fixtures/pdf-render-check.cjs b/scripts/fixtures/pdf-render-check.cjs new file mode 100644 index 0000000..5bf9cdc --- /dev/null +++ b/scripts/fixtures/pdf-render-check.cjs @@ -0,0 +1,54 @@ +/** + * Judges how pdf-render-check.pdf was rendered (see build-pdf-render-check.py for its pages). + * Shared by scripts/package-load-check.cjs and the PDF rendering integration test. + */ + +const path = require('node:path'); + +const SAMPLE_PDF = path.join(__dirname, 'pdf-render-check.pdf'); +const PAGE_COUNT = 5; +const PAGE_WIDTH_PT = 200; +const BOX_SIZE_PT = 40; +const BOXES_PER_PAGE = 2; +// Pages 1-4 hold text in the fixture's box font. Page 5 falls back to a system font when the +// standard font is missing, so only the warning below can tell. +const BOX_PAGES = ['UniGB-UCS2-H', 'GBK-EUC-H', 'Identity-H/Identity', 'Identity-H/Adobe-GB1']; +// Leaves room for anti-aliasing, while a single missing box still falls short. +const MIN_BOX_COVERAGE = 0.9; +// pdf.js reports both a CMap and a standard font it can't read this way. +const LOAD_FAILURE = /Unable to load/; + +async function countDarkPixels(png, sharp) { + const { data, info } = await sharp(png) + .flatten({ background: '#fff' }) + .greyscale() + .raw() + .toBuffer({ resolveWithObject: true }); + return { width: info.width, dark: data.reduce((sum, value) => sum + (value < 128 ? 1 : 0), 0) }; +} + +/** + * @param {Buffer[]} pngs the rendered pages, in order + * @param {string[]} warnings what pdf.js wrote to console.warn during the rendering + * @param {Function} sharp the sharp module; the packaged app has to pass its own + * @returns {Promise} problems found, empty when the rendering is complete + */ +async function findRenderProblems(pngs, warnings, sharp) { + const problems = warnings.filter((warning) => LOAD_FAILURE.test(warning)); + if (pngs.length !== PAGE_COUNT) { + problems.push(`Expected ${PAGE_COUNT} pages, got ${pngs.length}`); + } + + for (const [index, name] of BOX_PAGES.entries()) { + if (!pngs[index]) continue; + const { width, dark } = await countDarkPixels(pngs[index], sharp); + const scale = width / PAGE_WIDTH_PT; + const expected = BOXES_PER_PAGE * (BOX_SIZE_PT * scale) ** 2; + if (dark < expected * MIN_BOX_COVERAGE) { + problems.push(`Page ${index + 1} (${name}) lost its text: ${dark} of ${expected} dark pixels`); + } + } + return problems; +} + +module.exports = { SAMPLE_PDF, findRenderProblems }; diff --git a/scripts/fixtures/pdf-render-check.pdf b/scripts/fixtures/pdf-render-check.pdf new file mode 100644 index 0000000..cb6df4b Binary files /dev/null and b/scripts/fixtures/pdf-render-check.pdf differ diff --git a/scripts/package-load-check.cjs b/scripts/package-load-check.cjs index 62f016b..738cde7 100644 --- a/scripts/package-load-check.cjs +++ b/scripts/package-load-check.cjs @@ -1,5 +1,5 @@ /** - * Loads and exercises the app's native modules from an unpacked package. + * Loads and exercises the app's native modules from an unpacked package, and renders a sample PDF. * verify-package-arch.mjs runs this inside the packaged Electron (ELECTRON_RUN_AS_NODE=1), * so modules resolve the way they do in the app, through app.asar and app.asar.unpacked. * @@ -9,8 +9,11 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); +const { pathToFileURL } = require('node:url'); +const { SAMPLE_PDF, findRenderProblems } = require('./fixtures/pdf-render-check.cjs'); -const appModules = path.join(process.argv[2], 'app.asar', 'node_modules'); +const appDir = path.join(path.resolve(process.argv[2]), 'app.asar'); +const appModules = path.join(appDir, 'node_modules'); const loadFromApp = (name) => require(path.join(appModules, name)); async function checkSharp() { @@ -26,6 +29,27 @@ async function checkCanvas() { loadFromApp('pdf-to-png-converter'); } +/** Runs the app's own renderer, so this fails when the renderer stops finding the pdf.js data. */ +async function checkPdfRendering() { + const rendererUrl = pathToFileURL(path.join(appDir, 'dist', 'main', 'pdf-page-renderer.js')).href; + const { renderPdfPages } = await import(rendererUrl); + + const warnings = []; + const consoleWarn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + let pages; + try { + pages = await renderPdfPages(SAMPLE_PDF, { returnPageContent: true, viewportScale: 2 }); + } finally { + console.warn = consoleWarn; + } + + const pngs = pages.map((page) => page.content); + const problems = await findRenderProblems(pngs, warnings, loadFromApp('sharp')); + if (problems.length > 0) throw new Error(problems.join('; ')); + return `${pngs.length} pages`; +} + async function checkPrisma() { const { PrismaClient } = loadFromApp('@prisma/client'); const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'markpdfdown-load-check-')); @@ -41,7 +65,19 @@ async function checkPrisma() { } async function main() { - const checks = { sharp: checkSharp, '@napi-rs/canvas': checkCanvas, 'Prisma query engine': checkPrisma }; + const checks = { + sharp: checkSharp, + '@napi-rs/canvas': checkCanvas, + 'Prisma query engine': checkPrisma, + 'PDF rendering': checkPdfRendering, + }; + + // Start from a directory holding nothing, like the installed app. Must precede every check: + // pdf-to-png-converter reads the cwd once when it loads, and from the repo root (where CI runs + // this) it would find the repo's node_modules, passing with the app's own lookup broken (#85). + const emptyCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'markpdfdown-load-check-cwd-')); + process.chdir(emptyCwd); + let failed = false; for (const [name, check] of Object.entries(checks)) { try { @@ -52,6 +88,9 @@ async function main() { console.error(` load FAIL ${name}: ${error.message}`); } } + + process.chdir(os.tmpdir()); + fs.rmSync(emptyCwd, { recursive: true, force: true }); process.exit(failed ? 1 : 0); } diff --git a/src/core/infrastructure/adapters/split/PDFPageRenderer.ts b/src/core/infrastructure/adapters/split/PDFPageRenderer.ts new file mode 100644 index 0000000..d6b21ee --- /dev/null +++ b/src/core/infrastructure/adapters/split/PDFPageRenderer.ts @@ -0,0 +1,55 @@ +import { existsSync } from 'fs'; +import { createRequire } from 'module'; +import path from 'path'; +import { pdfToPng } from 'pdf-to-png-converter'; +// Not public API, which is why package.json pins the exact version of pdf-to-png-converter. +import { DOCUMENT_INIT_PARAMS_DEFAULTS } from 'pdf-to-png-converter/out/const.js'; + +type RenderOptions = NonNullable[1]>; + +/** pdf.js VerbosityLevel.WARNINGS: a CMap or font it can't load is only ever reported as a warning. */ +const PDFJS_WARNINGS = 1; + +/** + * Points pdf.js at the CMaps and standard fonts of the installed pdfjs-dist. + * + * pdf-to-png-converter derives these paths from process.cwd() when it loads and has no option to + * change them. Only `npm run dev` starts in a directory that holds them. Anywhere else pdf.js fails + * to load a predefined CMap and silently draws no text for that font (#85). + * + * @throws Error if the files are missing: rendering without them would silently lose text. + */ +function pointPdfjsAtInstalledAssets(): void { + // pdfjs-dist is a dependency of the converter rather than ours, so resolve it from there. + const converterEntry = createRequire(import.meta.url).resolve('pdf-to-png-converter'); + const pdfjsRoot = path.dirname(createRequire(converterEntry).resolve('pdfjs-dist/package.json')); + const cMapDir = path.join(pdfjsRoot, 'cmaps'); + const standardFontDir = path.join(pdfjsRoot, 'standard_fonts'); + + const expectedFiles = [ + path.join(cMapDir, 'UniGB-UCS2-H.bcmap'), + path.join(standardFontDir, 'LiberationSans-Regular.ttf'), + ]; + const missingFile = expectedFiles.find((file) => !existsSync(file)); + if (missingFile) { + console.error(`[PDFPageRenderer] Missing PDF.js data file: ${missingFile}`); + // No path in the message: PDFSplitter classifies errors by their words, and a path can hold any. + throw new Error('PDF.js font data is missing from this installation. Please reinstall the application.'); + } + + // pdf.js requires the trailing slash, also on Windows, and appends the file name to it. + DOCUMENT_INIT_PARAMS_DEFAULTS.cMapUrl = `${cMapDir}/`; + DOCUMENT_INIT_PARAMS_DEFAULTS.standardFontDataUrl = `${standardFontDir}/`; +} + +/** + * Renders PDF pages to PNG. The only way into pdf-to-png-converter, so that no caller depends on + * the directory the app was started from. + * + * Bundled as its own entry (dist/main/pdf-page-renderer.js): scripts/package-load-check.cjs imports + * it to exercise this code in the packaged app. + */ +export async function renderPdfPages(pdfPath: string, options: RenderOptions) { + pointPdfjsAtInstalledAssets(); + return pdfToPng(pdfPath, { verbosityLevel: PDFJS_WARNINGS, ...options }); +} diff --git a/src/core/infrastructure/adapters/split/PDFSplitter.ts b/src/core/infrastructure/adapters/split/PDFSplitter.ts index d8f4497..476c171 100644 --- a/src/core/infrastructure/adapters/split/PDFSplitter.ts +++ b/src/core/infrastructure/adapters/split/PDFSplitter.ts @@ -1,4 +1,3 @@ -import { pdfToPng } from 'pdf-to-png-converter'; import { PDFDocument } from 'pdf-lib'; import { promises as fs } from 'fs'; import path from 'path'; @@ -7,6 +6,7 @@ import { Task } from '../../../../shared/types/index.js'; import { PageRangeParser } from '../../../domain/split/PageRangeParser.js'; import { ImagePathUtil } from './ImagePathUtil.js'; import { FileWaitUtil } from './FileWaitUtil.js'; +import { renderPdfPages } from './PDFPageRenderer.js'; import { WORKER_CONFIG } from '../../config/worker.config.js'; /** A rendered page, held in memory until it is written to the split directory. */ @@ -129,7 +129,6 @@ export class PDFSplitter implements ISplitter { returnPageContent: true, viewportScale: WORKER_CONFIG.splitter.viewportScale, strictPagesToProcess: false, - verbosityLevel: 0, }; // Only add pagesToProcess if user explicitly specified a page range @@ -140,7 +139,7 @@ export class PDFSplitter implements ISplitter { } // Otherwise, don't specify pagesToProcess to convert all pages - const result = await pdfToPng(pdfPath, options); + const result = await renderPdfPages(pdfPath, options); if (!result || result.length === 0) { throw new Error('PDF conversion produced no output'); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts new file mode 100644 index 0000000..148852a --- /dev/null +++ b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import { createRequire } from 'module'; +import os from 'os'; +import path from 'path'; +import sharp from 'sharp'; +import { ImagePathUtil } from '../ImagePathUtil.js'; + +const require = createRequire(import.meta.url); +const { SAMPLE_PDF, findRenderProblems } = require('../../../../../../scripts/fixtures/pdf-render-check.cjs'); + +/** + * The real pdf-to-png-converter and pdf.js, started from a directory without a node_modules like + * the installed app (#85). Nothing may load the converter statically: it reads the cwd when it + * loads, so everything that reaches it is imported after the chdir below. + */ +describe('PDF rendering from a cwd without pdfjs-dist', () => { + const TASK_ID = 'task-1'; + const originalCwd = process.cwd(); + + let workDir: string; + let uploadsDir: string; + let splitter: { split: (task: { id: string; filename: string; page_range: string }) => Promise }; + + beforeAll(async () => { + workDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'pdf-render-cwd-'))); + uploadsDir = path.join(workDir, 'userData', 'files'); + await fs.mkdir(path.join(uploadsDir, TASK_ID), { recursive: true }); + await fs.copyFile(SAMPLE_PDF, path.join(uploadsDir, TASK_ID, 'sample.pdf')); + process.chdir(workDir); + + // Without this situation the test below would prove nothing. Should a new version of the + // converter fail here, it stopped using the cwd and PDFPageRenderer may be able to go. + const { DOCUMENT_INIT_PARAMS_DEFAULTS } = await import('pdf-to-png-converter/out/const.js'); + expect(DOCUMENT_INIT_PARAMS_DEFAULTS.cMapUrl).toContain(workDir); + + const { PDFSplitter } = await import('../PDFSplitter.js'); + ImagePathUtil.init(uploadsDir); + splitter = new PDFSplitter(uploadsDir); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(async () => { + ImagePathUtil.reset(); + process.chdir(originalCwd); + await fs.rm(workDir, { recursive: true, force: true }); + }); + + it('should draw predefined CMap, Identity-H and standard font text', { timeout: 60_000 }, async () => { + const warnings: string[] = []; + vi.spyOn(console, 'warn').mockImplementation((...args) => { + warnings.push(args.join(' ')); + }); + + const { pages } = await splitter.split({ id: TASK_ID, filename: 'sample.pdf', page_range: '' }); + + const pngs = await Promise.all(pages.map((page: { imagePath: string }) => fs.readFile(page.imagePath))); + expect(await findRenderProblems(pngs, warnings, sharp)).toEqual([]); + }); +}); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts new file mode 100644 index 0000000..4147cb6 --- /dev/null +++ b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, readFileSync } from 'fs'; +import os from 'os'; +import path from 'path'; + +vi.mock('pdf-to-png-converter', () => ({ + pdfToPng: vi.fn(), +})); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, default: actual, existsSync: vi.fn(actual.existsSync) }; +}); + +import { pdfToPng } from 'pdf-to-png-converter'; +import { DOCUMENT_INIT_PARAMS_DEFAULTS } from 'pdf-to-png-converter/out/const.js'; +import { renderPdfPages } from '../PDFPageRenderer.js'; + +describe('renderPdfPages', () => { + beforeEach(() => { + vi.mocked(pdfToPng).mockReset(); + vi.mocked(pdfToPng).mockResolvedValue([]); + vi.mocked(existsSync).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should have the pdf.js data of the installed package in place before it converts', async () => { + let urlsSeenByConverter: { cMapUrl?: string; standardFontDataUrl?: string } = {}; + vi.mocked(pdfToPng).mockImplementation(async () => { + urlsSeenByConverter = { ...DOCUMENT_INIT_PARAMS_DEFAULTS }; + return []; + }); + vi.spyOn(process, 'cwd').mockReturnValue(path.join(os.tmpdir(), 'unrelated-cwd')); + + await renderPdfPages('/doc.pdf', {}); + + const { cMapUrl, standardFontDataUrl } = urlsSeenByConverter; + // The trailing slash is mandatory for pdf.js, which throws without it. + expect(cMapUrl).toMatch(/pdfjs-dist[\\/]cmaps\/$/); + expect(standardFontDataUrl).toMatch(/pdfjs-dist[\\/]standard_fonts\/$/); + expect(path.isAbsolute(cMapUrl!)).toBe(true); + expect(path.isAbsolute(standardFontDataUrl!)).toBe(true); + // pdf.js builds file paths by appending the name, so that is how they must work. + expect(readFileSync(`${cMapUrl}UniGB-UCS2-H.bcmap`).length).toBeGreaterThan(0); + expect(readFileSync(`${standardFontDataUrl}LiberationSans-Regular.ttf`).length).toBeGreaterThan(0); + }); + + it('should pass the options on and turn pdf.js warnings on', async () => { + await renderPdfPages('/doc.pdf', { viewportScale: 2, pagesToProcess: [3] }); + + expect(pdfToPng).toHaveBeenCalledWith('/doc.pdf', { + verbosityLevel: 1, + viewportScale: 2, + pagesToProcess: [3], + }); + }); + + it('should return what the converter returns', async () => { + const pages = [{ pageNumber: 1, name: 'p1', content: Buffer.from('png'), path: '', width: 1, height: 1 }]; + vi.mocked(pdfToPng).mockResolvedValue(pages); + + await expect(renderPdfPages('/doc.pdf', {})).resolves.toBe(pages); + }); + + it.each([ + ['CMaps', 'UniGB-UCS2-H.bcmap'], + ['standard fonts', 'LiberationSans-Regular.ttf'], + ])('should refuse to render without the %s rather than lose text', async (_label, fileName) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(existsSync).mockImplementation((file) => !String(file).endsWith(fileName)); + + await expect(renderPdfPages('/doc.pdf', {})).rejects.toThrow('PDF.js font data is missing'); + + expect(pdfToPng).not.toHaveBeenCalled(); + expect(consoleError.mock.calls[0][0]).toContain(fileName); + }); + + it('should keep paths out of the error, whose words PDFSplitter classifies', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(existsSync).mockReturnValue(false); + + const error: Error = await renderPdfPages('/doc.pdf', {}).catch((e) => e); + + expect(error.message).not.toContain('pdfjs-dist'); + expect(error.message).not.toContain(path.sep); + }); +}); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts index 71955b5..8738ddf 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts @@ -4,9 +4,9 @@ import { ImagePathUtil } from '../ImagePathUtil.js'; import { promises as fs } from 'fs'; import path from 'path'; -// Mock pdf-to-png-converter -vi.mock('pdf-to-png-converter', () => ({ - pdfToPng: vi.fn(), +// Mock the renderer, the splitter's only way to pdf-to-png-converter +vi.mock('../PDFPageRenderer.js', () => ({ + renderPdfPages: vi.fn(), })); // Mock pdf-lib @@ -37,7 +37,7 @@ vi.mock('fs', () => ({ }, })); -import { pdfToPng } from 'pdf-to-png-converter'; +import { renderPdfPages } from '../PDFPageRenderer.js'; import { PDFDocument } from 'pdf-lib'; import { FileWaitUtil } from '../FileWaitUtil.js'; @@ -80,8 +80,8 @@ describe('PDFSplitter', () => { page_range: '', }; - // Mock pdfToPng to return 3 pages (PDFDocument.load already mocked in beforeEach) - vi.mocked(pdfToPng).mockResolvedValueOnce([ + // Mock the renderer to return 3 pages (PDFDocument.load already mocked in beforeEach) + vi.mocked(renderPdfPages).mockResolvedValueOnce([ { path: '/tmp/page-1.png', name: 'page-1.png', page: 1, content: Buffer.from('') }, { path: '/tmp/page-2.png', name: 'page-2.png', page: 2, content: Buffer.from('') }, { path: '/tmp/page-3.png', name: 'page-3.png', page: 3, content: Buffer.from('') }, @@ -110,8 +110,8 @@ describe('PDFSplitter', () => { getPageCount: () => 5, } as any); - // Mock pdfToPng to return selected pages - vi.mocked(pdfToPng).mockResolvedValueOnce([ + // Mock the renderer to return selected pages + vi.mocked(renderPdfPages).mockResolvedValueOnce([ { path: '/tmp/page-1.png', name: 'page-1.png', page: 1, content: Buffer.from('') }, { path: '/tmp/page-3.png', name: 'page-3.png', page: 3, content: Buffer.from('') }, ]); @@ -138,7 +138,7 @@ describe('PDFSplitter', () => { getPageCount: () => 2, } as any); - vi.mocked(pdfToPng).mockResolvedValueOnce([ + vi.mocked(renderPdfPages).mockResolvedValueOnce([ { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, { path: '', name: 'temp-2.png', pageNumber: 2, content: Buffer.from('png-2') }, ]); @@ -161,13 +161,13 @@ describe('PDFSplitter', () => { }); it('should not hand an output folder to the converter', async () => { - vi.mocked(pdfToPng).mockResolvedValueOnce([ + vi.mocked(renderPdfPages).mockResolvedValueOnce([ { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, ] as any); await splitter.split({ id: 'task789', filename: 'document.pdf', page_range: '' }); - expect(vi.mocked(pdfToPng).mock.calls[0][1]).not.toHaveProperty('outputFolder'); + expect(vi.mocked(renderPdfPages).mock.calls[0][1]).not.toHaveProperty('outputFolder'); }); it('should throw error for missing task ID', async () => { @@ -241,13 +241,13 @@ describe('PDFSplitter', () => { beforeEach(() => { vi.spyOn(splitter as any, 'sleep').mockResolvedValue(undefined); vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.mocked(pdfToPng).mockResolvedValue([ + vi.mocked(renderPdfPages).mockResolvedValue([ { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, ] as any); }); afterEach(() => { - vi.mocked(pdfToPng).mockReset(); + vi.mocked(renderPdfPages).mockReset(); vi.restoreAllMocks(); }); @@ -269,7 +269,7 @@ describe('PDFSplitter', () => { expect(result.totalPages).toBe(1); expect(fs.writeFile).toHaveBeenCalledTimes(2); - expect(pdfToPng).toHaveBeenCalledTimes(1); + expect(renderPdfPages).toHaveBeenCalledTimes(1); }); it.each(['password', 'encrypted', 'invalid pdf'])( @@ -286,7 +286,7 @@ describe('PDFSplitter', () => { ); it('should fail without writing any page when one has no image', async () => { - vi.mocked(pdfToPng).mockResolvedValue([ + vi.mocked(renderPdfPages).mockResolvedValue([ { path: '', name: 'temp-1.png', pageNumber: 1, content: Buffer.from('png-1') }, { path: '', name: 'temp-2.png', pageNumber: 2 }, ] as any); @@ -312,7 +312,7 @@ describe('PDFSplitter', () => { it('should not blame the source for an ENOENT on another file while it still exists', async () => { const fontPath = '/resources/standard_fonts/missing.pfb'; - vi.mocked(pdfToPng).mockRejectedValue(systemError('ENOENT', 'open', fontPath)); + vi.mocked(renderPdfPages).mockRejectedValue(systemError('ENOENT', 'open', fontPath)); const failure = splitter.split(task); @@ -385,7 +385,7 @@ describe('PDFSplitter', () => { getPageCount: () => 1, } as any); - vi.mocked(pdfToPng).mockResolvedValueOnce([ + vi.mocked(renderPdfPages).mockResolvedValueOnce([ { path: '/tmp/page-1.png', name: 'page-1.png', page: 1, content: Buffer.from('') }, ]); @@ -442,7 +442,7 @@ describe('PDFSplitter', () => { getPageCount: () => 1, } as any); - vi.mocked(pdfToPng).mockResolvedValueOnce([ + vi.mocked(renderPdfPages).mockResolvedValueOnce([ { path: '/tmp/page-1.png', name: 'page-1.png', page: 1, content: Buffer.from('') }, ]); @@ -474,7 +474,7 @@ describe('PDFSplitter', () => { content: Buffer.from(''), })); - vi.mocked(pdfToPng).mockResolvedValueOnce(pages); + vi.mocked(renderPdfPages).mockResolvedValueOnce(pages); vi.mocked(fs.mkdir).mockResolvedValue(undefined); const result = await splitter.split(task); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts index 87adb6d..22c323c 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.win32.test.ts @@ -6,8 +6,8 @@ vi.mock('path', async (importOriginal) => { return { ...actual.win32, default: actual.win32 }; }); -vi.mock('pdf-to-png-converter', () => ({ - pdfToPng: vi.fn(), +vi.mock('../PDFPageRenderer.js', () => ({ + renderPdfPages: vi.fn(), })); vi.mock('pdf-lib', () => ({ @@ -33,7 +33,7 @@ vi.mock('fs', () => ({ import { promises as fs } from 'fs'; import path from 'path'; -import { pdfToPng } from 'pdf-to-png-converter'; +import { renderPdfPages } from '../PDFPageRenderer.js'; import { PDFDocument } from 'pdf-lib'; import { PDFSplitter } from '../PDFSplitter.js'; import { ImagePathUtil } from '../ImagePathUtil.js'; @@ -89,7 +89,7 @@ class FakeWindowsFs { } /** Same output handling as pdf-to-png-converter 3.11.0 (out/pdfToPng.js). */ -function fakePdfToPng(pageCount: number): typeof pdfToPng { +function fakePdfToPng(pageCount: number): typeof renderPdfPages { return (async (_pdf: unknown, props?: { outputFolder?: string }) => { const pages = Array.from({ length: pageCount }, (_, i) => ({ pageNumber: i + 1, @@ -110,7 +110,7 @@ function fakePdfToPng(pageCount: number): typeof pdfToPng { } return pages; - }) as unknown as typeof pdfToPng; + }) as unknown as typeof renderPdfPages; } describe('PDFSplitter output location on Windows', () => { @@ -134,7 +134,7 @@ describe('PDFSplitter output location on Windows', () => { vi.mocked(fs.rename).mockImplementation(fakeFs.rename as any); vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('pdf-bytes')); vi.mocked(PDFDocument.load).mockResolvedValue({ getPageCount: () => 2 } as any); - vi.mocked(pdfToPng).mockImplementation(fakePdfToPng(2)); + vi.mocked(renderPdfPages).mockImplementation(fakePdfToPng(2)); vi.spyOn(console, 'warn').mockImplementation(() => {}); });