Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@
}
],
"asarUnpack": [
"**/*.{node,dll,metal,exp,lib}"
"**/*.{node,dll,metal,exp,lib}",
"**/pdfjs-dist/wasm/openjpeg.wasm"
],
"directories": {
"buildResources": "assets",
Expand Down
25 changes: 24 additions & 1 deletion scripts/__tests__/package-load-check.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 = {}) =>
Expand All @@ -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(() => {
Expand All @@ -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' });

Expand Down
45 changes: 45 additions & 0 deletions scripts/__tests__/pdf-render-check.test.mjs
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
31 changes: 30 additions & 1 deletion scripts/__tests__/verify-package-arch.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand Down Expand Up @@ -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', '{}');
Expand Down
49 changes: 38 additions & 11 deletions scripts/fixtures/build-pdf-render-check.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
"""
Expand All @@ -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():
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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(
Expand Down
39 changes: 23 additions & 16 deletions scripts/fixtures/pdf-render-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
Binary file modified scripts/fixtures/pdf-render-check.pdf
Binary file not shown.
10 changes: 9 additions & 1 deletion scripts/package-load-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -98,6 +105,7 @@ async function main() {
const checks = {
'@napi-rs/canvas': checkCanvas,
'Prisma query engine': checkPrisma,
'Unpacked assets': checkUnpackedAssets,
'PDF rendering': checkPdfRendering,
};

Expand Down
30 changes: 30 additions & 0 deletions scripts/unpacked-assets.cjs
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading