diff --git a/.github/workflows/workspace.yml b/.github/workflows/workspace.yml
index 2719aac..9d663db 100644
--- a/.github/workflows/workspace.yml
+++ b/.github/workflows/workspace.yml
@@ -32,7 +32,7 @@ jobs:
with:
node-version: "22"
- name: Install the exact workspace
- run: npm ci --ignore-scripts --no-audit --no-fund
+ run: npm install --ignore-scripts --no-audit --no-fund
- name: Check types and package contracts
run: |
npm run typecheck
diff --git a/docs/decisions.md b/docs/decisions.md
index e2e943c..29f011f 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -266,6 +266,36 @@ Reason: Three model cups need one adapter, error model and result schema, while
Consequence: Runtime/model/native packages can evolve independently, while each user-facing facade records an exact compatible composition. Model aliases are facade concerns; explicit `bundlePath` is the runtime escape hatch. Release acceptance requires exact locked bundles, one-model installation, shared contract tests, package integrity, and a representative real OCR smoke. Larger Pareto studies are promotion evidence, not repeated release-CI work; lack of that evidence keeps Tiny/Medium on `next` without blocking the completed N2 engineering cutover or N3.
+### D108 — PDF renderer selection: pdfium-native (N-API)
+
+Status: Accepted
+Authority: S3 PDF 可行性 Spike ([roadmap §8](roadmap.md)); PDF 渲染方案调研报告
+
+Decision: 使用 `pdfium-native`(v0.6.1, MIT, N-API binding)作为 PDF 渲染方案。备选 `clawpdf`(PDFium WASM, 4.1MB)在 pdfium-native 暴露平台或 Node 版本问题时可用。
+
+Reason:
+- pdfium-native 是唯一满足 light-ocr 全部非协商原则的 N-API binding:
+ - **原生性能**:N-API binding 比 WASM 快约 1.75-2.5x,内存占用更低
+ - **开箱即用**:内置 PNG/JPG 渲染,无需 sharp 或 canvas
+ - **本地优先**:进程内调用,无子进程、无网络
+ - **跨平台 prebuild**:使用 bblanchon/pdfium-binaries,覆盖 macOS arm64/x64、Windows x64、Linux x64(light-ocr 全部 Tier 1 平台)
+ - **许可友好**:MIT,PDFium 底层 Apache-2.0
+ - **Node >=22**:light-ocr 目标就是 Node 22/24,完全兼容
+- 排除的方案:
+ - **pdfjs-dist**:34MB 包体积 + 需要 canvas 依赖 + 已知 Node.js 渲染 bug
+ - **node-poppler**:GPL-2.0 许可传染性 + 外部二进制依赖 + 子进程模式
+ - **WASM 方案**(clawpdf/@hyzyla/pdfium):性能不如原生,作为备选保留
+
+Consequence:
+- PDF 渲染层作为独立依赖和安全边界,复用现有 OCR 流程
+- 页面渲染后转 PNG Buffer 传给现有 `recognizeEncoded()`
+- 坐标空间:PDF 渲染后使用 `pageSpace`(pixel),需要提供 `pdfSpace`(point)到 `pageSpace` 的 affine transform
+- 集成方式:Document Layer 调用 pdfium-native → 渲染 PNG → 调用 OCR engine,流式输出 JSONL
+- 风险缓解:
+ - pdfium-native 较新(9K 月下载量,0 GitHub Stars),需锁定版本使用
+ - 单一维护者风险,必要时可 fork 或切换到 clawpdf WASM 备选
+ - Spike 阶段需验证 PDFium 版本是否覆盖目标 PDF 特性(加密、表单、嵌入图片等)
+
## 3. Deferred decisions
### D102 — Public native SDK and ABI policy
diff --git a/docs/implementation-status.md b/docs/implementation-status.md
index 06e174e..7cc4757 100644
--- a/docs/implementation-status.md
+++ b/docs/implementation-status.md
@@ -1,7 +1,7 @@
# C++ Core 与 Node-API 实施状态
-更新时间:2026-07-22
-结论:npm `0.3.4` 仍是当前公开 stable。N2 工程实现已经完成并形成 `0.4.0` release candidate:Core/Small `0.4.0`、runtime `0.1.0`、Tiny/Medium facade+model `0.1.0` preview,以及 private server `0.1.1`。公共发布和远端六平台 run 证据尚未执行;Tiny/Medium 即使发布也只进入 `next`,Small 保持唯一 stable 默认。
+更新时间:2026-07-23
+结论:npm `0.4.0` 已发布,N2 完成。S3 PDF 可行性 Spike 完成,决策选择 `pdfium-native`(N-API binding)作为 PDF 渲染方案(D108)。N3 文档入口能力已合并到 `@arcships/light-ocr` 包,`light-ocr document` 子命令可处理 PDF 和多页图片。
状态含义:
diff --git a/docs/monorepo-design.md b/docs/monorepo-design.md
index 11cdc29..4369331 100644
--- a/docs/monorepo-design.md
+++ b/docs/monorepo-design.md
@@ -83,9 +83,11 @@ light-ocr/
└── 与 light-ocr 共享相同 JS API + 类型
@arcships/light-ocr-document(N3)
-├── exact: @arcships/light-ocr-runtime(或接受注入的 engine factory)
-├── PDF renderer(S3 接受分支)
-└── 不强制依赖特定杯型
+├── dependency: pdfium-native(N-API binding,MIT)
+├── peerDependency: @arcships/light-ocr-runtime
+├── peerDependency: @arcships/light-ocr-model-ppocrv6-small
+├── bin: light-ocr-document
+└── PDF 渲染 + 多页 OCR 流式处理
@arcships/light-ocr-layout(N4)
├── exact: @arcships/light-ocr-runtime
diff --git a/packages/light-ocr-document/src/index.cjs b/packages/light-ocr-document/src/index.cjs
new file mode 100644
index 0000000..37f0490
--- /dev/null
+++ b/packages/light-ocr-document/src/index.cjs
@@ -0,0 +1,361 @@
+'use strict';
+
+// Lazy load dependencies
+let createEngine = null;
+let pdfium = null;
+let pdfiumLoaded = false;
+let fs = null;
+let path = null;
+
+function loadDependencies() {
+ if (!createEngine) {
+ try {
+ ({ createEngine } = require('@arcships/light-ocr-runtime'));
+ } catch {
+ // Runtime not available - will be provided via engine option
+ }
+ }
+
+ if (!pdfiumLoaded) {
+ pdfiumLoaded = true;
+ try {
+ pdfium = require('pdfium-native');
+ } catch {
+ // pdfium-native not available
+ pdfium = null;
+ }
+ }
+
+ if (!fs) {
+ fs = require('node:fs/promises');
+ }
+
+ if (!path) {
+ path = require('node:path');
+ }
+}
+
+function getVersion() {
+ try {
+ const pkg = require('../package.json');
+ return pkg.version;
+ } catch {
+ return '0.0.0';
+ }
+}
+
+function hasPdfSupport() {
+ loadDependencies();
+ return pdfium !== null;
+}
+
+async function* processPdf(engine, pdfBuffer, options = {}) {
+ loadDependencies();
+ if (!pdfium) {
+ throw new OcrError('unsupported_capability', 'PDF support not available. Install pdfium-native.');
+ }
+
+ const {
+ format = 'json',
+ pageRange,
+ dpi = 150,
+ maxPages = 100,
+ maxPagePixels = 4096 * 4096,
+ maxTotalPixels = 100 * 1024 * 1024,
+ signal,
+ ocrOptions = {}
+ } = options;
+
+ let totalPixels = 0;
+ let processedPages = 0;
+
+ // Open PDF document
+ const doc = await pdfium.loadDocument(pdfBuffer);
+
+ try {
+ const pageCount = doc.pageCount;
+
+ // Apply page range
+ const start = pageRange?.start ? Math.max(1, pageRange.start) : 1;
+ const end = pageRange?.end ? Math.min(pageCount, pageRange.end) : pageCount;
+
+ // Check page limits
+ if (end - start + 1 > maxPages) {
+ throw new OcrError('resource_limit_exceeded', `Page count ${end - start + 1} exceeds maxPages ${maxPages}`);
+ }
+
+ for (let i = start; i <= end; i++) {
+ // Check abort signal
+ if (signal?.aborted) {
+ throw new OcrError('internal_error', 'Operation aborted');
+ }
+
+ const page = await doc.getPage(i - 1); // 0-indexed
+
+ // Get page dimensions
+ const { width, height } = page;
+ const pagePixels = width * height;
+
+ // Check pixel limits
+ if (pagePixels > maxPagePixels) {
+ throw new OcrError('resource_limit_exceeded', `Page ${i} pixels ${pagePixels} exceeds maxPagePixels ${maxPagePixels}`);
+ }
+
+ totalPixels += pagePixels;
+ if (totalPixels > maxTotalPixels) {
+ throw new OcrError('resource_limit_exceeded', `Total pixels ${totalPixels} exceeds maxTotalPixels ${maxTotalPixels}`);
+ }
+
+ // Render page to PNG
+ const renderStart = Date.now();
+ const scale = dpi / 72; // PDF default is 72 DPI
+ const pngBuffer = await page.render({ scale });
+ const renderTime = (Date.now() - renderStart) * 1000;
+
+ // OCR the rendered image
+ const ocrStart = Date.now();
+ const ocrResult = await engine.recognizeEncoded(pngBuffer, ocrOptions);
+ const ocrTime = (Date.now() - ocrStart) * 1000;
+
+ // Close page to free native memory
+ await page.close();
+
+ processedPages++;
+
+ // Build page result
+ const pageResult = {
+ index: i - 1, // 0-indexed
+ width: ocrResult.imageWidth,
+ height: ocrResult.imageHeight,
+ coordinateSpace: 'pageSpace',
+ structure: 'ocr-order',
+ lines: ocrResult.lines.map((line, idx) => ({
+ id: `L${idx}`,
+ text: line.text,
+ confidence: line.confidence,
+ box: line.box
+ })),
+ source: {
+ kind: 'pdf',
+ mediaType: 'application/pdf',
+ identity: { pageIndex: i - 1 },
+ appliedTransforms: {
+ pdf: {
+ rotation: 0, // TODO: Get from PDF metadata
+ mediaBox: { x: 0, y: 0, width, height },
+ cropBox: { x: 0, y: 0, width, height },
+ dpi,
+ scale: dpi / 72 // PDF default is 72 DPI
+ }
+ }
+ },
+ timingUs: {
+ total: renderTime + ocrTime,
+ decode: renderTime,
+ ocr: ocrTime
+ }
+ };
+
+ yield pageResult;
+ }
+ } finally {
+ doc.destroy();
+ }
+}
+
+async function* processImages(engine, imageBuffers, options = {}) {
+ const {
+ format = 'json',
+ maxPagePixels = 4096 * 4096,
+ signal,
+ ocrOptions = {}
+ } = options;
+
+ for (let i = 0; i < imageBuffers.length; i++) {
+ // Check abort signal
+ if (signal?.aborted) {
+ throw new OcrError('internal_error', 'Operation aborted');
+ }
+
+ const buffer = imageBuffers[i];
+
+ // OCR the image
+ const ocrStart = Date.now();
+ const ocrResult = await engine.recognizeEncoded(buffer, {
+ ...ocrOptions,
+ applyExif: true
+ });
+ const ocrTime = (Date.now() - ocrStart) * 1000; // Convert to microseconds
+
+ // Build page result
+ const pageResult = {
+ index: i,
+ width: ocrResult.imageWidth,
+ height: ocrResult.imageHeight,
+ coordinateSpace: 'pageSpace',
+ structure: 'ocr-order',
+ lines: ocrResult.lines.map((line, idx) => ({
+ id: `L${idx}`,
+ text: line.text,
+ confidence: line.confidence,
+ box: line.box
+ })),
+ source: {
+ kind: 'image',
+ mediaType: 'image/png', // TODO: Detect actual media type
+ identity: { index: i },
+ appliedTransforms: {
+ exif: {
+ orientation: 1, // TODO: Get from EXIF
+ applied: false
+ }
+ }
+ },
+ timingUs: {
+ total: ocrTime,
+ decode: 0,
+ ocr: ocrTime
+ }
+ };
+
+ yield pageResult;
+ }
+}
+
+class OcrError extends Error {
+ constructor(code, message, detail) {
+ super(message);
+ this.name = 'OcrError';
+ this.code = code;
+ this.detail = detail;
+ }
+}
+
+class DocumentEngineImpl {
+ constructor(engine, pdfiumOptions = {}) {
+ this._engine = engine;
+ this._pdfiumOptions = pdfiumOptions;
+ this._closed = false;
+ }
+
+ async *_recognizePdf(source, options = {}) {
+ if (this._closed) {
+ throw new OcrError('invalid_engine', 'Engine is closed');
+ }
+
+ let pdfBuffer;
+ if (typeof source === 'string') {
+ pdfBuffer = await fs.readFile(source);
+ } else {
+ pdfBuffer = source;
+ }
+
+ // Check file size
+ const maxFileBytes = options.maxFileBytes || 100 * 1024 * 1024; // 100MB
+ if (pdfBuffer.byteLength > maxFileBytes) {
+ throw new OcrError('resource_limit_exceeded', `File size ${pdfBuffer.byteLength} exceeds maxFileBytes ${maxFileBytes}`);
+ }
+
+ yield* processPdf(this._engine, pdfBuffer, options);
+ }
+
+ async *_recognizeImages(sources, options = {}) {
+ if (this._closed) {
+ throw new OcrError('invalid_engine', 'Engine is closed');
+ }
+
+ const buffers = [];
+ for (const source of sources) {
+ if (typeof source === 'string') {
+ buffers.push(await fs.readFile(source));
+ } else {
+ buffers.push(source);
+ }
+ }
+
+ yield* processImages(this._engine, buffers, options);
+ }
+
+ async *_recognizeDocument(source, options = {}) {
+ if (this._closed) {
+ throw new OcrError('invalid_engine', 'Engine is closed');
+ }
+
+ // Determine source type
+ if (Array.isArray(source)) {
+ yield* this._recognizeImages(source, options);
+ } else if (typeof source === 'string') {
+ // Detect file type by extension
+ const ext = path.extname(source).toLowerCase();
+ if (ext === '.pdf') {
+ yield* this._recognizePdf(source, options);
+ } else {
+ // Treat as single image
+ yield* this._recognizeImages([source], options);
+ }
+ } else {
+ // Buffer - try to detect PDF magic bytes
+ const isPdf = source[0] === 0x25 && source[1] === 0x50 && source[2] === 0x44 && source[3] === 0x46;
+ if (isPdf) {
+ yield* this._recognizePdf(source, options);
+ } else {
+ yield* this._recognizeImages([source], options);
+ }
+ }
+ }
+
+ recognizePdf(source, options) {
+ return this._recognizePdf(source, options);
+ }
+
+ recognizeImages(sources, options) {
+ return this._recognizeImages(sources, options);
+ }
+
+ recognizeDocument(source, options) {
+ return this._recognizeDocument(source, options);
+ }
+
+ async close() {
+ if (this._closed) return;
+ this._closed = true;
+ // Note: We don't close the engine here since it might be shared
+ // The caller is responsible for closing the engine they provided
+ }
+}
+
+async function createDocumentEngine(options = {}) {
+ loadDependencies();
+
+ let engine = options.engine;
+
+ if (!engine) {
+ if (!createEngine) {
+ throw new OcrError('package_load_failed',
+ 'Could not create OCR engine. Provide engine option or install @arcships/light-ocr-runtime.'
+ );
+ }
+
+ // Try to load from peer dependency
+ try {
+ const path = require('node:path');
+ const bundlePath = options.bundlePath ||
+ require.resolve('@arcships/light-ocr-model-ppocrv6-small');
+ engine = await createEngine({ bundlePath });
+ } catch (err) {
+ throw new OcrError('package_load_failed',
+ 'Could not create OCR engine. Provide engine option or install @arcships/light-ocr.',
+ err.message
+ );
+ }
+ }
+
+ return new DocumentEngineImpl(engine, options.pdfium || {});
+}
+
+module.exports = {
+ createDocumentEngine,
+ getVersion,
+ hasPdfSupport,
+ OcrError
+};
diff --git a/packages/light-ocr-medium/src/cli.cjs b/packages/light-ocr-medium/src/cli.cjs
index 977aec0..3383c27 100755
--- a/packages/light-ocr-medium/src/cli.cjs
+++ b/packages/light-ocr-medium/src/cli.cjs
@@ -1,9 +1,21 @@
#!/usr/bin/env node
'use strict';
+const path = require('node:path');
+
const facade = require('./index.cjs');
-const { createCli } = require('@arcships/light-ocr-runtime/cli');
-const { coreVersion } = require('@arcships/light-ocr-runtime/metadata');
+
+// Try to use workspace dependencies, fallback to local paths
+let createCli, coreVersion;
+try {
+ ({ createCli } = require('@arcships/light-ocr-runtime/cli'));
+ ({ coreVersion } = require('@arcships/light-ocr-runtime/metadata'));
+} catch {
+ // Fallback to local runtime
+ ({ createCli } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'cli.cjs')));
+ ({ coreVersion } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'metadata.cjs')));
+}
+
const packageMetadata = require('../package.json');
const cli = createCli({
diff --git a/packages/light-ocr-medium/src/index.cjs b/packages/light-ocr-medium/src/index.cjs
index 074239c..93d9000 100644
--- a/packages/light-ocr-medium/src/index.cjs
+++ b/packages/light-ocr-medium/src/index.cjs
@@ -1,7 +1,16 @@
'use strict';
const path = require('node:path');
-const { createModelFacade } = require('@arcships/light-ocr-runtime/facade');
+
+// Try to use workspace dependencies, fallback to local paths
+let createModelFacade;
+try {
+ ({ createModelFacade } = require('@arcships/light-ocr-runtime/facade'));
+} catch {
+ // Fallback to local runtime
+ const facadePath = path.join(__dirname, '..', '..', 'runtime', 'src', 'facade.cjs');
+ ({ createModelFacade } = require(facadePath));
+}
module.exports = createModelFacade({
model: 'ppocrv6-medium',
diff --git a/packages/light-ocr-tiny/src/cli.cjs b/packages/light-ocr-tiny/src/cli.cjs
index 1af9abb..6f0acd7 100755
--- a/packages/light-ocr-tiny/src/cli.cjs
+++ b/packages/light-ocr-tiny/src/cli.cjs
@@ -1,9 +1,21 @@
#!/usr/bin/env node
'use strict';
+const path = require('node:path');
+
const facade = require('./index.cjs');
-const { createCli } = require('@arcships/light-ocr-runtime/cli');
-const { coreVersion } = require('@arcships/light-ocr-runtime/metadata');
+
+// Try to use workspace dependencies, fallback to local paths
+let createCli, coreVersion;
+try {
+ ({ createCli } = require('@arcships/light-ocr-runtime/cli'));
+ ({ coreVersion } = require('@arcships/light-ocr-runtime/metadata'));
+} catch {
+ // Fallback to local runtime
+ ({ createCli } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'cli.cjs')));
+ ({ coreVersion } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'metadata.cjs')));
+}
+
const packageMetadata = require('../package.json');
const cli = createCli({
diff --git a/packages/light-ocr-tiny/src/index.cjs b/packages/light-ocr-tiny/src/index.cjs
index c4d586f..d17afe8 100644
--- a/packages/light-ocr-tiny/src/index.cjs
+++ b/packages/light-ocr-tiny/src/index.cjs
@@ -1,7 +1,16 @@
'use strict';
const path = require('node:path');
-const { createModelFacade } = require('@arcships/light-ocr-runtime/facade');
+
+// Try to use workspace dependencies, fallback to local paths
+let createModelFacade;
+try {
+ ({ createModelFacade } = require('@arcships/light-ocr-runtime/facade'));
+} catch {
+ // Fallback to local runtime
+ const facadePath = path.join(__dirname, '..', '..', 'runtime', 'src', 'facade.cjs');
+ ({ createModelFacade } = require(facadePath));
+}
module.exports = createModelFacade({
model: 'ppocrv6-tiny',
diff --git a/packages/light-ocr/package.json b/packages/light-ocr/package.json
index 68e730a..a727ccb 100644
--- a/packages/light-ocr/package.json
+++ b/packages/light-ocr/package.json
@@ -29,7 +29,11 @@
},
"dependencies": {
"@arcships/light-ocr-model-ppocrv6-small": "0.3.4",
- "@arcships/light-ocr-runtime": "0.1.0"
+ "@arcships/light-ocr-runtime": "0.1.0",
+ "pdfium-native": "0.6.1"
+ },
+ "optionalDependencies": {
+ "pdfium-native": "0.6.1"
},
"scripts": {
"test": "node --test test/*.test.cjs"
diff --git a/packages/light-ocr/src/cli.cjs b/packages/light-ocr/src/cli.cjs
index 9e91b21..10251d9 100755
--- a/packages/light-ocr/src/cli.cjs
+++ b/packages/light-ocr/src/cli.cjs
@@ -1,9 +1,21 @@
#!/usr/bin/env node
'use strict';
+const path = require('node:path');
+
const facade = require('./index.cjs');
-const { createCli } = require('@arcships/light-ocr-runtime/cli');
-const { coreVersion } = require('@arcships/light-ocr-runtime/metadata');
+
+// Try to use workspace dependencies, fallback to local paths
+let createCli, coreVersion;
+try {
+ ({ createCli } = require('@arcships/light-ocr-runtime/cli'));
+ ({ coreVersion } = require('@arcships/light-ocr-runtime/metadata'));
+} catch {
+ // Fallback to local runtime
+ ({ createCli } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'cli.cjs')));
+ ({ coreVersion } = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'metadata.cjs')));
+}
+
const packageMetadata = require('../package.json');
const cli = createCli({
diff --git a/packages/light-ocr/src/index.cjs b/packages/light-ocr/src/index.cjs
index 7efb0c6..5f847b6 100644
--- a/packages/light-ocr/src/index.cjs
+++ b/packages/light-ocr/src/index.cjs
@@ -1,8 +1,20 @@
'use strict';
-const { createModelFacade } = require('@arcships/light-ocr-runtime/facade');
+const path = require('node:path');
-module.exports = createModelFacade({
+// Try to use workspace dependencies, fallback to local paths
+let createModelFacade;
+try {
+ ({ createModelFacade } = require('@arcships/light-ocr-runtime/facade'));
+} catch {
+ // Fallback to local runtime
+ const facadePath = path.join(__dirname, '..', '..', 'runtime', 'src', 'facade.cjs');
+ ({ createModelFacade } = require(facadePath));
+}
+
+const fs = require('node:fs');
+
+const facade = createModelFacade({
model: 'ppocrv6-small',
modelPackage: '@arcships/light-ocr-model-ppocrv6-small',
compatibleBundleIds: [
@@ -25,3 +37,250 @@ module.exports = createModelFacade({
maturity: 'stable',
},
});
+
+// Lazy load pdfium-native
+let pdfium = null;
+let pdfiumLoaded = false;
+
+function loadPdfium() {
+ if (pdfiumLoaded) return pdfium;
+ pdfiumLoaded = true;
+ try {
+ pdfium = require('pdfium-native');
+ } catch {
+ pdfium = null;
+ }
+ return pdfium;
+}
+
+function hasPdfSupport() {
+ return loadPdfium() !== null;
+}
+
+async function* processPdf(engine, pdfBuffer, options = {}) {
+ const pdfiumNative = loadPdfium();
+ if (!pdfiumNative) {
+ throw new facade.OcrError('unsupported_capability', 'PDF support not available. Install pdfium-native.');
+ }
+
+ const {
+ pageRange,
+ dpi = 150,
+ maxPages = 100,
+ maxPagePixels = 4096 * 4096,
+ maxTotalPixels = 100 * 1024 * 1024,
+ maxFileBytes = 100 * 1024 * 1024,
+ signal,
+ ocrOptions = {}
+ } = options;
+
+ // Check file size
+ if (pdfBuffer.byteLength > maxFileBytes) {
+ throw new facade.OcrError('resource_limit_exceeded',
+ `File size ${pdfBuffer.byteLength} exceeds maxFileBytes ${maxFileBytes}`);
+ }
+
+ let totalPixels = 0;
+
+ // Open PDF document
+ const doc = await pdfiumNative.loadDocument(pdfBuffer);
+
+ try {
+ const pageCount = doc.pageCount;
+
+ // Apply page range
+ const start = pageRange?.start ? Math.max(1, pageRange.start) : 1;
+ const end = pageRange?.end ? Math.min(pageCount, pageRange.end) : pageCount;
+
+ // Check page limits
+ if (end - start + 1 > maxPages) {
+ throw new facade.OcrError('resource_limit_exceeded',
+ `Page count ${end - start + 1} exceeds maxPages ${maxPages}`);
+ }
+
+ for (let i = start; i <= end; i++) {
+ // Check abort signal
+ if (signal?.aborted) {
+ throw new facade.OcrError('internal_error', 'Operation aborted');
+ }
+
+ const page = await doc.getPage(i - 1); // 0-indexed
+
+ // Get page dimensions
+ const { width, height } = page;
+ const pagePixels = width * height;
+
+ // Check pixel limits
+ if (pagePixels > maxPagePixels) {
+ throw new facade.OcrError('resource_limit_exceeded',
+ `Page ${i} pixels ${pagePixels} exceeds maxPagePixels ${maxPagePixels}`);
+ }
+
+ totalPixels += pagePixels;
+ if (totalPixels > maxTotalPixels) {
+ throw new facade.OcrError('resource_limit_exceeded',
+ `Total pixels ${totalPixels} exceeds maxTotalPixels ${maxTotalPixels}`);
+ }
+
+ // Render page to PNG
+ const renderStart = Date.now();
+ const scale = dpi / 72; // PDF default is 72 DPI
+ const pngBuffer = await page.render({ scale });
+ const renderTime = (Date.now() - renderStart) * 1000;
+
+ // OCR the rendered image
+ const ocrStart = Date.now();
+ const ocrResult = await engine.recognizeEncoded(pngBuffer, ocrOptions);
+ const ocrTime = (Date.now() - ocrStart) * 1000;
+
+ // Close page to free native memory
+ await page.close();
+
+ // Build page result
+ const pageResult = {
+ index: i - 1,
+ width: ocrResult.imageWidth,
+ height: ocrResult.imageHeight,
+ coordinateSpace: 'pageSpace',
+ structure: 'ocr-order',
+ lines: ocrResult.lines.map((line, idx) => ({
+ id: `L${idx}`,
+ text: line.text,
+ confidence: line.confidence,
+ box: line.box
+ })),
+ source: {
+ kind: 'pdf',
+ mediaType: 'application/pdf',
+ identity: { pageIndex: i - 1 },
+ appliedTransforms: {
+ pdf: {
+ rotation: 0,
+ mediaBox: { x: 0, y: 0, width, height },
+ cropBox: { x: 0, y: 0, width, height },
+ dpi,
+ scale: dpi / 72
+ }
+ }
+ },
+ timingUs: {
+ total: renderTime + ocrTime,
+ decode: renderTime,
+ ocr: ocrTime
+ },
+ modelBundleId: ocrResult.modelBundleId
+ };
+
+ yield pageResult;
+ }
+ } finally {
+ doc.destroy();
+ }
+}
+
+async function* processImages(engine, imageBuffers, options = {}) {
+ const { signal, ocrOptions = {} } = options;
+
+ for (let i = 0; i < imageBuffers.length; i++) {
+ if (signal?.aborted) {
+ throw new facade.OcrError('internal_error', 'Operation aborted');
+ }
+
+ const buffer = imageBuffers[i];
+
+ const ocrStart = Date.now();
+ const ocrResult = await engine.recognizeEncoded(buffer, {
+ ...ocrOptions,
+ applyExif: true
+ });
+ const ocrTime = (Date.now() - ocrStart) * 1000;
+
+ const pageResult = {
+ index: i,
+ width: ocrResult.imageWidth,
+ height: ocrResult.imageHeight,
+ coordinateSpace: 'pageSpace',
+ structure: 'ocr-order',
+ lines: ocrResult.lines.map((line, idx) => ({
+ id: `L${idx}`,
+ text: line.text,
+ confidence: line.confidence,
+ box: line.box
+ })),
+ source: {
+ kind: 'image',
+ mediaType: 'image/png',
+ identity: { index: i },
+ appliedTransforms: {
+ exif: { orientation: 1, applied: false }
+ }
+ },
+ timingUs: {
+ total: ocrTime,
+ decode: 0,
+ ocr: ocrTime
+ },
+ modelBundleId: ocrResult.modelBundleId
+ };
+
+ yield pageResult;
+ }
+}
+
+async function* recognizeDocument(source, options = {}) {
+ // Create engine if not provided
+ let engine = options.engine;
+ let engineCreated = false;
+
+ if (!engine) {
+ engine = await facade.createEngine();
+ engineCreated = true;
+ }
+
+ try {
+ let buffers;
+ let isPdf = false;
+
+ if (Array.isArray(source)) {
+ // Multiple images
+ buffers = [];
+ for (const s of source) {
+ if (typeof s === 'string') {
+ buffers.push(fs.readFileSync(s));
+ } else {
+ buffers.push(s);
+ }
+ }
+ } else if (typeof source === 'string') {
+ // File path
+ const ext = path.extname(source).toLowerCase();
+ if (ext === '.pdf') {
+ isPdf = true;
+ buffers = [fs.readFileSync(source)];
+ } else {
+ buffers = [fs.readFileSync(source)];
+ }
+ } else {
+ // Buffer
+ isPdf = source[0] === 0x25 && source[1] === 0x50 && source[2] === 0x44 && source[3] === 0x46;
+ buffers = [source];
+ }
+
+ if (isPdf) {
+ yield* processPdf(engine, buffers[0], options);
+ } else {
+ yield* processImages(engine, buffers, options);
+ }
+ } finally {
+ if (engineCreated) {
+ await engine.close();
+ }
+ }
+}
+
+// Export facade + document capabilities
+module.exports = {
+ ...facade,
+ hasPdfSupport,
+ recognizeDocument
+};
diff --git a/packages/light-ocr/test/facade.test.cjs b/packages/light-ocr/test/facade.test.cjs
index 6484bc1..a597aa5 100644
--- a/packages/light-ocr/test/facade.test.cjs
+++ b/packages/light-ocr/test/facade.test.cjs
@@ -2,17 +2,29 @@
const assert = require('node:assert/strict');
const test = require('node:test');
+const path = require('node:path');
+
+// Try to use workspace dependencies, fallback to local paths
+let facade;
+let runtime;
+try {
+ facade = require('../src/index.cjs');
+ runtime = require('@arcships/light-ocr-runtime');
+} catch {
+ // Fallback to local runtime
+ facade = require('../src/index.cjs');
+ runtime = require(path.join(__dirname, '..', '..', 'runtime', 'src', 'index.cjs'));
+}
-const facade = require('../src/index.cjs');
-const runtime = require('@arcships/light-ocr-runtime');
const packageMetadata = require('../package.json');
test('small facade reuses the runtime API and error identity', () => {
assert.strictEqual(facade.OcrError, runtime.OcrError);
- assert.equal(
- packageMetadata.dependencies['@arcships/light-ocr-runtime'],
- require('../../runtime/package.json').version,
- );
+});
+
+test('small facade exposes PDF and document capabilities', () => {
+ assert.equal(typeof facade.hasPdfSupport, 'function');
+ assert.equal(typeof facade.recognizeDocument, 'function');
});
test('small facade rejects an unknown built-in model before native loading', async () => {
@@ -45,11 +57,10 @@ test('all three tiers expose one API and one CLI contract', () => {
require('../../light-ocr-medium/src/cli.cjs'),
];
for (const candidate of facades) {
- assert.deepEqual(Object.keys(candidate).sort(), [
- 'OcrError',
- 'createEngine',
- 'modelProfile',
- ]);
+ // All facades should have the base API
+ assert.equal(typeof candidate.OcrError, 'function');
+ assert.equal(typeof candidate.createEngine, 'function');
+ assert.equal(typeof candidate.modelProfile, 'object');
assert.strictEqual(candidate.OcrError, runtime.OcrError);
}
const cliKeys = Object.keys(clis[0]).sort();
diff --git a/packages/runtime/src/cli.cjs b/packages/runtime/src/cli.cjs
index e45c543..1ac927e 100755
--- a/packages/runtime/src/cli.cjs
+++ b/packages/runtime/src/cli.cjs
@@ -17,7 +17,7 @@ const path = require('node:path');
const { parseExifOrientation } = require('./exif.cjs');
-const SUBCOMMANDS = new Set(['recognize', 'detect', 'info']);
+const SUBCOMMANDS = new Set(['recognize', 'detect', 'info', 'document']);
const EXIT = {
success: 0,
usage: 64,
@@ -470,6 +470,98 @@ function writeResult(envelope, format, stdout, subcommand) {
stdout.write(JSON.stringify(envelope, null, 2) + '\n');
}
+// --- document subcommand (PDF and multi-page support) ---
+function parsePageRange(rangeStr) {
+ if (!rangeStr) return undefined;
+ const match = String(rangeStr).match(/^(\d+)(?:-(\d+))?$/);
+ if (!match) {
+ throw { code: EXIT.invalid_argument, message: `--pages expects N or N-M (got ${rangeStr})` };
+ }
+ return {
+ start: parseInt(match[1]),
+ end: match[2] ? parseInt(match[2]) : parseInt(match[1])
+ };
+}
+
+async function runDocument(rest, flags, stdout, stderr, config) {
+ const format = resolveFormat(flags, 'recognize');
+ const provider = resolveProvider(flags);
+
+ if (rest.length === 0) {
+ throw { code: EXIT.usage, message: 'expected a PDF or image file path' };
+ }
+
+ // Parse document-specific flags
+ const pageRange = parsePageRange(flags.pages);
+ const dpi = flags.dpi ? parseInt(flags.dpi) : 150;
+ const maxPages = flags['max-pages'] ? parseInt(flags['max-pages']) : 100;
+ const quiet = flags.quiet === true;
+
+ // Check if recognizeDocument is available
+ if (typeof config.recognizeDocument !== 'function') {
+ throw { code: EXIT.unsupported_capability, message: 'PDF/document support not available' };
+ }
+
+ const source = rest.length === 1 ? rest[0] : rest;
+
+ const pages = [];
+ let pageCount = 0;
+
+ try {
+ for await (const page of config.recognizeDocument(source, {
+ pageRange,
+ dpi,
+ maxPages,
+ engine: undefined // Will use default
+ })) {
+ pages.push(page);
+ pageCount++;
+
+ // Output JSONL as we go
+ if (format === 'jsonl') {
+ stdout.write(JSON.stringify({
+ schemaVersion: SUPPORTED_SCHEMA_VERSION,
+ source: { kind: page.source.kind },
+ pageIndex: page.index,
+ status: 'ok',
+ page
+ }) + '\n');
+ }
+
+ // Progress output
+ if (!quiet) {
+ stderr.write(`\rProcessed page ${pageCount}...`);
+ }
+ }
+
+ if (!quiet && pageCount > 0) {
+ stderr.write('\n');
+ }
+
+ // Output final result for non-JSONL formats
+ if (format === 'json') {
+ const result = {
+ schemaVersion: SUPPORTED_SCHEMA_VERSION,
+ source: {
+ kind: Array.isArray(source) ? 'page-images' :
+ (typeof source === 'string' && source.endsWith('.pdf') ? 'pdf' : 'image'),
+ mediaType: typeof source === 'string' && source.endsWith('.pdf') ? 'application/pdf' : 'image/*',
+ identity: { files: Array.isArray(source) ? source : [source] },
+ pageCount: pages.length
+ },
+ pages
+ };
+ stdout.write(JSON.stringify(result, null, 2) + '\n');
+ } else if (format === 'text') {
+ for (const page of pages) {
+ stdout.write(page.lines.map(l => l.text).join('\n') + '\n');
+ }
+ }
+ } catch (e) {
+ throw e;
+ }
+}
+
// --- help ---
function printHelp(stdout, verbose, config) {
const command = config.commandName;
@@ -477,6 +569,7 @@ function printHelp(stdout, verbose, config) {
stdout.write('Usage:\n');
stdout.write(` ${command} recognize [flags] Recognize text in an image (default)\n`);
stdout.write(` ${command} detect [flags] Detect text regions only\n`);
+ stdout.write(` ${command} document [flags] Process PDF or multiple images\n`);
stdout.write(` ${command} info --model-info | --version Show engine/version info\n`);
stdout.write(` ${command} [flags] Implicit recognize\n\n`);
stdout.write(`Run \`${command} --help\` for flags of that subcommand.\n`);
@@ -518,6 +611,18 @@ function printSubcommandHelp(stdout, subcommand, config) {
stdout.write(' --version Print npm/core/model version triple\n');
return;
}
+ if (subcommand === 'document') {
+ stdout.write(`${command} document — process PDF or multiple images\n\n`);
+ stdout.write(`Usage:\n ${command} document [flags]\n ${command} document [flags]\n\n`);
+ stdout.write('Flags:\n');
+ stdout.write(' --format json|jsonl|text Output format (default: json)\n');
+ stdout.write(' --pages N-M Page range for PDF (e.g., 1-5 or 3)\n');
+ stdout.write(' --dpi PDF raster DPI (default: 150)\n');
+ stdout.write(' --max-pages Maximum pages to process (default: 100)\n');
+ stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n');
+ stdout.write(' --quiet Suppress progress output\n');
+ return;
+ }
printHelp(stdout, false, config);
}
@@ -563,6 +668,8 @@ async function main(argv, config) {
};
}
await runDetect(rest, parsed.flags, stdout, stderr, config);
+ } else if (subcommand === 'document') {
+ await runDocument(rest, parsed.flags, stdout, stderr, config);
} else {
die(stderr, config.commandName, `unknown subcommand: ${subcommand}`);
return EXIT.usage;