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 @@ -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 <mac|win|linux> --arch <x64|arm64>` 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.

Expand Down
10 changes: 10 additions & 0 deletions electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
141 changes: 141 additions & 0 deletions scripts/fixtures/build-pdf-render-check.py
Original file line number Diff line number Diff line change
@@ -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()
54 changes: 54 additions & 0 deletions scripts/fixtures/pdf-render-check.cjs
Original file line number Diff line number Diff line change
@@ -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<string[]>} 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 };
Binary file added scripts/fixtures/pdf-render-check.pdf
Binary file not shown.
45 changes: 42 additions & 3 deletions scripts/package-load-check.cjs
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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() {
Expand All @@ -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-'));
Expand All @@ -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 {
Expand All @@ -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);
}

Expand Down
55 changes: 55 additions & 0 deletions src/core/infrastructure/adapters/split/PDFPageRenderer.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof pdfToPng>[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 });
}
Loading
Loading