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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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: `` });
|
||||
} catch {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user