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;