diff --git a/AGENTS.md b/AGENTS.md index 165b09b..2cee94e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -308,6 +308,7 @@ For detailed instructions on adding new features, including step-by-step develop - **Images leaving the machine carry no metadata**: `ImageSplitter` re-encodes uploads through `ImageReencoder`, dropping Exif (GPS, device) and bringing photos to quality 80, and never falls back to the original. `ModelService` re-encodes any page file that still looks like it has metadata, such as those split by an older version. - **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`. +- **JPEG2000 needs `openjpeg.wasm`**: without it pdf.js drops JPX images and draws the page without them, with only a warning. The file is in `asarUnpack` and `wasmUrl` points at `app.asar.unpacked`, because Electron's `existsSync` answers for an unpacked file from the archive's header, even when it's gone from the disk. A missing wasm can also go unnoticed in the pixels, as pdf.js falls back to a JS decoder, so the checks look at its `JpxImage#` warnings too. `scripts/unpacked-assets.cjs` lists the unpacked files that `verify:package` and the load check require. `qcms_bg.wasm` isn't needed: pdf.js turns ICC off in Node. - **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/package.json b/package.json index c117821..1478331 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ } ], "asarUnpack": [ - "**/*.{node,dll,metal,exp,lib}" + "**/*.{node,dll,metal,exp,lib}", + "**/pdfjs-dist/wasm/openjpeg.wasm" ], "directories": { "buildResources": "assets", diff --git a/scripts/__tests__/package-load-check.test.mjs b/scripts/__tests__/package-load-check.test.mjs index 2d08adf..b806545 100644 --- a/scripts/__tests__/package-load-check.test.mjs +++ b/scripts/__tests__/package-load-check.test.mjs @@ -24,7 +24,7 @@ module.exports = { /** As many pages as the sample PDF has; the fake canvas decides what they look like. */ const FAKE_PDF_RENDERER = ` -exports.renderPdfPages = async () => Array.from({ length: 5 }, () => ({ content: Buffer.alloc(0) })); +exports.renderPdfPages = async () => Array.from({ length: 6 }, () => ({ content: Buffer.alloc(0) })); `; /** A Prisma client that fails where the test's environment tells it to, as a packaged one might. */ @@ -51,6 +51,7 @@ module.exports.PrismaClient = class { describe('package-load-check', () => { let root; let tempDir; + let unpackedWasm; /** Runs the script against a fake package, keeping its temp files in a directory of their own. */ const runCheck = (failures = {}) => @@ -75,6 +76,9 @@ describe('package-load-check', () => { const rendererDir = path.join(root, 'resources', 'app.asar', 'dist', 'main'); mkdirSync(rendererDir, { recursive: true }); writeFileSync(path.join(rendererDir, 'pdf-page-renderer.js'), FAKE_PDF_RENDERER); + unpackedWasm = path.join(root, 'resources', 'app.asar.unpacked', 'node_modules', 'pdfjs-dist', 'wasm', 'openjpeg.wasm'); + mkdirSync(path.dirname(unpackedWasm), { recursive: true }); + writeFileSync(unpackedWasm, '\0asm'); }); afterEach(() => { @@ -96,6 +100,25 @@ describe('package-load-check', () => { expect(result.status).toBe(1); }); + it('fails when openjpeg.wasm is missing from outside the asar', () => { + rmSync(unpackedWasm); + + const result = runCheck(); + + expect(result.stderr).toContain('load FAIL Unpacked assets: Missing from app.asar.unpacked'); + expect(result.stderr).toContain('openjpeg.wasm'); + expect(result.status).toBe(1); + }); + + it('fails when openjpeg.wasm is not a wasm file', () => { + writeFileSync(unpackedWasm, 'git-lfs pointer'); + + const result = runCheck(); + + expect(result.stderr).toContain('load FAIL Unpacked assets: Not a WebAssembly file'); + expect(result.status).toBe(1); + }); + it('only warns when its temp directories cannot be removed, after retrying', () => { const result = runCheck({ FAIL_REMOVE: '1' }); diff --git a/scripts/__tests__/pdf-render-check.test.mjs b/scripts/__tests__/pdf-render-check.test.mjs new file mode 100644 index 0000000..56217db --- /dev/null +++ b/scripts/__tests__/pdf-render-check.test.mjs @@ -0,0 +1,45 @@ +import * as canvasModule from '@napi-rs/canvas'; +import { createRequire } from 'node:module'; +import { describe, expect, it } from 'vitest'; + +const require = createRequire(import.meta.url); +const { findRenderProblems } = require('../fixtures/pdf-render-check.cjs'); + +const JPX_PAGE = 5; + +const pagePng = (color) => { + const canvas = canvasModule.createCanvas(400, 200); + const context = canvas.getContext('2d'); + context.fillStyle = color; + context.fillRect(0, 0, 400, 200); + return canvas.encode('png'); +}; + +describe('findRenderProblems', () => { + const allDrawn = async () => Promise.all(Array.from({ length: 6 }, () => pagePng('#000'))); + + it('accepts a complete rendering', async () => { + expect(await findRenderProblems(await allDrawn(), [], canvasModule)).toEqual([]); + }); + + it('reports a page whose JPX image was dropped, which is the only one left blank', async () => { + const pngs = await allDrawn(); + pngs[JPX_PAGE] = await pagePng('#fff'); + + const problems = await findRenderProblems(pngs, [], canvasModule); + + expect(problems).toEqual([expect.stringContaining('Page 6 (JPXDecode image) lost its content: 0 of 38400')]); + }); + + it.each([ + 'Warning: JpxImage#instantiateWasm: Error: Unable to load wasm data at: /x/openjpeg.wasm', + 'Warning: JpxImage#instantiateWasm: CompileError: expected magic word', + 'Warning: JpxImage#getJsModule: Error: Cannot find module', + ])('reports a decoder that failed to load even if the image got drawn: %s', async (warning) => { + expect(await findRenderProblems(await allDrawn(), [warning], canvasModule)).toEqual([warning]); + }); + + it('ignores warnings unrelated to loading', async () => { + expect(await findRenderProblems(await allDrawn(), ['Warning: TT: undefined function'], canvasModule)).toEqual([]); + }); +}); diff --git a/scripts/__tests__/verify-package-arch.test.mjs b/scripts/__tests__/verify-package-arch.test.mjs index e835e86..00dffa5 100644 --- a/scripts/__tests__/verify-package-arch.test.mjs +++ b/scripts/__tests__/verify-package-arch.test.mjs @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +8,8 @@ import { loadCheckProblems, parseBinaryHeader, resolvePackageLayout, verifyBinar const SCRIPT = fileURLToPath(new URL('../verify-package-arch.mjs', import.meta.url)); +const UNPACKED_WASM = 'app.asar.unpacked/node_modules/pdfjs-dist/wasm/openjpeg.wasm'; + const CPU_TYPE = { x64: 0x01000007, arm64: 0x0100000c }; function elf(machine) { @@ -159,12 +161,39 @@ describe('verifyBinaries', () => { resourcesDir = mkdtempSync(path.join(tmpdir(), 'verify-package-arch-')); executable = path.join(resourcesDir, 'MarkPDFdown'); writeFileSync(executable, macho('x64')); + put(UNPACKED_WASM, '\0asm'); }); afterEach(() => { rmSync(resourcesDir, { recursive: true, force: true }); }); + describe('openjpeg.wasm', () => { + beforeEach(() => putModules('darwin-x64', macho('x64'))); + + it('reports it missing from outside the asar, which is where pdf.js reads it', () => { + rmSync(path.join(resourcesDir, UNPACKED_WASM)); + + expect(verifyMac('x64').problems).toEqual([ + 'Missing from app.asar.unpacked: node_modules/pdfjs-dist/wasm/openjpeg.wasm', + ]); + }); + + it('rejects a file that is not WebAssembly', () => { + put(UNPACKED_WASM, 'git-lfs pointer'); + + expect(verifyMac('x64').problems).toEqual([ + 'Not a WebAssembly file: app.asar.unpacked/node_modules/pdfjs-dist/wasm/openjpeg.wasm', + ]); + }); + + it('is kept out of the asar by the electron-builder config', () => { + const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')); + + expect(packageJson.build.asarUnpack).toContain('**/pdfjs-dist/wasm/openjpeg.wasm'); + }); + }); + it('accepts a package whose binaries all match', () => { putModules('darwin-x64', macho('x64')); put('app.asar.unpacked/node_modules/@napi-rs/canvas/package.json', '{}'); diff --git a/scripts/fixtures/build-pdf-render-check.py b/scripts/fixtures/build-pdf-render-check.py index e4b2e48..d5c01df 100644 --- a/scripts/fixtures/build-pdf-render-check.py +++ b/scripts/fixtures/build-pdf-render-check.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Builds pdf-render-check.pdf, the sample behind the PDF rendering checks (needs fonttools). +Builds pdf-render-check.pdf, the sample behind the PDF rendering checks (needs fonttools and Pillow). 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. @@ -10,6 +10,7 @@ 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 + Page 6 full-page JPXDecode image needs pdfjs-dist/wasm/openjpeg.wasm Usage: python3 scripts/fixtures/build-pdf-render-check.py """ @@ -20,12 +21,15 @@ from fontTools.fontBuilder import FontBuilder from fontTools.pens.ttGlyphPen import TTGlyphPen +from PIL import Image, ImageDraw UNITS_PER_EM = 1000 # Larger than any CID in the Adobe-GB1 character collection. CID_COUNT = 32768 BOX_GID = 1 TEXT = "中文" +PAGE_WIDTH_PT = 200 +PAGE_HEIGHT_PT = 100 def build_box_font(): @@ -54,6 +58,17 @@ def build_box_font(): return output.getvalue() +def build_jpx_image(): + """A JP2 file holding a black rectangle on white, at 2 pixels per point of the page.""" + size = (PAGE_WIDTH_PT * 2, PAGE_HEIGHT_PT * 2) + image = Image.new("L", size, "white") + # 320x120 pixels, or 160x60 points: the end coordinates are inclusive. + ImageDraw.Draw(image).rectangle((40, 40, 359, 159), fill="black") + output = io.BytesIO() + image.save(output, "JPEG2000") + return size, output.getvalue() + + class Pdf: def __init__(self): self.objects = [] @@ -104,26 +119,38 @@ def composite_font(encoding, ordering="GB1", widths=""): ) helvetica = pdf.add("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + + def text_page(font, text, size=40): + return f"/Font << /F1 {font} 0 R >>", f"BT /F1 {size} Tf 20 30 Td <{text}> Tj ET" + + (jpx_width, jpx_height), jpx_data = build_jpx_image() + jpx_image = pdf.add( + f"<< /Type /XObject /Subtype /Image /Width {jpx_width} /Height {jpx_height} /ColorSpace /DeviceGray " + f"/BitsPerComponent 8 /Filter /JPXDecode /Length {len(jpx_data)} >>\nstream\n".encode() + + jpx_data + + b"\nendstream" + ) + pages = [ - (composite_font("UniGB-UCS2-H"), TEXT.encode("utf-16-be").hex()), - (composite_font("GBK-EUC-H"), TEXT.encode("gbk").hex()), + text_page(composite_font("UniGB-UCS2-H"), TEXT.encode("utf-16-be").hex()), + text_page(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"), + text_page(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()), + text_page(composite_font("Identity-H", widths=f"/W [1 [{UNITS_PER_EM}]] "), "00010002"), + text_page(helvetica, "Standard font".encode("ascii").hex(), size=20), + (f"/XObject << /Im0 {jpx_image} 0 R >>", f"q {PAGE_WIDTH_PT} 0 0 {PAGE_HEIGHT_PT} 0 0 cm /Im0 Do Q"), ] 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()) + for resources, operators in pages: + content = pdf.add_stream(operators.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 >> >> >>" + f"<< /Type /Page /Parent {pages_number} 0 R /MediaBox [0 0 {PAGE_WIDTH_PT} {PAGE_HEIGHT_PT}] " + f"/Contents {content} 0 R /Resources << {resources} >> >>" ) ) pages_object = pdf.add( diff --git a/scripts/fixtures/pdf-render-check.cjs b/scripts/fixtures/pdf-render-check.cjs index 7d057da..bb625fd 100644 --- a/scripts/fixtures/pdf-render-check.cjs +++ b/scripts/fixtures/pdf-render-check.cjs @@ -7,17 +7,24 @@ const path = require('node:path'); const { countDarkPixels } = require('./count-dark-pixels.cjs'); const SAMPLE_PDF = path.join(__dirname, 'pdf-render-check.pdf'); -const PAGE_COUNT = 5; +const PAGE_COUNT = 6; 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']; +// Two boxes of the fixture's font, 40 points wide. +const BOX_TEXT_AREA_PT = 2 * 40 ** 2; +// Page 5 is left out: it falls back to a system font when the standard font is missing, so only +// the warning below can tell. +const DRAWN_PAGES = [ + { page: 1, name: 'UniGB-UCS2-H', darkAreaPt: BOX_TEXT_AREA_PT }, + { page: 2, name: 'GBK-EUC-H', darkAreaPt: BOX_TEXT_AREA_PT }, + { page: 3, name: 'Identity-H/Identity', darkAreaPt: BOX_TEXT_AREA_PT }, + { page: 4, name: 'Identity-H/Adobe-GB1', darkAreaPt: BOX_TEXT_AREA_PT }, + { page: 6, name: 'JPXDecode image', darkAreaPt: 160 * 60 }, +]; // 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/; +const MIN_COVERAGE = 0.9; +// pdf.js reports both a CMap and a standard font it can't read as "Unable to load". For JPX the +// warning is all there is: without the wasm it falls back to a JS decoder, which may well draw. +const LOAD_FAILURE = /Unable to load|JpxImage#/; /** * @param {Buffer[]} pngs the rendered pages, in order @@ -31,13 +38,13 @@ async function findRenderProblems(pngs, warnings, canvasModule) { 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], canvasModule); - 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`); + for (const { page, name, darkAreaPt } of DRAWN_PAGES) { + const png = pngs[page - 1]; + if (!png) continue; + const { width, dark } = await countDarkPixels(png, canvasModule); + const expected = darkAreaPt * (width / PAGE_WIDTH_PT) ** 2; + if (dark < expected * MIN_COVERAGE) { + problems.push(`Page ${page} (${name}) lost its content: ${dark} of ${expected} dark pixels`); } } return problems; diff --git a/scripts/fixtures/pdf-render-check.pdf b/scripts/fixtures/pdf-render-check.pdf index cb6df4b..14eb021 100644 Binary files a/scripts/fixtures/pdf-render-check.pdf and b/scripts/fixtures/pdf-render-check.pdf differ diff --git a/scripts/package-load-check.cjs b/scripts/package-load-check.cjs index c72315e..a48f88e 100644 --- a/scripts/package-load-check.cjs +++ b/scripts/package-load-check.cjs @@ -12,8 +12,10 @@ const path = require('node:path'); const { pathToFileURL } = require('node:url'); const { countDarkPixels } = require('./fixtures/count-dark-pixels.cjs'); const { SAMPLE_PDF, findRenderProblems } = require('./fixtures/pdf-render-check.cjs'); +const { findUnpackedAssetProblems } = require('./unpacked-assets.cjs'); -const appDir = path.join(path.resolve(process.argv[2]), 'app.asar'); +const resourcesDir = path.resolve(process.argv[2]); +const appDir = path.join(resourcesDir, 'app.asar'); const appModules = path.join(appDir, 'node_modules'); const loadFromApp = (name) => require(path.join(appModules, name)); @@ -48,6 +50,11 @@ async function checkCanvas() { loadFromApp('pdf-to-png-converter'); } +function checkUnpackedAssets() { + const problems = findUnpackedAssetProblems(resourcesDir); + if (problems.length > 0) throw new Error(problems.join('; ')); +} + /** 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; @@ -98,6 +105,7 @@ async function main() { const checks = { '@napi-rs/canvas': checkCanvas, 'Prisma query engine': checkPrisma, + 'Unpacked assets': checkUnpackedAssets, 'PDF rendering': checkPdfRendering, }; diff --git a/scripts/unpacked-assets.cjs b/scripts/unpacked-assets.cjs new file mode 100644 index 0000000..bdad3e3 --- /dev/null +++ b/scripts/unpacked-assets.cjs @@ -0,0 +1,30 @@ +/** + * Non-native files the packaged app reads from outside app.asar (see asarUnpack in package.json). + * Shared by verify-package-arch.mjs, which runs on any host, and package-load-check.cjs. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const WASM_MAGIC = Buffer.from('\0asm', 'latin1'); +// Without it pdf.js drops JPEG2000 images and draws the page without them (#95). +const UNPACKED_WASM_FILES = ['node_modules/pdfjs-dist/wasm/openjpeg.wasm']; + +/** + * @param {string} resourcesDir the package's resources directory + * @returns {string[]} problems found, empty when every file is in place + */ +function findUnpackedAssetProblems(resourcesDir) { + const problems = []; + for (const file of UNPACKED_WASM_FILES) { + const unpackedFile = path.join(resourcesDir, 'app.asar.unpacked', ...file.split('/')); + if (!fs.existsSync(unpackedFile)) { + problems.push(`Missing from app.asar.unpacked: ${file}`); + } else if (!fs.readFileSync(unpackedFile).subarray(0, WASM_MAGIC.length).equals(WASM_MAGIC)) { + problems.push(`Not a WebAssembly file: app.asar.unpacked/${file}`); + } + } + return problems; +} + +module.exports = { findUnpackedAssetProblems }; diff --git a/scripts/verify-package-arch.mjs b/scripts/verify-package-arch.mjs index 2aca013..bc52b33 100644 --- a/scripts/verify-package-arch.mjs +++ b/scripts/verify-package-arch.mjs @@ -5,7 +5,7 @@ * * 1. Every native binary in the third-party module trees matches the target platform and arch. * 2. The native modules the app needs at startup are present (canvas, and the Prisma engine, on - * Linux that of every distro family and libssl version). + * Linux that of every distro family and libssl version), and so are the unpacked wasm files. * 3. When the host can execute the package, those modules really load inside the packaged Electron. * This catches what headers cannot, such as a library left inside app.asar where dlopen can't see it. * @@ -24,6 +24,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { parseArgs } from 'node:util'; import { linuxEngineTargets } from './prisma-engines.mjs'; +import unpackedAssets from './unpacked-assets.cjs'; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const LOAD_CHECK_SCRIPT = path.join(SCRIPT_DIR, 'package-load-check.cjs'); @@ -190,6 +191,7 @@ export function verifyBinaries({ resourcesDir, executable, platform, arch }) { problems.push(`Missing native module for ${platform}-${arch}: ${label}`); } } + problems.push(...unpackedAssets.findUnpackedAssetProblems(resourcesDir)); return { binaries, problems }; } diff --git a/src/core/infrastructure/adapters/split/PDFPageRenderer.ts b/src/core/infrastructure/adapters/split/PDFPageRenderer.ts index d6b21ee..25477ea 100644 --- a/src/core/infrastructure/adapters/split/PDFPageRenderer.ts +++ b/src/core/infrastructure/adapters/split/PDFPageRenderer.ts @@ -11,13 +11,24 @@ type RenderOptions = NonNullable[1]>; const PDFJS_WARNINGS = 1; /** - * Points pdf.js at the CMaps and standard fonts of the installed pdfjs-dist. + * Where a file unpacked by electron-builder's asarUnpack really is; other paths stay as they are. * - * pdf-to-png-converter derives these paths from process.cwd() when it loads and has no option to + * Electron's fs would redirect an app.asar path there as well, but its existsSync answers from the + * archive's header, so a file gone from the disk would still seem to be there. + */ +export function outsideAsar(dir: string): string { + return dir.replace(/([\\/])app\.asar(?=[\\/])/, '$1app.asar.unpacked'); +} + +/** + * Points pdf.js at the CMaps, standard fonts and wasm decoders of the installed pdfjs-dist. + * + * pdf-to-png-converter derives the first two 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). + * to load a predefined CMap and silently draws no text for that font (#85). It doesn't set wasmUrl + * at all, without which pdf.js drops JPEG2000 images and draws the page without them (#95). * - * @throws Error if the files are missing: rendering without them would silently lose text. + * @throws Error if the files are missing: rendering without them would silently lose content. */ function pointPdfjsAtInstalledAssets(): void { // pdfjs-dist is a dependency of the converter rather than ours, so resolve it from there. @@ -25,21 +36,24 @@ function pointPdfjsAtInstalledAssets(): void { 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 wasmDir = outsideAsar(path.join(pdfjsRoot, 'wasm')); const expectedFiles = [ path.join(cMapDir, 'UniGB-UCS2-H.bcmap'), path.join(standardFontDir, 'LiberationSans-Regular.ttf'), + path.join(wasmDir, 'openjpeg.wasm'), ]; 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.'); + throw new Error('PDF.js data files are 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}/`; + DOCUMENT_INIT_PARAMS_DEFAULTS.wasmUrl = `${wasmDir}/`; } /** diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts index d66491a..6a6502d 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.integration.test.ts @@ -49,7 +49,7 @@ describe('PDF rendering from a cwd without pdfjs-dist', () => { await fs.rm(workDir, { recursive: true, force: true }); }); - it('should draw predefined CMap, Identity-H and standard font text', { timeout: 60_000 }, async () => { + it('should draw predefined CMap, Identity-H and standard font text, and JPEG2000 images', { timeout: 60_000 }, async () => { const warnings: string[] = []; vi.spyOn(console, 'warn').mockImplementation((...args) => { warnings.push(args.join(' ')); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts index 4147cb6..7bd3a82 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFPageRenderer.test.ts @@ -14,7 +14,7 @@ vi.mock('fs', async (importOriginal) => { 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'; +import { outsideAsar, renderPdfPages } from '../PDFPageRenderer.js'; describe('renderPdfPages', () => { beforeEach(() => { @@ -28,7 +28,7 @@ describe('renderPdfPages', () => { }); it('should have the pdf.js data of the installed package in place before it converts', async () => { - let urlsSeenByConverter: { cMapUrl?: string; standardFontDataUrl?: string } = {}; + let urlsSeenByConverter: { cMapUrl?: string; standardFontDataUrl?: string; wasmUrl?: string } = {}; vi.mocked(pdfToPng).mockImplementation(async () => { urlsSeenByConverter = { ...DOCUMENT_INIT_PARAMS_DEFAULTS }; return []; @@ -37,15 +37,18 @@ describe('renderPdfPages', () => { await renderPdfPages('/doc.pdf', {}); - const { cMapUrl, standardFontDataUrl } = urlsSeenByConverter; + const { cMapUrl, standardFontDataUrl, wasmUrl } = 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(wasmUrl).toMatch(/pdfjs-dist[\\/]wasm\/$/); expect(path.isAbsolute(cMapUrl!)).toBe(true); expect(path.isAbsolute(standardFontDataUrl!)).toBe(true); + expect(path.isAbsolute(wasmUrl!)).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); + expect(readFileSync(`${wasmUrl}openjpeg.wasm`).length).toBeGreaterThan(0); }); it('should pass the options on and turn pdf.js warnings on', async () => { @@ -68,11 +71,12 @@ describe('renderPdfPages', () => { 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) => { + ['JPEG2000 decoder', 'openjpeg.wasm'], + ])('should refuse to render without the %s rather than lose content', 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'); + await expect(renderPdfPages('/doc.pdf', {})).rejects.toThrow('PDF.js data files are missing'); expect(pdfToPng).not.toHaveBeenCalled(); expect(consoleError.mock.calls[0][0]).toContain(fileName); @@ -88,3 +92,20 @@ describe('renderPdfPages', () => { expect(error.message).not.toContain(path.sep); }); }); + +describe('outsideAsar', () => { + it.each([ + ['/opt/MarkPDFdown/resources/app.asar/node_modules/x', '/opt/MarkPDFdown/resources/app.asar.unpacked/node_modules/x'], + ['C:\\MarkPDFdown\\resources\\app.asar\\node_modules\\x', 'C:\\MarkPDFdown\\resources\\app.asar.unpacked\\node_modules\\x'], + ])('should turn the path inside the archive into the real one: %s', (inside, outside) => { + expect(outsideAsar(inside)).toBe(outside); + }); + + it.each([ + ['a development checkout', '/home/me/repo/node_modules/x'], + ['a path that is already outside', '/res/app.asar.unpacked/node_modules/x'], + ['a directory that merely ends the same way', '/home/me/myapp.asar/node_modules/x'], + ])('should leave %s alone', (_label, dir) => { + expect(outsideAsar(dir)).toBe(dir); + }); +});