diff --git a/docs/README.md b/docs/README.md
index fcbe562..a7e6b91 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,7 @@ The root [README](../README.md) introduces Secure Tools. This directory owns det
| [Privacy model](./privacy-model.md) | Local-processing and network boundaries, storage, security controls, and bounded privacy claims |
| [Dependencies](./dependencies.md) | Production runtime inventory, versions, vendoring, licenses, and integrity ownership |
| [Local OCR foundation](./ocr-foundation.md) | Self-hosted Tesseract assets, languages, lifecycle, cancellation, caching, and privacy guarantees |
+| [PDF OCR foundation](./pdf-ocr-foundation.md) | Typed local page rendering, OCR sequencing, progress, cancellation, and resource boundaries |
| [Sprint 16B Image → Text QA](./sprint-16b-qa.md) | Automated and Chromium browser evidence for the v2.1.0 Image → Text workflow |
| [Sprint 16C v2.1.0 release hardening](./sprint-16c-v2.1-release-hardening.md) | Release-candidate regression, OCR, privacy, browser, performance, and readiness evidence |
| [v2.1.0 release notes draft](./v2.1.0-release-notes-draft.md) | Unpublished release-note copy for the later promotion and release task |
diff --git a/docs/architecture.md b/docs/architecture.md
index daee86d..7ec1dd6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -47,6 +47,7 @@ The current production inventory and tool-specific behavior live in [tool status
- `js/config.js` centralizes repository links.
- `tools/shared/` owns common file admission, signature validation, image/PDF helpers, queue conventions, local save behavior, and shared tool presentation.
- `tools/shared/ocr.js` owns language selection, same-origin OCR paths, normalized progress, orientation-aware image preparation, worker reuse, cancellation, and disposal for the public Image → Text workflow.
+- `tools/shared/pdf-ocr.ts` composes the existing PDF.js renderer and OCR service into a sequential, cancellable per-page text pipeline for future PDF OCR interfaces. It does not generate searchable PDFs.
- The File System Access API is used when available; a revoking Blob-download fallback serves other browsers.
Tool implementations retain specialized models when their workflows differ. Organizer uses a page grid and PDF rendering lifecycle; Metadata tools use bounded inspection models and fail-closed output verification. Shared UI does not erase these tool-specific guarantees.
diff --git a/docs/pdf-ocr-foundation.md b/docs/pdf-ocr-foundation.md
new file mode 100644
index 0000000..27fbe49
--- /dev/null
+++ b/docs/pdf-ocr-foundation.md
@@ -0,0 +1,21 @@
+# PDF OCR foundation
+
+The PDF OCR foundation is a shared, strict TypeScript pipeline for future PDF-to-text and Searchable PDF workflows. It is not currently exposed as a public tool route. Searchable PDF generation remains a separate Sprint.
+
+## Pipeline
+
+`tools/shared/pdf-ocr.ts` loads a local PDF with the existing same-origin PDF.js renderer, renders each selected page to a white-background PNG canvas, passes that page image to the existing Tesseract OCR service, and returns an ordered array of `{ pageNumber, text }` results. The pipeline preserves English, Korean, and English + Korean recognition only.
+
+Pages are processed sequentially with one OCR service. A render scale of `2` corresponds to approximately 144 pixels per PDF inch and balances OCR detail with browser memory. The existing 16,384-pixel dimension and 50-megapixel canvas safeguards remain in force. Documents with 25 or more pages surface a `largeDocument` signal rather than receiving an arbitrary hard rejection.
+
+## Lifecycle and progress
+
+Progress reports document loading, page rendering, real OCR stages, completed pages, and overall progress derived from actual OCR values. Unknown OCR progress remains indeterminate. Callers may select every page or an explicit ordered list of one-based page numbers.
+
+Cancellation destroys the active PDF renderer and forwards an abort signal to OCR. Generation checks prevent late progress and page results after cancellation or source replacement. Every page proxy is cleaned, every canvas is reset to 1 × 1, the renderer is destroyed after each job, and OCR disposal terminates its worker. No page image, text, or PDF is uploaded, and no object URL is required.
+
+The core exposes review-ready page boundaries but no searchable PDF, positioned text layer, geometry mapping, or PDF output. A future public UI can add range parsing, review, copy, and TXT export around these typed contracts without changing the local processing boundary.
+
+## Verification
+
+Unit tests cover page selection, page numbering, all three OCR language contracts, progress, invalid input, retry, cancellation during rendering and recognition, source replacement, stale callbacks, large-document signaling, and resource cleanup. The internal browser harness performs real one-page PDF rendering and English OCR, real multi-page and larger-page rendering, cancellation timing, and same-origin request checks. Existing OCR smoke continues to execute real English, Korean, and combined recognition.
diff --git a/scripts/stage-typescript-test-dependencies.mjs b/scripts/stage-typescript-test-dependencies.mjs
index e7ae3e3..77c7c31 100644
--- a/scripts/stage-typescript-test-dependencies.mjs
+++ b/scripts/stage-typescript-test-dependencies.mjs
@@ -6,6 +6,6 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const stagingDirectory = path.join(root, ".ts-build", "tools", "shared");
fs.mkdirSync(stagingDirectory, { recursive: true });
-for (const file of ["image.js", "ocr.js", "save.js"]) {
+for (const file of ["image.js", "ocr.js", "pdf.js", "save.js"]) {
fs.copyFileSync(path.join(root, "tools", "shared", file), path.join(stagingDirectory, file));
}
diff --git a/scripts/typescript-modules.mjs b/scripts/typescript-modules.mjs
index beb21ac..6951c96 100644
--- a/scripts/typescript-modules.mjs
+++ b/scripts/typescript-modules.mjs
@@ -1,4 +1,9 @@
export const compiledBrowserModules = Object.freeze([
+ Object.freeze({
+ source: "tools/shared/pdf-ocr.ts",
+ compiled: "tools/shared/pdf-ocr.js",
+ public: "shared/pdf-ocr.js",
+ }),
Object.freeze({
source: "tools/image/to-text/controller.ts",
compiled: "tools/image/to-text/controller.js",
diff --git a/tests/browser/pdf-ocr-smoke.html b/tests/browser/pdf-ocr-smoke.html
new file mode 100644
index 0000000..059b92b
--- /dev/null
+++ b/tests/browser/pdf-ocr-smoke.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+ Secure Tools local PDF OCR smoke test
+
+
+
+ Local PDF OCR smoke test
+
+
+
+
+
+
+
diff --git a/tests/browser/pdf-ocr-smoke.js b/tests/browser/pdf-ocr-smoke.js
new file mode 100644
index 0000000..e9b5dd1
--- /dev/null
+++ b/tests/browser/pdf-ocr-smoke.js
@@ -0,0 +1,93 @@
+import { createPdfOcrService } from "/shared/pdf-ocr.js";
+
+const output = document.querySelector("#result");
+const requestsBefore = performance.getEntriesByType("resource").map((entry) => entry.name);
+
+async function createPdf(labels, dimensions = [612, 792]) {
+ const document = await window.PDFLib.PDFDocument.create();
+ const font = await document.embedFont(window.PDFLib.StandardFonts.HelveticaBold);
+ for (const label of labels) {
+ const page = document.addPage(dimensions);
+ page.drawText(label, { x: 90, y: dimensions[1] / 2, size: 88, font, color: window.PDFLib.rgb(0, 0, 0) });
+ }
+ const bytes = await document.save();
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+}
+
+async function runRealOcr() {
+ const service = createPdfOcrService();
+ const started = performance.now();
+ try {
+ const result = await service.recognizeDocument({ sourceBytes: await createPdf(["HELLO"]), language: "eng" });
+ if (result.pages.length !== 1 || result.pages[0].pageNumber !== 1 || !/HELLO/i.test(result.pages[0].text)) {
+ throw new Error(`Unexpected PDF OCR result: ${JSON.stringify(result.pages)}`);
+ }
+ return { milliseconds: Math.round(performance.now() - started), text: result.pages[0].text.trim() };
+ } finally {
+ await service.dispose();
+ }
+}
+
+async function runRenderingSanity(labels, dimensions) {
+ let calls = 0;
+ const service = createPdfOcrService({
+ ocrService: {
+ async recognizeImage(image, options) {
+ calls += 1;
+ if (image.type !== "image/png") throw new Error(`Unexpected rendered type: ${image.type}`);
+ options.onProgress?.({ stage: "recognizing", progress: 1 });
+ return { text: `page ${calls}` };
+ },
+ async dispose() {},
+ },
+ });
+ const started = performance.now();
+ try {
+ const result = await service.recognizeDocument({ sourceBytes: await createPdf(labels, dimensions), language: "eng" });
+ if (result.pages.length !== labels.length) throw new Error(`Expected ${labels.length} pages, received ${result.pages.length}`);
+ return { milliseconds: Math.round(performance.now() - started), pages: result.pages.length };
+ } finally {
+ await service.dispose();
+ }
+}
+
+async function runCancellationSanity() {
+ let recognizing;
+ const reachedRecognition = new Promise((resolve) => { recognizing = resolve; });
+ const service = createPdfOcrService({
+ ocrService: {
+ recognizeImage(_image, options) {
+ recognizing();
+ return new Promise((_resolve, reject) => {
+ options.signal.addEventListener("abort", () => reject(Object.assign(new Error("OCR_CANCELLED"), { code: "OCR_CANCELLED" })), { once: true });
+ });
+ },
+ async dispose() {},
+ },
+ });
+ const task = service.recognizeDocument({ sourceBytes: await createPdf(["CANCEL"]), language: "eng" });
+ await reachedRecognition;
+ const started = performance.now();
+ await service.cancel();
+ let code = null;
+ try { await task; } catch (error) { code = error.code; }
+ await service.dispose();
+ if (code !== "PDF_OCR_CANCELLED") throw new Error(`Unexpected cancellation result: ${code}`);
+ return { milliseconds: Math.round(performance.now() - started) };
+}
+
+try {
+ const onePage = await runRealOcr();
+ const multiPage = await runRenderingSanity(["ONE", "TWO", "THREE"], [612, 792]);
+ const largerPage = await runRenderingSanity(["LARGE"], [900, 1200]);
+ const cancellation = await runCancellationSanity();
+ const requests = performance.getEntriesByType("resource").map((entry) => entry.name).slice(requestsBefore.length);
+ const externalRequests = requests.filter((value) => new URL(value).origin !== location.origin);
+ if (externalRequests.length) throw new Error(`External requests: ${externalRequests.join(", ")}`);
+ window.__pdfOcrSmokeResult = { ok: true, onePage, multiPage, largerPage, cancellation, requests, externalRequests };
+ output.textContent = `PASS: PDF OCR — ${onePage.text} | 1 page ${onePage.milliseconds} ms | 3 pages ${multiPage.milliseconds} ms | large page ${largerPage.milliseconds} ms | cancel ${cancellation.milliseconds} ms`;
+} catch (error) {
+ window.__pdfOcrSmokeResult = { ok: false, error: error.message, cause: error.cause?.message || null };
+ output.textContent = `FAIL: ${error.message}`;
+ throw error;
+}
diff --git a/tests/pdf-ocr-foundation.test.mjs b/tests/pdf-ocr-foundation.test.mjs
new file mode 100644
index 0000000..46b5785
--- /dev/null
+++ b/tests/pdf-ocr-foundation.test.mjs
@@ -0,0 +1,262 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ PDF_OCR_LARGE_DOCUMENT_THRESHOLD,
+ PDF_OCR_MAX_RENDER_DIMENSION,
+ PDF_OCR_MAX_RENDER_PIXELS,
+ PDF_OCR_RENDER_SCALE,
+ createPdfOcrService,
+ readPdfOcrSource,
+ resolvePdfOcrPages,
+} from "../.ts-build/tools/shared/pdf-ocr.js";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const read = (relative) => fs.readFileSync(path.join(root, relative), "utf8");
+const codedError = (code) => Object.assign(new Error(code), { code });
+const waitTurn = () => new Promise((resolve) => setImmediate(resolve));
+
+function canvasHarness(events) {
+ return {
+ width: 0,
+ height: 0,
+ getContext() { return {}; },
+ toBlob(callback, type) {
+ events.push(["encode", this.width, this.height, type]);
+ callback(new Blob([`page:${this.width}x${this.height}`], { type }));
+ },
+ };
+}
+
+function rendererHarness({ pageCount = 3, loadError = null, blockRender = false } = {}) {
+ const events = [];
+ let rejectRender = null;
+ const renderer = {
+ async load() {
+ events.push("load");
+ if (loadError) throw loadError;
+ return pageCount;
+ },
+ async getPage(pageNumber) {
+ events.push(["getPage", pageNumber]);
+ return {
+ getViewport({ scale }) { return { width: (100 + pageNumber) * scale, height: 50 * scale }; },
+ cleanup() { events.push(["cleanup", pageNumber]); },
+ };
+ },
+ runRender(page, context) {
+ events.push(["render", context.viewport.width, context.background]);
+ if (!blockRender) return Promise.resolve();
+ return new Promise((resolve, reject) => { rejectRender = reject; });
+ },
+ async destroy() {
+ events.push("destroy");
+ rejectRender?.(new Error("render cancelled"));
+ rejectRender = null;
+ },
+ };
+ return { renderer, events };
+}
+
+function ocrHarness(handler = async (_image, options) => ({ text: `${options.language} text` })) {
+ const events = [];
+ return {
+ service: {
+ async recognizeImage(image, options) {
+ events.push(["recognize", options.language, image.type]);
+ options.onProgress?.({ stage: "recognizing", progress: 0.5 });
+ return handler(image, options);
+ },
+ async dispose() { events.push("dispose"); },
+ },
+ events,
+ };
+}
+
+assert.equal(PDF_OCR_RENDER_SCALE, 2);
+assert.equal(PDF_OCR_MAX_RENDER_DIMENSION, 16_384);
+assert.equal(PDF_OCR_MAX_RENDER_PIXELS, 50_000_000);
+assert.equal(PDF_OCR_LARGE_DOCUMENT_THRESHOLD, 25);
+assert.deepEqual(resolvePdfOcrPages({ mode: "all" }, 3), [1, 2, 3]);
+assert.deepEqual(resolvePdfOcrPages({ mode: "selected", pageNumbers: [3, 1] }, 3), [3, 1]);
+for (const [selection, code] of [
+ [{ mode: "selected", pageNumbers: [] }, "PDF_OCR_PAGE_SELECTION_REQUIRED"],
+ [{ mode: "selected", pageNumbers: [0] }, "PDF_OCR_PAGE_SELECTION_INVALID"],
+ [{ mode: "selected", pageNumbers: [4] }, "PDF_OCR_PAGE_OUT_OF_RANGE"],
+ [{ mode: "selected", pageNumbers: [2, 2] }, "PDF_OCR_PAGE_DUPLICATE"],
+]) {
+ assert.throws(() => resolvePdfOcrPages(selection, 3), (error) => error.code === code);
+}
+assert.throws(() => resolvePdfOcrPages({ mode: "all" }, 0), (error) => error.code === "PDF_HAS_NO_PAGES");
+
+await assert.rejects(
+ readPdfOcrSource(Object.assign(new Blob(["x"], { type: "text/plain" }), { name: "notes.txt" })),
+ (error) => error.code === "UNSUPPORTED_PDF",
+);
+const acceptedPdf = Object.assign(new Blob(["%PDF-1.7"], { type: "application/pdf" }), { name: "input.pdf" });
+assert.equal(new TextDecoder().decode(await readPdfOcrSource(acceptedPdf)), "%PDF-1.7");
+
+{
+ const renderer = rendererHarness({ pageCount: 4 });
+ const ocr = ocrHarness();
+ const canvases = [];
+ const progress = [];
+ const pageResults = [];
+ const service = createPdfOcrService({
+ rendererFactory: async () => renderer.renderer,
+ ocrService: ocr.service,
+ canvasFactory() { const canvas = canvasHarness(renderer.events); canvases.push(canvas); return canvas; },
+ });
+ const result = await service.recognizeDocument({
+ sourceBytes: Uint8Array.of(1, 2, 3).buffer,
+ language: "eng+kor",
+ selection: { mode: "selected", pageNumbers: [3, 1] },
+ onProgress(value) { progress.push(value); },
+ onPageResult(value) { pageResults.push(value); },
+ });
+ assert.deepEqual(result.selectedPageNumbers, [3, 1]);
+ assert.deepEqual(result.pages, [{ pageNumber: 3, text: "eng+kor text" }, { pageNumber: 1, text: "eng+kor text" }]);
+ assert.deepEqual(pageResults, result.pages);
+ assert.equal(result.pageCount, 4);
+ assert.equal(result.largeDocument, false);
+ assert.deepEqual(ocr.events.filter((event) => event[0] === "recognize").map((event) => event[1]), ["eng+kor", "eng+kor"]);
+ assert.deepEqual(renderer.events.filter((event) => Array.isArray(event) && event[0] === "getPage"), [["getPage", 3], ["getPage", 1]]);
+ assert.deepEqual(renderer.events.filter((event) => Array.isArray(event) && event[0] === "cleanup"), [["cleanup", 3], ["cleanup", 1]]);
+ assert.equal(renderer.events.filter((event) => event === "destroy").length, 1);
+ assert.ok(canvases.every((canvas) => canvas.width === 1 && canvas.height === 1), "every page canvas is released");
+ assert.equal(progress[0].phase, "loading-document");
+ assert.ok(progress.some((value) => value.phase === "rendering-page" && value.pageNumber === 3));
+ assert.ok(progress.some((value) => value.phase === "recognizing-page" && value.pageProgress === 0.5));
+ assert.deepEqual(progress.at(-1), {
+ phase: "complete", pageNumber: 1, pageIndex: 2, pageCount: 2, documentPageCount: 4,
+ pageProgress: 1, overallProgress: 1, ocrStage: "complete",
+ });
+ await service.dispose();
+ assert.deepEqual(ocr.events.at(-1), "dispose");
+}
+
+{
+ const languages = [];
+ const ocr = ocrHarness(async (_image, options) => { languages.push(options.language); return { text: options.language }; });
+ const service = createPdfOcrService({
+ rendererFactory: async () => rendererHarness({ pageCount: 1 }).renderer,
+ ocrService: ocr.service,
+ canvasFactory: () => canvasHarness([]),
+ });
+ for (const language of ["eng", "kor", "eng+kor"]) {
+ const result = await service.recognizeDocument({ sourceBytes: new ArrayBuffer(1), language });
+ assert.equal(result.pages[0].text, language);
+ }
+ assert.deepEqual(languages, ["eng", "kor", "eng+kor"]);
+ await service.dispose();
+}
+
+{
+ let savedProgress = null;
+ let recognitionAttempts = 0;
+ const emitted = [];
+ const appended = [];
+ const ocr = ocrHarness((_image, options) => {
+ recognitionAttempts += 1;
+ if (recognitionAttempts > 1) return Promise.resolve({ text: "replacement" });
+ return new Promise((_resolve, reject) => {
+ savedProgress = options.onProgress;
+ options.signal.addEventListener("abort", () => reject(codedError("OCR_CANCELLED")), { once: true });
+ });
+ });
+ const service = createPdfOcrService({
+ rendererFactory: async () => rendererHarness({ pageCount: 2 }).renderer,
+ ocrService: ocr.service,
+ canvasFactory: () => canvasHarness([]),
+ });
+ const task = service.recognizeDocument({
+ sourceBytes: new ArrayBuffer(2), language: "eng",
+ onProgress(value) { emitted.push(value); },
+ onPageResult(value) { appended.push(value); },
+ });
+ await waitTurn();
+ assert.equal(await service.cancel(), true);
+ await assert.rejects(task, (error) => error.code === "PDF_OCR_CANCELLED");
+ const countAfterCancel = emitted.length;
+ savedProgress?.({ stage: "recognizing", progress: 1 });
+ assert.equal(emitted.length, countAfterCancel, "stale progress is rejected after cancellation");
+ assert.deepEqual(appended, [], "no stale page result is appended");
+
+ const replacementResult = await service.recognizeDocument({
+ sourceBytes: Uint8Array.of(9).buffer,
+ language: "eng",
+ selection: { mode: "selected", pageNumbers: [2] },
+ });
+ assert.equal(replacementResult.pages[0].text, "replacement", "a replacement source can start cleanly");
+ await service.dispose();
+}
+
+{
+ const renderer = rendererHarness({ pageCount: 1, blockRender: true });
+ const ocr = ocrHarness();
+ const service = createPdfOcrService({
+ rendererFactory: async () => renderer.renderer,
+ ocrService: ocr.service,
+ canvasFactory: () => canvasHarness(renderer.events),
+ });
+ const task = service.recognizeDocument({ sourceBytes: new ArrayBuffer(1), language: "eng" });
+ await waitTurn();
+ const started = performance.now();
+ assert.equal(await service.cancel(), true);
+ assert.ok(performance.now() - started < 1_000, "render cancellation is responsive");
+ await assert.rejects(task, (error) => error.code === "PDF_OCR_CANCELLED");
+ assert.equal(renderer.events.filter((event) => event === "destroy").length, 1, "render cancellation destroys once");
+ await service.dispose();
+}
+
+{
+ let attempts = 0;
+ const ocr = ocrHarness(async () => {
+ attempts += 1;
+ if (attempts === 1) throw codedError("OCR_RECOGNITION_FAILED");
+ return { text: "retry succeeded" };
+ });
+ const service = createPdfOcrService({
+ rendererFactory: async () => rendererHarness({ pageCount: 1 }).renderer,
+ ocrService: ocr.service,
+ canvasFactory: () => canvasHarness([]),
+ });
+ await assert.rejects(service.recognizeDocument({ sourceBytes: new ArrayBuffer(1), language: "eng" }), (error) => error.code === "PDF_OCR_FAILED");
+ assert.equal((await service.recognizeDocument({ sourceBytes: new ArrayBuffer(1), language: "eng" })).pages[0].text, "retry succeeded");
+ await service.dispose();
+ await assert.rejects(service.recognizeDocument({ sourceBytes: new ArrayBuffer(1), language: "eng" }), (error) => error.code === "PDF_OCR_DISPOSED");
+}
+
+{
+ const renderer = rendererHarness({ loadError: new Error("FormatError: Invalid PDF structure") });
+ const ocr = ocrHarness();
+ const service = createPdfOcrService({ rendererFactory: async () => renderer.renderer, ocrService: ocr.service, canvasFactory: () => canvasHarness([]) });
+ await assert.rejects(service.recognizeDocument({ sourceBytes: new ArrayBuffer(1), language: "eng" }), (error) => error.code === "UNREADABLE_PDF");
+ assert.equal(renderer.events.filter((event) => event === "destroy").length, 1);
+ await service.dispose();
+}
+
+{
+ const renderer = rendererHarness({ pageCount: PDF_OCR_LARGE_DOCUMENT_THRESHOLD });
+ const ocr = ocrHarness();
+ const service = createPdfOcrService({ rendererFactory: async () => renderer.renderer, ocrService: ocr.service, canvasFactory: () => canvasHarness([]) });
+ const result = await service.recognizeDocument({
+ sourceBytes: new ArrayBuffer(1), language: "eng", selection: { mode: "selected", pageNumbers: [1] },
+ });
+ assert.equal(result.largeDocument, true, "large documents surface a warning signal without an arbitrary hard limit");
+ await service.dispose();
+}
+
+const source = read("tools/shared/pdf-ocr.ts");
+assert.match(source, /for \(let index = 0; index < selectedPageNumbers\.length; index \+= 1\)/, "pages process sequentially");
+assert.match(source, /page\.cleanup\(\);[\s\S]*canvas\.width = 1;[\s\S]*canvas\.height = 1;/);
+assert.doesNotMatch(source, /fetch\s*\(|XMLHttpRequest|sendBeacon|WebSocket|EventSource|https?:\/\//);
+assert.doesNotMatch(source, /createObjectURL|revokeObjectURL/);
+assert.doesNotMatch(source, /\bany\b|sourceMappingURL/);
+assert.match(read("docs/pdf-ocr-foundation.md"), /Searchable PDF generation remains a separate Sprint/);
+assert.match(read("tests/browser/pdf-ocr-smoke.html"), /connect-src 'none'/);
+assert.match(read("tests/browser/pdf-ocr-smoke.js"), /externalRequests/);
+
+console.log("PDF OCR page selection, typed progress, sequential rendering, cancellation, stale-result, retry, and cleanup checks passed.");
diff --git a/tests/run-all.mjs b/tests/run-all.mjs
index 1dfecc8..5d4b8cb 100644
--- a/tests/run-all.mjs
+++ b/tests/run-all.mjs
@@ -31,6 +31,7 @@ for (const test of [
"tests/image-compressor.test.mjs",
"tests/image-metadata.test.mjs",
"tests/ocr-foundation.test.mjs",
+ "tests/pdf-ocr-foundation.test.mjs",
"tests/image-to-text.test.mjs",
"tests/typescript-foundation.test.mjs",
"tests/category-availability.test.mjs",
diff --git a/tests/serve-ocr-smoke.mjs b/tests/serve-ocr-smoke.mjs
index 826b8a1..b1d098a 100644
--- a/tests/serve-ocr-smoke.mjs
+++ b/tests/serve-ocr-smoke.mjs
@@ -13,6 +13,7 @@ const contentTypes = new Map([
[".gz", "application/gzip"],
[".html", "text/html; charset=utf-8"],
[".js", "text/javascript; charset=utf-8"],
+ [".mjs", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".png", "image/png"],
[".ico", "image/x-icon"],
@@ -50,5 +51,6 @@ const server = http.createServer((request, response) => {
server.listen(port, "127.0.0.1", () => {
console.log(`OCR browser smoke: http://127.0.0.1:${port}/tests/browser/ocr-smoke.html`);
+ console.log(`PDF OCR browser smoke: http://127.0.0.1:${port}/tests/browser/pdf-ocr-smoke.html`);
console.log(`Image to Text UI QA: http://127.0.0.1:${port}/image/to-text/`);
});
diff --git a/tests/typescript-foundation.test.mjs b/tests/typescript-foundation.test.mjs
index f8cb91e..7180ac7 100644
--- a/tests/typescript-foundation.test.mjs
+++ b/tests/typescript-foundation.test.mjs
@@ -21,7 +21,7 @@ assert.equal(typeConfig.compilerOptions.noEmit, true);
assert.equal(typeConfig.compilerOptions.sourceMap, false);
assert.equal(buildConfig.compilerOptions.outDir, ".ts-build");
-assert.equal(compiledBrowserModules.length, 3, "the initial migration remains intentionally bounded");
+assert.equal(compiledBrowserModules.length, 4, "the compiled TypeScript boundary remains intentionally bounded");
assert.equal(new Set(compiledBrowserModules.map((module) => module.public)).size, compiledBrowserModules.length);
for (const module of compiledBrowserModules) {
assert.ok(fs.existsSync(path.join(root, module.source)), `${module.source} exists`);
diff --git a/tools/shared/ocr.d.ts b/tools/shared/ocr.d.ts
index 0cc5a4d..9a5fd4e 100644
--- a/tools/shared/ocr.d.ts
+++ b/tools/shared/ocr.d.ts
@@ -12,5 +12,21 @@ export interface OcrProgress {
progress: number | null;
}
+export interface OcrRecognitionOptions {
+ language: OcrLanguage;
+ signal?: AbortSignal;
+ onProgress?: (progress: OcrProgress) => void;
+}
+
+export interface OcrService {
+ recognizeImage(image: Blob, options: OcrRecognitionOptions): Promise<{ text: string }>;
+ dispose(): Promise;
+}
+
+export interface OcrServiceConfiguration {
+ prepareImage?: (image: Blob) => Promise;
+}
+
export function resolveOcrLanguage(language: unknown): OcrLanguage;
export function prepareImageForOcr(image: Blob): Promise;
+export function createOcrService(configuration?: OcrServiceConfiguration): OcrService;
diff --git a/tools/shared/pdf-ocr.ts b/tools/shared/pdf-ocr.ts
new file mode 100644
index 0000000..b7eaba4
--- /dev/null
+++ b/tools/shared/pdf-ocr.ts
@@ -0,0 +1,321 @@
+import { createOcrService, resolveOcrLanguage } from "./ocr.js";
+import type { OcrLanguage, OcrProgress, OcrService } from "./ocr.js";
+import { isSupportedPdf } from "./pdf.js";
+import type { PdfFileLike } from "./pdf.js";
+import type { LocalPdfRenderer, PdfPageProxy, PdfRenderContext } from "./pdf-renderer.js";
+
+export const PDF_OCR_RENDER_SCALE = 2;
+export const PDF_OCR_MAX_RENDER_DIMENSION = 16_384;
+export const PDF_OCR_MAX_RENDER_PIXELS = 50_000_000;
+export const PDF_OCR_LARGE_DOCUMENT_THRESHOLD = 25;
+
+export type PdfOcrPhase = "loading-document" | "rendering-page" | "recognizing-page" | "complete";
+
+export type PdfOcrPageSelection =
+ | { mode: "all" }
+ | { mode: "selected"; pageNumbers: readonly number[] };
+
+export interface PdfOcrPageResult {
+ pageNumber: number;
+ text: string;
+}
+
+export interface PdfOcrProgress {
+ phase: PdfOcrPhase;
+ pageNumber: number | null;
+ pageIndex: number;
+ pageCount: number;
+ documentPageCount: number | null;
+ pageProgress: number | null;
+ overallProgress: number | null;
+ ocrStage: OcrProgress["stage"] | null;
+}
+
+export interface PdfOcrDocumentResult {
+ pageCount: number;
+ selectedPageNumbers: readonly number[];
+ pages: readonly PdfOcrPageResult[];
+ largeDocument: boolean;
+}
+
+export interface PdfOcrRequest {
+ sourceBytes: ArrayBuffer;
+ language: OcrLanguage;
+ selection?: PdfOcrPageSelection;
+ signal?: AbortSignal;
+ onProgress?: (progress: PdfOcrProgress) => void;
+ onPageResult?: (result: PdfOcrPageResult) => void;
+}
+
+export interface PdfOcrRenderer {
+ load(): Promise;
+ getPage(pageNumber: number): Promise;
+ runRender(page: PdfPageProxy, renderContext: PdfRenderContext): Promise;
+ destroy(): Promise;
+}
+
+export interface PdfOcrServiceConfiguration {
+ ocrService?: OcrService;
+ rendererFactory?: (sourceBytes: ArrayBuffer) => PdfOcrRenderer | Promise;
+ canvasFactory?: () => HTMLCanvasElement;
+ renderScale?: number;
+}
+
+export interface PdfOcrService {
+ recognizeDocument(request: PdfOcrRequest): Promise;
+ cancel(): Promise;
+ dispose(): Promise;
+}
+
+interface ActiveJob {
+ generation: number;
+ controller: AbortController;
+ promise: Promise;
+}
+
+interface ErrorWithCode extends Error {
+ code: string;
+}
+
+function pdfOcrError(code: string, cause?: unknown): ErrorWithCode {
+ const options = cause === undefined ? undefined : { cause };
+ return Object.assign(new Error(code, options), { code });
+}
+
+function hasErrorCode(error: unknown): error is ErrorWithCode {
+ return error instanceof Error && "code" in error && typeof error.code === "string";
+}
+
+function errorCode(error: unknown): string | undefined {
+ return hasErrorCode(error) ? error.code : undefined;
+}
+
+function errorDetail(error: unknown): string {
+ if (!(error instanceof Error)) return "";
+ return `${error.name} ${error.message}`.toLowerCase();
+}
+
+function throwIfAborted(signal: AbortSignal): void {
+ if (signal.aborted) throw pdfOcrError("PDF_OCR_CANCELLED");
+}
+
+function normalizePipelineError(error: unknown, signal: AbortSignal): ErrorWithCode {
+ if (signal.aborted || errorCode(error) === "OCR_CANCELLED") return pdfOcrError("PDF_OCR_CANCELLED", error);
+ if (hasErrorCode(error) && error.code.startsWith("PDF_OCR_")) return error;
+ const detail = errorDetail(error);
+ if (detail.includes("password") || detail.includes("encrypt")) return pdfOcrError("ENCRYPTED_PDF", error);
+ if (detail.includes("invalid pdf") || detail.includes("missing pdf") || detail.includes("formaterror")) {
+ return pdfOcrError("UNREADABLE_PDF", error);
+ }
+ return pdfOcrError("PDF_OCR_FAILED", error);
+}
+
+function validateRenderDimensions(widthValue: number, heightValue: number): { width: number; height: number } {
+ const width = Math.ceil(widthValue);
+ const height = Math.ceil(heightValue);
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
+ throw pdfOcrError("PDF_OCR_RENDER_DIMENSIONS_INVALID");
+ }
+ if (width > PDF_OCR_MAX_RENDER_DIMENSION || height > PDF_OCR_MAX_RENDER_DIMENSION) {
+ throw pdfOcrError("PDF_OCR_RENDER_DIMENSION_EXCEEDED");
+ }
+ if (width * height > PDF_OCR_MAX_RENDER_PIXELS) throw pdfOcrError("PDF_OCR_RENDER_PIXELS_EXCEEDED");
+ return { width, height };
+}
+
+function canvasToPngBlob(canvas: HTMLCanvasElement): Promise {
+ return new Promise((resolve, reject) => {
+ canvas.toBlob((blob) => {
+ if (blob?.type === "image/png") resolve(blob);
+ else reject(pdfOcrError("PDF_OCR_IMAGE_ENCODING_FAILED"));
+ }, "image/png");
+ });
+}
+
+async function renderPage(
+ renderer: PdfOcrRenderer,
+ pageNumber: number,
+ scale: number,
+ canvasFactory: () => HTMLCanvasElement,
+ signal: AbortSignal,
+): Promise {
+ throwIfAborted(signal);
+ const page = await renderer.getPage(pageNumber);
+ const viewport = page.getViewport({ scale });
+ const dimensions = validateRenderDimensions(viewport.width, viewport.height);
+ const canvas = canvasFactory();
+ canvas.width = dimensions.width;
+ canvas.height = dimensions.height;
+ const context = canvas.getContext("2d", { alpha: false });
+ if (!context) {
+ page.cleanup();
+ canvas.width = 1;
+ canvas.height = 1;
+ throw pdfOcrError("CANVAS_UNAVAILABLE");
+ }
+ try {
+ await renderer.runRender(page, { canvasContext: context, viewport, background: "#ffffff" });
+ throwIfAborted(signal);
+ const blob = await canvasToPngBlob(canvas);
+ throwIfAborted(signal);
+ return blob;
+ } finally {
+ page.cleanup();
+ canvas.width = 1;
+ canvas.height = 1;
+ }
+}
+
+export function resolvePdfOcrPages(selection: PdfOcrPageSelection | undefined, pageCount: number): number[] {
+ if (!Number.isSafeInteger(pageCount) || pageCount < 1) throw pdfOcrError("PDF_HAS_NO_PAGES");
+ if (!selection || selection.mode === "all") {
+ return Array.from({ length: pageCount }, (_, index) => index + 1);
+ }
+ if (selection.mode !== "selected" || selection.pageNumbers.length < 1) {
+ throw pdfOcrError("PDF_OCR_PAGE_SELECTION_REQUIRED");
+ }
+ const pages: number[] = [];
+ const seen = new Set();
+ for (const pageNumber of selection.pageNumbers) {
+ if (!Number.isSafeInteger(pageNumber) || pageNumber < 1) throw pdfOcrError("PDF_OCR_PAGE_SELECTION_INVALID");
+ if (pageNumber > pageCount) throw pdfOcrError("PDF_OCR_PAGE_OUT_OF_RANGE");
+ if (seen.has(pageNumber)) throw pdfOcrError("PDF_OCR_PAGE_DUPLICATE");
+ seen.add(pageNumber);
+ pages.push(pageNumber);
+ }
+ return pages;
+}
+
+export async function readPdfOcrSource(file: PdfFileLike): Promise {
+ if (!isSupportedPdf(file)) throw pdfOcrError("UNSUPPORTED_PDF");
+ return file.arrayBuffer();
+}
+
+async function defaultRendererFactory(sourceBytes: ArrayBuffer): Promise {
+ const module = await import("./pdf-renderer.js");
+ const Renderer: typeof LocalPdfRenderer = module.LocalPdfRenderer;
+ return new Renderer(sourceBytes);
+}
+
+export function createPdfOcrService(configuration: PdfOcrServiceConfiguration = {}): PdfOcrService {
+ const ocrService = configuration.ocrService || createOcrService();
+ const rendererFactory = configuration.rendererFactory || defaultRendererFactory;
+ const canvasFactory = configuration.canvasFactory || (() => document.createElement("canvas"));
+ const renderScale = configuration.renderScale ?? PDF_OCR_RENDER_SCALE;
+ if (!Number.isFinite(renderScale) || renderScale <= 0) throw pdfOcrError("PDF_OCR_RENDER_SCALE_INVALID");
+
+ let generation = 0;
+ let active: ActiveJob | null = null;
+ let disposed = false;
+
+ async function run(request: PdfOcrRequest, jobGeneration: number, signal: AbortSignal): Promise {
+ const language = resolveOcrLanguage(request.language);
+ let sourceBytes = request.sourceBytes.slice(0);
+ let renderer: PdfOcrRenderer | null = null;
+ let destroying: Promise | null = null;
+ const destroyRenderer = (): Promise => {
+ if (!renderer) return Promise.resolve();
+ destroying ||= renderer.destroy();
+ return destroying;
+ };
+ const emit = (progress: PdfOcrProgress): void => {
+ if (!disposed && jobGeneration === generation && !signal.aborted) request.onProgress?.(progress);
+ };
+ const append = (result: PdfOcrPageResult): void => {
+ if (!disposed && jobGeneration === generation && !signal.aborted) request.onPageResult?.(result);
+ };
+ const abortRenderer = (): void => { void destroyRenderer().catch(() => {}); };
+ signal.addEventListener("abort", abortRenderer, { once: true });
+ try {
+ throwIfAborted(signal);
+ emit({ phase: "loading-document", pageNumber: null, pageIndex: 0, pageCount: 0, documentPageCount: null, pageProgress: null, overallProgress: 0, ocrStage: null });
+ renderer = await rendererFactory(sourceBytes);
+ const documentPageCount = await renderer.load();
+ throwIfAborted(signal);
+ const selectedPageNumbers = resolvePdfOcrPages(request.selection, documentPageCount);
+ const pages: PdfOcrPageResult[] = [];
+
+ for (let index = 0; index < selectedPageNumbers.length; index += 1) {
+ const pageNumber = selectedPageNumbers[index];
+ emit({
+ phase: "rendering-page", pageNumber, pageIndex: index + 1, pageCount: selectedPageNumbers.length,
+ documentPageCount, pageProgress: null, overallProgress: index / selectedPageNumbers.length, ocrStage: null,
+ });
+ const image = await renderPage(renderer, pageNumber, renderScale, canvasFactory, signal);
+ throwIfAborted(signal);
+ const recognized = await ocrService.recognizeImage(image, {
+ language,
+ signal,
+ onProgress(progress: OcrProgress) {
+ const pageProgress = progress.progress;
+ emit({
+ phase: "recognizing-page", pageNumber, pageIndex: index + 1, pageCount: selectedPageNumbers.length,
+ documentPageCount, pageProgress,
+ overallProgress: pageProgress === null ? null : (index + pageProgress) / selectedPageNumbers.length,
+ ocrStage: progress.stage,
+ });
+ },
+ });
+ throwIfAborted(signal);
+ const result = Object.freeze({ pageNumber, text: recognized.text });
+ pages.push(result);
+ append(result);
+ }
+
+ emit({
+ phase: "complete", pageNumber: selectedPageNumbers.at(-1) ?? null, pageIndex: selectedPageNumbers.length,
+ pageCount: selectedPageNumbers.length, documentPageCount, pageProgress: 1, overallProgress: 1, ocrStage: "complete",
+ });
+ return Object.freeze({
+ pageCount: documentPageCount,
+ selectedPageNumbers: Object.freeze([...selectedPageNumbers]),
+ pages: Object.freeze([...pages]),
+ largeDocument: documentPageCount >= PDF_OCR_LARGE_DOCUMENT_THRESHOLD,
+ });
+ } catch (error: unknown) {
+ throw normalizePipelineError(error, signal);
+ } finally {
+ signal.removeEventListener("abort", abortRenderer);
+ await destroyRenderer().catch(() => {});
+ sourceBytes = new ArrayBuffer(0);
+ }
+ }
+
+ async function recognizeDocument(request: PdfOcrRequest): Promise {
+ if (disposed) throw pdfOcrError("PDF_OCR_DISPOSED");
+ if (active) throw pdfOcrError("PDF_OCR_BUSY");
+ const jobGeneration = ++generation;
+ const controller = new AbortController();
+ const forwardAbort = (): void => controller.abort();
+ request.signal?.addEventListener("abort", forwardAbort, { once: true });
+ if (request.signal?.aborted) controller.abort();
+ const promise = run(request, jobGeneration, controller.signal);
+ active = { generation: jobGeneration, controller, promise };
+ try {
+ return await promise;
+ } finally {
+ request.signal?.removeEventListener("abort", forwardAbort);
+ if (active?.generation === jobGeneration) active = null;
+ }
+ }
+
+ async function cancel(): Promise {
+ if (!active) return false;
+ const current = active;
+ generation += 1;
+ active = null;
+ current.controller.abort();
+ try { await current.promise; } catch { /* Cancellation is reported by the active request. */ }
+ return true;
+ }
+
+ async function dispose(): Promise {
+ if (disposed) return;
+ await cancel();
+ disposed = true;
+ generation += 1;
+ await ocrService.dispose();
+ }
+
+ return Object.freeze({ recognizeDocument, cancel, dispose });
+}
diff --git a/tools/shared/pdf-renderer.d.ts b/tools/shared/pdf-renderer.d.ts
new file mode 100644
index 0000000..f6af021
--- /dev/null
+++ b/tools/shared/pdf-renderer.d.ts
@@ -0,0 +1,23 @@
+export interface PdfViewport {
+ readonly width: number;
+ readonly height: number;
+}
+
+export interface PdfPageProxy {
+ getViewport(options: { scale: number }): PdfViewport;
+ cleanup(): void;
+}
+
+export interface PdfRenderContext {
+ canvasContext: CanvasRenderingContext2D;
+ viewport: PdfViewport;
+ background: string;
+}
+
+export class LocalPdfRenderer {
+ constructor(sourceBytes: ArrayBuffer);
+ load(): Promise;
+ getPage(pageNumber: number): Promise;
+ runRender(page: PdfPageProxy, renderContext: PdfRenderContext): Promise;
+ destroy(): Promise;
+}
diff --git a/tools/shared/pdf.d.ts b/tools/shared/pdf.d.ts
new file mode 100644
index 0000000..18e3307
--- /dev/null
+++ b/tools/shared/pdf.d.ts
@@ -0,0 +1,5 @@
+export interface PdfFileLike extends Blob {
+ readonly name?: string;
+}
+
+export function isSupportedPdf(file: unknown): boolean;
diff --git a/tsconfig.build.json b/tsconfig.build.json
index ccfc657..072290c 100644
--- a/tsconfig.build.json
+++ b/tsconfig.build.json
@@ -9,6 +9,7 @@
"tools/image/to-text/controller.ts",
"tools/image/to-text/output.ts",
"tools/image/to-text/preview.ts",
+ "tools/shared/pdf-ocr.ts",
"tools/shared/*.d.ts"
]
}
diff --git a/tsconfig.json b/tsconfig.json
index 96294db..1f8c68d 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -16,6 +16,7 @@
},
"include": [
"tools/image/to-text/*.ts",
+ "tools/shared/pdf-ocr.ts",
"tools/shared/*.d.ts"
]
}