fix(coding-agent): bundle mupdf into the compiled binary so PDF reads work

The vendored markit engine kept `mupdf` external, but a single-file
`bun --compile` binary has no node_modules to resolve it from, so the
standalone binary aborted at startup with `Cannot find package 'mupdf'`
— the otherwise-lazy import is resolved eagerly at boot. Bundle mupdf and
embed its WASM blob (scripts/embed-mupdf-wasm.ts, reset after the build);
npm and source installs still load mupdf from node_modules.

Import mupdf lazily inside the PDF converter so the bundled markit chunk's
init stays synchronous: mupdf's top-level await otherwise made the chunk
init async and bun's compiled bundler failed to await it through the
barrel, exposing the converters before their module-level const tables
initialized (undefined EXTENSIONS). Also keeps the ~10MB wasm off non-PDF
document conversions.
This commit is contained in:
can1357
2026-06-18 20:44:58 +02:00
parent 2023a6f674
commit 88aa88f605
9 changed files with 128 additions and 13 deletions
+1
View File
@@ -57,6 +57,7 @@ pi-*.html
# Generated files
packages/coding-agent/src/export/html/tool-views.generated.js
packages/natives/npm/
packages/coding-agent/src/utils/mupdf-wasm.wasm
/runs/
python/omp-rpc/src/omp_rpc.egg-info/
# parallel-agent worktrees
+1
View File
@@ -27,6 +27,7 @@
- Fixed TUI prompts beginning with shell-style variables such as `$HOME` being misrouted to Python eval; Python shortcuts now require `$ <code>` or `$$ <code>`. ([#2944](https://github.com/can1357/oh-my-pi/issues/2944))
- Fixed LM Studio runtime discovery to use native `/api/v0/models` metadata so `inspect_image` can select VLM models. ([#2945](https://github.com/can1357/oh-my-pi/issues/2945))
- Fixed `omp` crashing at startup with `Cannot find module './browser-data/browser-data.js'` (e.g. on Termux/proot Linux arm64). `tools/browser/attach.ts` value-imported the `TargetType` enum from `puppeteer-core`, which dragged the puppeteer-core barrel (and its `@puppeteer/browsers` Chromium downloader) back onto the eager startup import graph — defeating the lazy-load deferral and turning any bundling quirk in that subtree into a fatal boot crash instead of a recoverable browser-tool error. The import is now `import type` and the target check compares against the `"page"` literal, so puppeteer only loads on first browser use.
- Fixed the standalone compiled binary aborting at startup with `error: Cannot find package 'mupdf'`. The vendored document engine kept `mupdf` external, but a single-file `bun --compile` binary has no `node_modules` to resolve it from, and the bundler resolves the otherwise-lazy import eagerly at boot. `mupdf` (and its WASM module, embedded at build time via `scripts/embed-mupdf-wasm.ts`) is now bundled into the binary, so startup and PDF/document conversion both work in the standalone binary; npm and source installs still load `mupdf` from `node_modules`. `mupdf` is also imported lazily inside the PDF converter now, which fixes a compiled-bundle init-order hazard — `mupdf`'s top-level await made the bundled markit chunk's module init async, exposing the converters before their extension tables initialized — and keeps the ~10MB WASM off non-PDF document conversions.
### Removed
@@ -44,6 +44,7 @@ async function main(): Promise<void> {
await runCommand(["bun", "--cwd=../stats", "scripts/generate-client-bundle.ts", "--generate"]);
await runCommand(["bun", "scripts/generate-docs-index.ts", "--generate"]);
await runCommand(["bun", "--cwd=../natives", "run", "embed:native"]);
await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--generate"]);
try {
const buildEnv = shouldAdhocSignDarwinBinary() ? { ...Bun.env, BUN_NO_CODESIGN_MACHO_BINARY: "1" } : Bun.env;
await runCommand(
@@ -61,8 +62,6 @@ async function main(): Promise<void> {
"--define",
`process.env.PI_TINY_TRANSFORMERS_VERSION=${JSON.stringify(transformersVersion)}`,
"--external",
"mupdf",
"--external",
"fastembed",
"--external",
"onnxruntime-node",
@@ -97,6 +96,7 @@ async function main(): Promise<void> {
await runCommand(["codesign", "--force", "--sign", "-", outputPath]);
}
} finally {
await runCommand(["bun", "scripts/embed-mupdf-wasm.ts", "--reset"]);
await runCommand(["bun", "--cwd=../natives", "run", "embed:native", "--reset"]);
}
} finally {
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bun
// Embeds mupdf's `mupdf-wasm.wasm` into the compiled single-file binary.
//
// mupdf loads its wasm by reading the `mupdf-wasm.wasm` sibling of its own
// module via `new URL(..., import.meta.url)` + `readFileSync`. A `bun --compile`
// binary has no node_modules, so that read fails (`ENOENT .../mupdf-wasm.wasm`),
// and marking mupdf `--external` instead makes `bun --compile` eagerly fail to
// resolve the package at startup (the static `import * as mupdf` lives in a lazy
// chunk but is hoisted). So the binary build bundles mupdf and embeds the wasm
// bytes here, handing them to the WASM module as `$libmupdf_wasm_Module.wasmBinary`
// (see src/utils/markit.ts).
//
// `--generate` copies the wasm next to src/utils/mupdf-wasm-embed.ts and rewrites
// that module to import it via `with { type: "file" }`; `--reset` restores the
// checked-in placeholder and removes the copy. The npm `dist/cli.js` bundle never
// runs this — it keeps mupdf external and loads the wasm from node_modules.
import * as fs from "node:fs/promises";
import { createRequire } from "node:module";
import * as path from "node:path";
const utilsDir = path.join(import.meta.dir, "..", "src", "utils");
const helperPath = path.join(utilsDir, "mupdf-wasm-embed.ts");
const wasmCopyPath = path.join(utilsDir, "mupdf-wasm.wasm");
const placeholder = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit by hand.
//
// Compiled single-file binaries cannot let mupdf resolve its \`mupdf-wasm.wasm\`
// sibling from the read-only bunfs, so the binary build (scripts/build-binary.ts
// and scripts/ci-release-build-binaries.ts) regenerates this module to embed the
// wasm bytes via \`with { type: "file" }\` and copies the wasm next to it. Source
// checkouts, \`bun test\`, and the npm \`dist/cli.js\` bundle keep mupdf external and
// load the wasm from node_modules, so this placeholder returns undefined and the
// build resets back to it afterward.
export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
\treturn undefined;
}
`;
const generated = `// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit or commit.
import { readFileSync } from "node:fs";
import wasmPath from "./mupdf-wasm.wasm" with { type: "file" };
export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
\treturn readFileSync(wasmPath);
}
`;
if (process.argv.includes("--reset")) {
await Bun.write(helperPath, placeholder);
try {
await fs.unlink(wasmCopyPath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
process.exit(0);
}
const wasmSource = path.join(path.dirname(createRequire(import.meta.url).resolve("mupdf")), "mupdf-wasm.wasm");
const wasmFile = Bun.file(wasmSource);
if (!(await wasmFile.exists())) {
throw new Error(`mupdf wasm not found at ${wasmSource}; run \`bun install\` first.`);
}
await Bun.write(wasmCopyPath, wasmFile);
await Bun.write(helperPath, generated);
console.log(`Embedded mupdf wasm (${wasmFile.size} bytes) into ${path.relative(process.cwd(), wasmCopyPath)}`);
@@ -9,9 +9,24 @@
*
* Coordinate system: PDF native (origin = bottom-left, Y increases upward).
*/
import * as mupdf from "mupdf";
import type * as mupdf from "mupdf";
import type { ImageRegion, PageContent, Segment, TextBox } from "./types";
// mupdf instantiates its WASM module via a top-level await. A static
// `import * as mupdf` would pull that await into this module's init, which makes
// the whole bundled markit chunk's `__esm` init async — and bun's compiled
// bundler fails to await that init transitively through the `../markit` barrel,
// exposing the converter classes before their module-level consts initialize
// (e.g. `EXTENSIONS` reads as undefined). Importing mupdf lazily keeps the chunk
// init synchronous and also keeps the ~10MB wasm off non-PDF conversions.
let mupdfModule: typeof mupdf | undefined;
async function loadMupdf(): Promise<typeof mupdf> {
if (!mupdfModule) {
mupdfModule = await import("mupdf");
}
return mupdfModule;
}
/** mupdf structured-text JSON bounding box (top-left origin). */
interface StextBBox {
x: number;
@@ -479,8 +494,9 @@ function extractImageRegions(stext: StructuredTextJSON, pageNumber: number, page
* Render an image region from a PDF page as a PNG buffer.
* Uses mupdf's DrawDevice to render just the cropped area at 2x resolution.
*/
export function renderImageRegion(input: Uint8Array, region: ImageRegion): Uint8Array {
const doc = mupdf.Document.openDocument(input, "application/pdf");
export async function renderImageRegion(input: Uint8Array, region: ImageRegion): Promise<Uint8Array> {
const m = await loadMupdf();
const doc = m.Document.openDocument(input, "application/pdf");
const page = doc.loadPage(region.pageNumber - 1);
const pad = 10;
const bx = region.bbox.x - pad;
@@ -490,12 +506,12 @@ export function renderImageRegion(input: Uint8Array, region: ImageRegion): Uint8
const scale = 2;
const pw = Math.round(bw * scale);
const ph = Math.round(bh * scale);
const pix = new mupdf.Pixmap(mupdf.ColorSpace.DeviceRGB, [0, 0, pw, ph], false);
const pix = new m.Pixmap(m.ColorSpace.DeviceRGB, [0, 0, pw, ph], false);
pix.clear(255);
const matrix: mupdf.Matrix = [scale, 0, 0, scale, -bx * scale, -by * scale];
const dl = page.toDisplayList();
const dev = new mupdf.DrawDevice(matrix, pix);
dl.run(dev, mupdf.Matrix.identity);
const dev = new m.DrawDevice(matrix, pix);
dl.run(dev, m.Matrix.identity);
dev.close();
return pix.asPNG();
}
@@ -504,7 +520,8 @@ export function renderImageRegion(input: Uint8Array, region: ImageRegion): Uint8
* Extract text boxes and vector segments from all pages of a PDF buffer.
*/
export async function extractPages(input: Uint8Array): Promise<PageContent[]> {
const doc = mupdf.Document.openDocument(input, "application/pdf");
const m = await loadMupdf();
const doc = m.Document.openDocument(input, "application/pdf");
const pages: PageContent[] = [];
for (let i = 0; i < doc.countPages(); i++) {
const pageNumber = i + 1;
@@ -72,7 +72,7 @@ export class PdfConverter implements Converter {
const filename = `${img.id}.png`;
const filepath = path.join(imageDir, filename);
try {
const png = renderImageRegion(pdfBytes, img);
const png = await renderImageRegion(pdfBytes, img);
await Bun.write(filepath, png);
imageBlocks.push({ topY: img.topY, markdown: `![${img.id}](${filepath})` });
} catch {
+15
View File
@@ -1,6 +1,7 @@
import { logger, untilAborted } from "@oh-my-pi/pi-utils";
import type { Markit, StreamInfo } from "../markit";
import { ToolAbortError } from "../tools/tool-errors";
import { loadEmbeddedMupdfWasm } from "./mupdf-wasm-embed";
export interface MarkitConversionResult {
content: string;
@@ -21,6 +22,7 @@ export interface MarkitFileConversionOptions {
interface MuPdfWasmModuleConfig {
print?: (...values: unknown[]) => void;
printErr?: (...values: unknown[]) => void;
wasmBinary?: Uint8Array;
}
function logMuPdfWasmOutput(stream: "stdout" | "stderr", values: unknown[]): void {
@@ -38,11 +40,24 @@ function installMuPdfWasmLogger(): void {
globalThis.$libmupdf_wasm_Module = moduleConfig;
}
// Hand the WASM module its bytes directly when the compiled binary embedded them
// (scripts/embed-mupdf-wasm.ts); a single-file binary has no node_modules for
// mupdf to read `mupdf-wasm.wasm` from. Source/npm builds get undefined here and
// mupdf loads its own wasm. Must run before the mupdf module evaluates.
function installEmbeddedMupdfWasm(): void {
const wasmBinary = loadEmbeddedMupdfWasm();
if (!wasmBinary) return;
const moduleConfig: MuPdfWasmModuleConfig = globalThis.$libmupdf_wasm_Module ?? {};
moduleConfig.wasmBinary = wasmBinary;
globalThis.$libmupdf_wasm_Module = moduleConfig;
}
installMuPdfWasmLogger();
let markit: () => Markit | Promise<Markit> = async () => {
// Lazy: keep the document engine (mammoth/mupdf) off the startup
// import graph — it loads only when a document is first converted.
installEmbeddedMupdfWasm();
const promise = import("../markit").then(({ Markit }) => {
const instance = new Markit();
markit = () => instance;
@@ -0,0 +1,12 @@
// AUTOGENERATED -- managed by scripts/embed-mupdf-wasm.ts. Do not edit by hand.
//
// Compiled single-file binaries cannot let mupdf resolve its `mupdf-wasm.wasm`
// sibling from the read-only bunfs, so the binary build (scripts/build-binary.ts
// and scripts/ci-release-build-binaries.ts) regenerates this module to embed the
// wasm bytes via `with { type: "file" }` and copies the wasm next to it. Source
// checkouts, `bun test`, and the npm `dist/cli.js` bundle keep mupdf external and
// load the wasm from node_modules, so this placeholder returns undefined and the
// build resets back to it afterward.
export function loadEmbeddedMupdfWasm(): Uint8Array | undefined {
return undefined;
}
+5 -3
View File
@@ -110,7 +110,7 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
console.log(`Building ${target.outfile}...`);
await embedNative(target);
if (isDryRun) {
console.log(`DRY RUN bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --minify-identifiers --keep-names --define process.env.PI_COMPILED="true" --root . --external mupdf --target=${target.target} ${entrypoint} --outfile ${target.outfile}`);
console.log(`DRY RUN bun build --compile --no-compile-autoload-bunfig --no-compile-autoload-dotenv --no-compile-autoload-tsconfig --no-compile-autoload-package-json --minify-identifiers --keep-names --define process.env.PI_COMPILED="true" --root . --target=${target.target} ${entrypoint} --outfile ${target.outfile}`);
return;
}
@@ -132,8 +132,6 @@ async function buildBinary(target: BinaryTarget): Promise<void> {
'process.env.PI_COMPILED="true"',
"--root",
".",
"--external",
"mupdf",
"--target",
target.target,
entrypoint,
@@ -154,10 +152,12 @@ async function generateBundle(): Promise<void> {
if (isDryRun) {
console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --generate");
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --generate");
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/embed-mupdf-wasm.ts --generate");
return;
}
await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--generate"], repoRoot);
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--generate"], repoRoot);
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/embed-mupdf-wasm.ts", "--generate"], repoRoot);
}
async function resetArtifacts(): Promise<void> {
@@ -165,11 +165,13 @@ async function resetArtifacts(): Promise<void> {
console.log("DRY RUN bun --cwd=packages/natives run embed:native --reset");
console.log("DRY RUN bun --cwd=packages/stats scripts/generate-client-bundle.ts --reset");
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/generate-docs-index.ts --reset");
console.log("DRY RUN bun --cwd=packages/coding-agent scripts/embed-mupdf-wasm.ts --reset");
return;
}
await runCommand(["bun", "--cwd=packages/natives", "run", "embed:native", "--reset"], repoRoot);
await runCommand(["bun", "--cwd=packages/stats", "scripts/generate-client-bundle.ts", "--reset"], repoRoot);
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/generate-docs-index.ts", "--reset"], repoRoot);
await runCommand(["bun", "--cwd=packages/coding-agent", "scripts/embed-mupdf-wasm.ts", "--reset"], repoRoot);
}
async function main(): Promise<void> {