Skip to content

Commit cec8aac

Browse files
Merge pull request #194 from BjoernSchotte/codex/import-pdf-mvp
feat(import-pdf): semantic PDF import MVP
2 parents e3c6b8c + 6b27aa5 commit cec8aac

174 files changed

Lines changed: 24783 additions & 1039 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ packages/pdf/.fonts/
2626
# packages/pdf-compiler-browser/scripts/vendor-typst.ts for why not committed).
2727
packages/pdf-compiler-browser/vendor/
2828

29+
# Reproducible PDFium importer WASM/licenses/provenance. The exact-pinned
30+
# dependency is verified and copied by packages/import-pdf/scripts/vendor-pdfium.ts.
31+
packages/import-pdf/vendor/
32+
2933
# SonarQube coverage artifact
3034
coverage/
3135
.scannerwork/

NOTICE

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ original attribution preserved.
1111

1212
Third-party notices:
1313

14+
- @embedpdf/pdfium (https://github.com/embedpdf/embed-pdf-viewer), version
15+
2.15.0, licensed under the MIT License, distributing a PDFium WebAssembly
16+
binary from https://github.com/embedpdf/pdfium. PDFium is licensed under a
17+
BSD-style license and carries Apache-2.0-licensed components. Exact release,
18+
fork, binary provenance, license texts, and the unresolved transitive-notice
19+
inventory ship in packages/import-pdf/vendor/.
1420
- typst.ts web compiler (https://github.com/BjoernSchotte/typst.ts), forked
1521
from https://github.com/Myriad-Dreamin/typst.ts, distribution version
1622
0.8.0-rc3.typst0151.1 with embedded Typst 0.15.1, licensed under the Apache

apps/browser-export-harness/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
"scripts": {
88
"pretypecheck": "bun run --cwd ../.. fonts:ensure",
99
"typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.worker.json && tsc -p tsconfig.tools.json",
10-
"prebuild": "bun run --cwd ../.. fonts:ensure && bun run --cwd ../.. vendor:typst && bun run --cwd ../.. clean:browser-export-harness-output",
10+
"prebuild": "bun run --cwd ../.. fonts:ensure && bun run --cwd ../.. vendor:typst && bun run --cwd ../.. vendor:pdfium && bun run --cwd ../.. clean:browser-export-harness-output",
1111
"build": "vite build",
1212
"check:output": "bun --conditions=development scripts/check-output.ts",
1313
"assert:cases": "bun scripts/assert-case-manifest.ts",
14-
"precheck:parity": "bun run --cwd ../.. fonts:ensure && bun run --cwd ../.. vendor:typst",
14+
"precheck:parity": "bun run --cwd ../.. fonts:ensure && bun run --cwd ../.. vendor:typst && bun run --cwd ../.. vendor:pdfium",
1515
"check:parity": "bun --conditions=development scripts/check-parity.ts",
1616
"test:unit": "bun --conditions=development test tests/boundaries.test.ts tests/output-scan.test.ts tests/pdf-worker-client.test.ts tests/pdf-job-parity.test.ts tests/docx-job-parity.test.ts scripts/check-parity.test.ts",
17-
"pretest:e2e": "bun run --cwd ../.. fonts:ensure && bun run --cwd ../.. vendor:typst",
17+
"pretest:e2e": "bun run --cwd ../.. fonts:ensure && bun run --cwd ../.. vendor:typst && bun run --cwd ../.. vendor:pdfium",
1818
"test:e2e": "playwright test"
1919
},
2020
"dependencies": {
@@ -25,6 +25,7 @@
2525
"@atlcli/export-fixtures": "workspace:*",
2626
"@atlcli/export-jobs": "workspace:*",
2727
"@atlcli/export-wiring": "workspace:*",
28+
"@atlcli/import-pdf": "workspace:*",
2829
"@atlcli/pdf": "workspace:*",
2930
"@atlcli/pdf-compiler-browser": "workspace:*",
3031
"@atlcli/pdf-template-authoring": "workspace:*",

apps/browser-export-harness/scripts/check-output.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
33
import { readdirSync, readFileSync, statSync } from "node:fs";
44
import { basename, extname, join, relative } from "node:path";
55
import { PDF_RUNTIME_ASSETS } from "@atlcli/pdf/browser";
6+
import { PDFIUM_WASM_SHA256 } from "@atlcli/import-pdf/browser-worker";
67

78
const NODE_SPECIFIER_RE = /["'`](?:node|bun):[A-Za-z0-9_./-]*["'`]/g;
89
const NODE_RUNTIME_RE = /(?<![\w.])Buffer\.[A-Za-z_$]|\bprocess\.(?:env|versions)\b|\brequire\s*\(\s*["'`]|\b__dirname\b|\b__filename\b/g;
@@ -34,6 +35,11 @@ const AGGREGATE_SHIKI_RUNTIME_RES = [
3435
/\blangs-bundle-full\b/g,
3536
/["'`]shiki(?:\/(?:langs|themes))?["'`]/g,
3637
];
38+
const IMPORT_ENGINE_BOUNDARY_RES = [
39+
/pdfjs-dist/g,
40+
/DEFAULT_PDFIUM_WASM_URL/g,
41+
/cdn[.]jsdelivr[.]net\/npm\/@embedpdf\/pdfium/gi,
42+
];
3743
const DOCX_CODE_FONT_SHA256 =
3844
"a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f";
3945

@@ -56,21 +62,34 @@ function matches(text: string, expressions: RegExp[]): string[] {
5662
return [...findings];
5763
}
5864

65+
function withoutReviewedEmscriptenBrowserProbe(text: string): string {
66+
// @embedpdf/pdfium's browser-only Emscripten wrapper retains one discarded
67+
// environment probe even though the Node branch is compiled out. This exact
68+
// expression does not import/call Node and evaluates safely in a Worker.
69+
// Keep every other process.versions use subject to the Node-runtime ban.
70+
return text.replace(
71+
/typeof process\s*==\s*["'`]object["'`]\s*&&\s*typeof process[.]versions\s*==\s*["'`]object["'`]\s*&&\s*typeof process[.]versions[.]node\s*==\s*["'`]string["'`]\s*&&\s*process[.]type/gu,
72+
"false",
73+
);
74+
}
75+
5976
export function scanHarnessText(text: string): string[] {
77+
const reviewed = withoutReviewedEmscriptenBrowserProbe(text);
6078
const findings = [
61-
...matches(text, [NODE_SPECIFIER_RE, NODE_RUNTIME_RE]),
62-
...matches(text, DYNAMIC_CODE_RES),
63-
...matches(text, REMOTE_EXECUTABLE_RES),
64-
...matches(text, [EXTENSION_RUNTIME_RE]),
65-
...matches(text, ROOT_RELATIVE_RES),
66-
...matches(text, ONIGURUMA_RUNTIME_RES),
67-
...matches(text, AGGREGATE_SHIKI_RUNTIME_RES),
79+
...matches(reviewed, [NODE_SPECIFIER_RE, NODE_RUNTIME_RE]),
80+
...matches(reviewed, DYNAMIC_CODE_RES),
81+
...matches(reviewed, REMOTE_EXECUTABLE_RES),
82+
...matches(reviewed, [EXTENSION_RUNTIME_RE]),
83+
...matches(reviewed, ROOT_RELATIVE_RES),
84+
...matches(reviewed, ONIGURUMA_RUNTIME_RES),
85+
...matches(reviewed, AGGREGATE_SHIKI_RUNTIME_RES),
86+
...matches(reviewed, IMPORT_ENGINE_BOUNDARY_RES),
6887
];
6988
// Shiki grammar chunks are inert JSON payloads. Some grammars list Node
7089
// globals as source-language keywords; those strings are not runtime use.
7190
if (
72-
text.includes("Object.freeze(JSON.parse(`") &&
73-
text.includes('"scopeName"')
91+
reviewed.includes("Object.freeze(JSON.parse(`") &&
92+
reviewed.includes('"scopeName"')
7493
) {
7594
return findings.filter(
7695
(finding) => finding !== "__dirname" && finding !== "__filename",
@@ -170,6 +189,12 @@ export function validateHarnessInventory(artifacts: OutputArtifact[]): string[]
170189
"Typst compiler WASM",
171190
/(?:^|\/)assets\/typst_ts_web_compiler_bg-[^/]+\.wasm$/,
172191
));
192+
issues.push(...requireOne(
193+
artifacts,
194+
"PDFium importer WASM",
195+
/(?:^|\/)assets\/pdfium-[^/]+\.wasm$/,
196+
PDFIUM_WASM_SHA256,
197+
));
173198
issues.push(...requireOne(
174199
artifacts,
175200
"DOCX code font",

apps/browser-export-harness/src/conformance-manifest.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,14 @@ export const CONFORMANCE_MANIFEST: readonly ConformanceCaseMeta[] = [
9191
mediaPolicy: "none",
9292
emitsDigests: false,
9393
},
94+
{
95+
id: "import-pdf",
96+
title: "PDFium import Worker contract",
97+
folderTaskIds: ["import-pdf-mvp/PDF-10"],
98+
engines: ["pdf"],
99+
mediaPolicy: "none",
100+
emitsDigests: false,
101+
},
94102
{
95103
id: "pdf-settings",
96104
title: "PDF settings & watermark (007)",
@@ -222,6 +230,7 @@ export const EXPECTED_LANDED_CASE_IDS: readonly string[] = [
222230
"docx-job-parity",
223231
"pdf",
224232
"pdf-job-parity",
233+
"import-pdf",
225234
"pdf-settings",
226235
"pdf-v5",
227236
"docx-template-intake",

apps/browser-export-harness/src/conformance-registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { runDocxCase } from "./docx-case.js";
1616
import { runDocxJobParityCase } from "./docx-job-parity-case.js";
1717
import { runDocxQualityCase } from "./docx-quality-case.js";
1818
import { runDocxTemplateIntakeCase } from "./docx-template-intake-case.js";
19+
import { runImportPdfCase } from "./import-pdf-case.js";
1920
import { runM1Case } from "./m1-case.js";
2021
import { runMacroCase } from "./macro-case.js";
2122
import { runManuscriptCase } from "./manuscript-case.js";
@@ -40,6 +41,7 @@ const RUNNERS: Record<string, () => Promise<unknown>> = {
4041
"docx-job-parity": runDocxJobParityCase,
4142
pdf: runPdfCase,
4243
"pdf-job-parity": runPdfJobParityCase,
44+
"import-pdf": runImportPdfCase,
4345
"pdf-settings": runPdfSettingsCase,
4446
"pdf-v5": runPdfV5Case,
4547
"docx-template-intake": runDocxTemplateIntakeCase,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import fixtureUrl from "../../../specs/import-pdf-mvp/fixtures/simple-untagged.pdf?url&no-inline";
2+
import {
3+
PDFIUM_ENGINE_VERSION,
4+
PDFIUM_WASM_SHA256,
5+
} from "@atlcli/import-pdf/browser-worker";
6+
import {
7+
isImportPdfWorkerResponse,
8+
type ImportPdfWorkerResponse,
9+
} from "./import-pdf-worker-protocol.js";
10+
11+
export interface ImportPdfCaseResult {
12+
pageCount: number;
13+
complete: boolean;
14+
classification: string;
15+
engine: string;
16+
engineVersion: string;
17+
wasmSha256: string;
18+
factsDigest: string;
19+
semanticDigest: string;
20+
titleCandidate: string | null;
21+
blockTypes: string[];
22+
workerTerminated: boolean;
23+
}
24+
25+
async function loadFixture(): Promise<Uint8Array<ArrayBuffer>> {
26+
const resolved = new URL(fixtureUrl, location.href);
27+
if (resolved.origin !== location.origin) {
28+
throw new Error("The PDF import fixture must be loaded from the harness origin.");
29+
}
30+
const response = await fetch(resolved);
31+
if (!response.ok) throw new Error(`The PDF import fixture failed to load (${response.status}).`);
32+
return new Uint8Array(await response.arrayBuffer());
33+
}
34+
35+
function runWorker(bytes: Uint8Array<ArrayBuffer>): Promise<ImportPdfWorkerResponse> {
36+
const worker = new Worker(new URL("./import-pdf-worker.ts", import.meta.url), {
37+
type: "module",
38+
name: "atlcli-browser-harness-import-pdf",
39+
});
40+
return new Promise((resolve, reject) => {
41+
const timeout = window.setTimeout(() => {
42+
worker.terminate();
43+
reject(new Error("The PDF import Worker exceeded its 30 second harness deadline."));
44+
}, 30_000);
45+
worker.onmessage = (event: MessageEvent<unknown>) => {
46+
if (!isImportPdfWorkerResponse(event.data) || event.data.requestId !== 1) return;
47+
window.clearTimeout(timeout);
48+
worker.terminate();
49+
resolve(event.data);
50+
};
51+
worker.onerror = (event) => {
52+
window.clearTimeout(timeout);
53+
worker.terminate();
54+
reject(new Error(event.message || "The PDF import Worker failed."));
55+
};
56+
worker.postMessage({ kind: "analyze", requestId: 1, bytes }, [bytes.buffer]);
57+
});
58+
}
59+
60+
export async function runImportPdfCase(): Promise<ImportPdfCaseResult> {
61+
const response = await runWorker(await loadFixture());
62+
if (!response.ok) throw new Error(response.error);
63+
const result = response.result;
64+
if (
65+
result.pageCount !== 1
66+
|| !result.complete
67+
|| result.classification !== "digital-untagged"
68+
|| result.engine !== "pdfium"
69+
|| result.engineVersion !== PDFIUM_ENGINE_VERSION
70+
|| result.wasmSha256 !== PDFIUM_WASM_SHA256
71+
|| result.titleCandidate !== "Quarterly Garden Notes"
72+
|| result.blockTypes.join(",") !== "heading,paragraph,paragraph,paragraph,heading,list"
73+
|| !/^[a-f0-9]{64}$/u.test(result.factsDigest)
74+
|| !/^[a-f0-9]{64}$/u.test(result.semanticDigest)
75+
) {
76+
throw new Error(`The browser PDF import facts drifted: ${JSON.stringify(result)}`);
77+
}
78+
return { ...result, workerTerminated: true };
79+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
export interface ImportPdfWorkerRequest {
2+
kind: "analyze";
3+
requestId: number;
4+
bytes: Uint8Array;
5+
}
6+
7+
export interface ImportPdfWorkerSuccess {
8+
kind: "result";
9+
requestId: number;
10+
ok: true;
11+
result: {
12+
pageCount: number;
13+
complete: boolean;
14+
classification: string;
15+
engine: string;
16+
engineVersion: string;
17+
wasmSha256: string;
18+
factsDigest: string;
19+
semanticDigest: string;
20+
titleCandidate: string | null;
21+
blockTypes: string[];
22+
};
23+
}
24+
25+
export interface ImportPdfWorkerFailure {
26+
kind: "result";
27+
requestId: number;
28+
ok: false;
29+
error: string;
30+
}
31+
32+
export type ImportPdfWorkerResponse = ImportPdfWorkerSuccess | ImportPdfWorkerFailure;
33+
34+
export function isImportPdfWorkerResponse(value: unknown): value is ImportPdfWorkerResponse {
35+
if (!value || typeof value !== "object") return false;
36+
const candidate = value as Partial<ImportPdfWorkerResponse>;
37+
return candidate.kind === "result"
38+
&& typeof candidate.requestId === "number"
39+
&& typeof candidate.ok === "boolean";
40+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/// <reference lib="webworker" />
2+
3+
import pdfiumWasmUrl from "@atlcli/import-pdf/wasm?url&no-inline";
4+
import {
5+
createBrowserPdfiumFactsAdapter,
6+
normalizeUntaggedPdfFacts,
7+
} from "@atlcli/import-pdf/browser-worker";
8+
import type {
9+
ImportPdfWorkerRequest,
10+
ImportPdfWorkerResponse,
11+
} from "./import-pdf-worker-protocol.js";
12+
13+
const scope = self as unknown as DedicatedWorkerGlobalScope;
14+
15+
async function sameOriginBytes(url: string): Promise<Uint8Array<ArrayBuffer>> {
16+
const resolved = new URL(url, scope.location.href);
17+
if (resolved.origin !== scope.location.origin) {
18+
throw new Error("The PDF importer runtime asset must be same-origin.");
19+
}
20+
const response = await fetch(resolved);
21+
if (!response.ok) {
22+
throw new Error(`The packaged PDFium asset failed to load (${response.status}).`);
23+
}
24+
return new Uint8Array(await response.arrayBuffer());
25+
}
26+
27+
async function analyze(request: ImportPdfWorkerRequest): Promise<void> {
28+
try {
29+
const wasmBinary = await sameOriginBytes(pdfiumWasmUrl);
30+
const adapter = createBrowserPdfiumFactsAdapter({ wasmBinary });
31+
const analyzed = await adapter.analyze(request.bytes);
32+
const semantics = await normalizeUntaggedPdfFacts(
33+
analyzed.facts,
34+
analyzed.factsDigest,
35+
);
36+
const response: ImportPdfWorkerResponse = {
37+
kind: "result",
38+
requestId: request.requestId,
39+
ok: true,
40+
result: {
41+
pageCount: analyzed.facts.pageCount,
42+
complete: analyzed.facts.completeness.complete,
43+
classification: analyzed.facts.classification,
44+
engine: analyzed.facts.provenance.engine,
45+
engineVersion: analyzed.facts.provenance.engineVersion,
46+
wasmSha256: analyzed.facts.provenance.wasmSha256,
47+
factsDigest: analyzed.factsDigest,
48+
semanticDigest: semantics.semanticDigest,
49+
titleCandidate: semantics.document.titleCandidate ?? null,
50+
blockTypes: semantics.document.blocks.map((block) => block.type),
51+
},
52+
};
53+
scope.postMessage(response);
54+
} catch (error) {
55+
const response: ImportPdfWorkerResponse = {
56+
kind: "result",
57+
requestId: request.requestId,
58+
ok: false,
59+
error: error instanceof Error ? error.message : String(error),
60+
};
61+
scope.postMessage(response);
62+
}
63+
}
64+
65+
scope.addEventListener("message", (event: MessageEvent<ImportPdfWorkerRequest>) => {
66+
if (event.data?.kind === "analyze") void analyze(event.data);
67+
});

apps/browser-export-harness/tests/boundaries.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,21 @@ describe("browser harness boundaries", () => {
5656
expect(combined).toContain("new Worker(new URL");
5757
});
5858

59+
it("keeps PDFium in the isolated import Worker and PDF.js out of the importer graph", () => {
60+
const worker = read("src/import-pdf-worker.ts");
61+
const importerFiles = [
62+
"src/import-pdf-case.ts",
63+
"src/import-pdf-worker.ts",
64+
"src/import-pdf-worker-protocol.ts",
65+
].map(read).join("\n");
66+
expect(worker).toContain('@atlcli/import-pdf/wasm?url&no-inline');
67+
expect(worker).toContain('@atlcli/import-pdf/browser-worker');
68+
expect(worker).toContain("sameOriginBytes");
69+
expect(importerFiles).not.toContain("pdfjs-dist");
70+
expect(importerFiles).not.toContain("DEFAULT_PDFIUM_WASM_URL");
71+
expect(importerFiles).not.toContain("@embedpdf/pdfium");
72+
});
73+
5974
it("keeps every font import static and checks it against the manifest", () => {
6075
const worker = read("src/pdf-worker.ts");
6176
const staticFontImports = [...worker.matchAll(/from\s+"@atlcli\/pdf\/fonts\/([^?]+)\?url"/g)]

0 commit comments

Comments
 (0)