diff --git a/.agents/skills/local-ocr/SKILL.md b/.agents/skills/local-ocr/SKILL.md index f5263ed..b2d9a6d 100644 --- a/.agents/skills/local-ocr/SKILL.md +++ b/.agents/skills/local-ocr/SKILL.md @@ -75,19 +75,21 @@ Need to extract text from a PDF file or process multiple images as one document. ```bash # Full PDF with JSON output -light-ocr-document report.pdf --format json +light-ocr report.pdf --format json # Page range with streaming JSONL -light-ocr-document report.pdf --pages 1-10 --format jsonl +light-ocr report.pdf --pages 1-10 --format jsonl # Multiple images as one document -light-ocr-document scan1.png scan2.png --format text +light-ocr document scan1.png scan2.png --format text # Check if PDF support is available -light-ocr-document info +light-ocr document info ``` -The document command requires the explicit `@arcships/light-ocr-document@next` preview package. Its pinned `pdfium-native` dependency installs a verified platform prebuild; it is not part of the stable image package. +PDF support is part of `@arcships/light-ocr`. The matching PDFium binary is +inside the npm platform package; do not install another package and do not +download a renderer at runtime. ### System diagnostics @@ -108,7 +110,8 @@ Need text from an image or document? ├── Need text + coordinates? → recognize --format json ├── Only need where text is? → detect ├── Large image, unsure where text is? → detect first, then recognize --region -├── PDF or multiple images? → light-ocr-document --format json +├── PDF? → light-ocr --format json +├── Multiple images? → light-ocr document --format json ├── Need system/hardware info? → doctor --json └── Need engine info or version? → info --model-info / info --version ``` diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 27ace3f..0fbb525 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -6,7 +6,7 @@ on: version: description: Core and stable Small facade version required: true - default: 0.5.4 + default: 0.5.5 type: string publish_to_registry: description: Publish the tested candidate to npm under next @@ -79,6 +79,25 @@ jobs: - name: Bootstrap pinned native dependencies shell: bash run: python tools/bootstrap_dependencies.py --cache-dir .cache/dependencies + - name: Fetch the pinned PDF renderer for npm bundling (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + mkdir -p .cache/pdfium-package + cd .cache/pdfium-package + npm init --yes + npm install --ignore-scripts --no-audit --no-fund --package-lock=false \ + pdfium-native@0.6.1 + node node_modules/pdfium-native/scripts/install.mjs + - name: Fetch the pinned PDF renderer for npm bundling (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force .cache/pdfium-package | Out-Null + Set-Location .cache/pdfium-package + npm init --yes + npm install --ignore-scripts --no-audit --no-fund --package-lock=false pdfium-native@0.6.1 + node node_modules/pdfium-native/scripts/install.mjs - name: Assemble the locked WebGPU SDK if: matrix.runtime_flavor == 'webgpu' shell: bash @@ -151,6 +170,7 @@ jobs: --build-dir build-npm --configuration Release --metadata-dir "reports/npm/${{ matrix.id }}" + --pdfium-dir .cache/pdfium-package/node_modules/pdfium-native --output-dir "dist/native-input/${{ matrix.id }}" --runtime-flavor "${{ matrix.runtime_flavor }}" --webgpu-artifact-manifest @@ -255,7 +275,7 @@ jobs: npm install --offline --ignore-scripts --no-audit --no-fund --package-lock=false \ ../dist/release/arcships-light-ocr-model-ppocrv6-small-0.3.4.tgz \ ../dist/release/arcships-light-ocr-${{ matrix.native }}-${RELEASE_VERSION}.tgz \ - ../dist/release/arcships-light-ocr-runtime-0.1.4.tgz \ + ../dist/release/arcships-light-ocr-runtime-0.1.5.tgz \ ../dist/release/arcships-light-ocr-${RELEASE_VERSION}.tgz cp ../tools/npm/smoke.cjs . - name: Run stable Small OCR @@ -264,22 +284,10 @@ jobs: env: LIGHT_OCR_SMOKE_FIXTURE: ${{ github.workspace }}/corpus/fixtures/generated-hello-123 run: node smoke.cjs - - name: Install and smoke the explicit Document preview (Unix) - if: runner.os != 'Windows' + - name: Run built-in PDF OCR without scripts or network shell: bash working-directory: package-smoke - run: | - npm install --no-audit --no-fund --package-lock=false \ - ../dist/release/arcships-light-ocr-document-0.1.0.tgz - node ../tools/npm/document-smoke.cjs - - name: Install and smoke the explicit Document preview (Windows) - if: runner.os == 'Windows' - shell: pwsh - working-directory: package-smoke - run: | - npm install --no-audit --no-fund --package-lock=false ` - ../dist/release/arcships-light-ocr-document-0.1.0.tgz - node ../tools/npm/document-smoke.cjs + run: node ../tools/npm/document-smoke.cjs - name: Install and smoke preview tiers if: matrix.preview shell: bash @@ -290,8 +298,8 @@ jobs: npm install --offline --ignore-scripts --no-audit --no-fund --package-lock=false \ ../dist/release/arcships-light-ocr-model-ppocrv6-tiny-0.1.0.tgz \ ../dist/release/arcships-light-ocr-model-ppocrv6-medium-0.1.0.tgz \ - ../dist/release/arcships-light-ocr-tiny-0.1.3.tgz \ - ../dist/release/arcships-light-ocr-medium-0.1.3.tgz + ../dist/release/arcships-light-ocr-tiny-0.1.4.tgz \ + ../dist/release/arcships-light-ocr-medium-0.1.4.tgz node ../tools/npm/smoke-tier.cjs @arcships/light-ocr-tiny light-ocr-tiny \ ppocrv6-tiny-onnx-20260722.1 node ../tools/npm/smoke-tier.cjs @arcships/light-ocr-medium light-ocr-medium \ @@ -339,14 +347,14 @@ jobs: --tarball-dir dist/release --phase facade --tag next - - name: Verify the published Document preview with install scripts + - name: Verify the published compatibility document entry shell: bash run: | mkdir registry-document cd registry-document npm init --yes - npm install --no-audit --no-fund --package-lock=false \ - "@arcships/light-ocr-document@0.1.0" + npm install --ignore-scripts --no-audit --no-fund --package-lock=false \ + "@arcships/light-ocr-document@0.1.1" node ../tools/npm/document-smoke.cjs - name: Verify the published stable package offline shell: bash @@ -360,3 +368,4 @@ jobs: sudo unshare --net -- env \ LIGHT_OCR_SMOKE_FIXTURE="$GITHUB_WORKSPACE/corpus/fixtures/generated-hello-123" \ node smoke.cjs + sudo unshare --net -- node ../tools/npm/document-smoke.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e3001..97fa227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ This file records user-visible changes to `light-ocr`. Published artifact detail ## [Unreleased] +## [0.5.5] - 2026-07-27 + +### Changed + +- Built PDF and multi-page OCR directly into `@arcships/light-ocr`; the main + API now exports `recognizeDocument()` and `createDocumentEngine()`, and + `light-ocr report.pdf` works without a second package. +- Bundled the pinned PDFium addon and shared library into each of the six + platform npm packages. The stable closure remains installable with scripts + disabled and performs no install-time or runtime download. +- Converted `@arcships/light-ocr-document@0.1.1` into a compatibility entry + that forwards to the main package without depending on `pdfium-native`. +- Advanced Tiny and Medium facades to `0.1.4` so the independently versioned + preview entries exact-pin the same runtime `0.1.5` in combined installs. + ## [0.5.4] - 2026-07-27 ### Fixed diff --git a/README.md b/README.md index 7f97faf..ef3fc64 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ English | [简体中文](README.zh-CN.md) **Fast, offline OCR for Node.js and C++.** -Recognize text in JPEG, PNG, or raw image data directly on your machine. `light-ocr` returns lines in reading order with confidence scores and quadrilateral coordinates. For Node.js, the npm package includes PP-OCRv6 Small and prebuilt components for macOS, Linux, and Windows. +Recognize text in PDF, JPEG, PNG, or raw image data directly on your machine. `light-ocr` returns lines in reading order with confidence scores and quadrilateral coordinates. For Node.js, the npm package includes PP-OCRv6 Small, PDFium, and prebuilt components for macOS, Linux, and Windows. ## Quick start @@ -65,6 +65,9 @@ light-ocr image.png --format json # Just text light-ocr image.png --format text +# PDF pages, using the renderer already included by npm +light-ocr report.pdf --pages 1-10 --format text + # Detect text regions only (no recognition) light-ocr detect image.png @@ -78,29 +81,35 @@ light-ocr info --version light-ocr doctor --json ``` -Four subcommands: `recognize` (default), `detect` (boxes only), `info` (version diagnostics), and `doctor` (system diagnostics). Output wraps in a versioned `schemaVersion: 1` envelope with stable line/detection IDs. EXIF orientation is corrected automatically. See the [CLI design](docs/cli-design.md) and [npm README](bindings/node/README.md#cli) for full reference. +Image commands are `recognize` (default), `detect` (boxes only), `info` +(version diagnostics), and `doctor` (system diagnostics). A `.pdf` path routes +directly to document OCR; `document` handles explicit multi-source jobs. Output +uses a versioned `schemaVersion: 1` contract. EXIF orientation is corrected +automatically. See the [CLI design](docs/cli-design.md) and +[npm README](bindings/node/README.md#cli) for full reference. ### PDF and multi-page documents -PDF and multi-page OCR live in an explicit preview package so the stable default keeps its script-free, offline-installable dependency closure. Installing the preview runs `pdfium-native`'s verified prebuild installer; PDF processing itself stays local. +PDF and multi-page OCR are built into `@arcships/light-ocr`. The matching +PDFium binary is carried by the same platform npm package as the OCR runtime: +there is no postinstall script, runtime download, compiler, or separate package +to install. ```bash -npm install @arcships/light-ocr-document@next - # Single PDF with default 150 DPI -light-ocr-document report.pdf +light-ocr report.pdf # Page range with streaming JSONL output -light-ocr-document report.pdf --pages 1-10 --format jsonl +light-ocr report.pdf --pages 1-10 --format jsonl # Multiple images as one document -light-ocr-document scan1.png scan2.png scan3.png --format text +light-ocr document scan1.png scan2.png scan3.png --format text ``` Programmatic API: ```ts -import { recognizeDocument } from "@arcships/light-ocr-document"; +import { recognizeDocument } from "@arcships/light-ocr"; // Stream pages from a PDF for await (const page of recognizeDocument("report.pdf", { dpi: 200 })) { @@ -124,9 +133,9 @@ An [Agent Skill](.agents/skills/local-ocr/SKILL.md) is included for AI agents th ## What you get -- **Local processing.** Images and OCR results stay on your machine; the explicit Document preview also processes PDFs locally. -- **One package to install.** The model and matching prebuilt component are included with the npm package. -- **Opt-in document support.** The separate Document preview processes PDFs and multiple images with streaming output. +- **Local processing.** Images, PDFs, and OCR results stay on your machine. +- **One package to install.** The model, OCR runtime, and PDF renderer are included through the npm package's platform dependency. +- **No secondary downloads.** Installation and runtime need no postinstall fetch, compiler, model download, or PDF engine download. - **Useful output.** Every line includes recognized text, confidence, and its position in the original image. - **Hardware acceleration by default.** Auto tries Core ML first on macOS 15+ Apple Silicon, and WebGPU first on the Linux and Windows builds below. - **Application-friendly execution.** Recognition runs off the JavaScript main thread and supports queues, cancellation, and explicit cleanup. diff --git a/README.zh-CN.md b/README.zh-CN.md index 3c2c5cc..882119b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,7 @@ **面向 Node.js 与 C++ 的快速离线 OCR。** -直接在本机识别 JPEG、PNG 或像素数据,返回按阅读顺序排列的文字、置信度和四边形坐标。Node.js 用户安装的 npm 包内置 PP-OCRv6 Small 模型,并提供 macOS、Linux 和 Windows 的预编译组件。 +直接在本机识别 PDF、JPEG、PNG 或像素数据,返回按阅读顺序排列的文字、置信度和四边形坐标。Node.js 用户安装的 npm 包内置 PP-OCRv6 Small 模型、PDFium,以及 macOS、Linux 和 Windows 的预编译组件。 ## 快速开始 @@ -63,6 +63,9 @@ light-ocr image.png --format json # 只要文字 light-ocr image.png --format text +# PDF;渲染器已经包含在 npm 安装产物中 +light-ocr report.pdf --pages 1-10 --format text + # 只检测文字区域(不识别) light-ocr detect image.png @@ -76,29 +79,33 @@ light-ocr info --version light-ocr doctor --json ``` -四个子命令:`recognize`(默认)、`detect`(只检测框)、`info`(版本诊断)、`doctor`(系统诊断)。输出使用 `schemaVersion: 1` 版本化 envelope,带稳定 line/detection ID。EXIF 方向自动修正。完整参考见 [CLI 设计](docs/cli-design.md) 和 [npm README](bindings/node/README.md#cli)。 +图片命令包括 `recognize`(默认)、`detect`(只检测框)、`info`(版本诊断)和 +`doctor`(系统诊断)。传入 `.pdf` 路径会直接进入文档 OCR;显式 +`document` 命令用于多输入任务。输出遵循版本化的 `schemaVersion: 1` +契约,EXIF 方向自动修正。完整参考见 [CLI 设计](docs/cli-design.md) 和 +[npm README](bindings/node/README.md#cli)。 ### PDF 和多页文档 -PDF 和多页 OCR 位于显式安装的 Preview 包中,稳定默认包因此继续保持无安装脚本、可离线安装的依赖闭包。安装 Preview 时会运行 `pdfium-native` 的已校验 prebuild 安装器;实际 PDF 处理仍完全在本机完成。 +PDF 和多页 OCR 已直接内置于 `@arcships/light-ocr`。匹配当前平台的 +PDFium 二进制与 OCR 原生运行时位于同一个平台 npm 包中:没有 +postinstall 脚本、运行时下载、编译器要求,也不需要另装文档包。 ```bash -npm install @arcships/light-ocr-document@next - # 单个 PDF,默认 150 DPI -light-ocr-document report.pdf +light-ocr report.pdf # 指定页码范围,流式 JSONL 输出 -light-ocr-document report.pdf --pages 1-10 --format jsonl +light-ocr report.pdf --pages 1-10 --format jsonl # 多张图片作为一个文档 -light-ocr-document scan1.png scan2.png scan3.png --format text +light-ocr document scan1.png scan2.png scan3.png --format text ``` 编程 API: ```ts -import { recognizeDocument } from "@arcships/light-ocr-document"; +import { recognizeDocument } from "@arcships/light-ocr"; // 从 PDF 流式获取页面 for await (const page of recognizeDocument("report.pdf", { dpi: 200 })) { @@ -122,9 +129,9 @@ for await (const page of recognizeDocument([buf1, buf2, buf3])) { ## 主要能力 -- **本地处理。**图片和 OCR 结果始终留在本机;显式安装的 Document Preview 也只在本机处理 PDF。 -- **只需安装一个包。**模型和当前平台的预编译组件会随 npm 包一起安装。 -- **按需安装文档能力。**独立 Document Preview 支持 PDF、多张图片和流式输出。 +- **本地处理。**图片、PDF 和 OCR 结果始终留在本机。 +- **只需安装一个包。**模型、OCR 运行时和 PDF 渲染器会通过 npm 的当前平台依赖一起安装。 +- **没有二次下载。**安装和运行都不需要 postinstall 拉取、编译器、模型下载或 PDF 引擎下载。 - **直接得到可用结果。**每一行都包含识别文字、置信度和原图位置。 - **默认使用硬件加速。**Auto 在 macOS 15+ Apple Silicon 上优先使用 Core ML,在下表的 Linux 和 Windows 版本中优先使用 WebGPU。 - **适合应用内调用。**识别任务在 JavaScript 主线程之外执行,并支持队列、取消和明确释放资源。 diff --git a/VERSION b/VERSION index 7d85683..d1d899f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.4 +0.5.5 diff --git a/bindings/node/test/cli.test.cjs b/bindings/node/test/cli.test.cjs index d7a1591..652f7e3 100644 --- a/bindings/node/test/cli.test.cjs +++ b/bindings/node/test/cli.test.cjs @@ -108,7 +108,7 @@ test('info: --version is metadata-only and reports the Small tier', async () => assert.equal(code, EXIT.success); assert.equal(stderr, ''); const info = JSON.parse(stdout); - assert.equal(info.core, '0.5.4'); + assert.equal(info.core, '0.5.5'); assert.equal(info.tier, 'small'); assert.equal(info.maturity, 'stable'); assert.equal(info.model, 'ppocrv6-small-native-20260719.1'); @@ -513,7 +513,9 @@ test('doctor: omits hostname-derived stable identifiers', async () => { assert.equal(Object.hasOwn(result.system, 'hostname'), false); }); -test('main CLI keeps document work in the independent preview command', async () => { - const { stdout } = await runCli(['--help']); - assert.doesNotMatch(stdout, /document/); +test('main CLI routes PDF paths and the document subcommand to built-in document OCR', () => { + const { shouldUseDocumentCli } = require('../../../packages/light-ocr/src/cli.cjs'); + assert.equal(shouldUseDocumentCli(['report.pdf']), true); + assert.equal(shouldUseDocumentCli(['document', 'scan.png', 'scan-2.png']), true); + assert.equal(shouldUseDocumentCli(['image.png']), false); }); diff --git a/docs/cli-design.md b/docs/cli-design.md index 056f576..76f7c49 100644 --- a/docs/cli-design.md +++ b/docs/cli-design.md @@ -57,7 +57,8 @@ light-ocr --help [subcommand] # 分层 help - `light-ocr image.png` 隐式等价 `light-ocr recognize image.png`,保持首次使用直觉与向后兼容; - 每个 flag 只挂在自己相关的子命令下(见 §2.2),互斥约束在子命令内收敛; - `info` 子命令不接受 path/`--stdin`,不接受 `--format`;`--model-info` 与 `--version` 作为 `info` 的 flags; -- N3 文档入口另起 `light-ocr-document` bin([roadmap §3.1](roadmap.md)),与 N1 子命令并列,不并入 `light-ocr`。 +- N3 文档入口由 `light-ocr ` 和 `light-ocr document + ` 提供;旧 `light-ocr-document` bin 只作兼容转发(D110)。 > **与 [roadmap §5.2](roadmap.md) 的偏离说明**:roadmap 原文写「`--model-info` 和 `--version`」为顶导 flag,本设计改为 `info` 子命令以保持与 `recognize`/`detect` 的结构统一;语义等价。该偏离在 D106 决策中记录。 diff --git a/docs/decisions.md b/docs/decisions.md index 67b7d74..c34ef3e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -298,7 +298,7 @@ Consequence: ### D109 — Keep Document preview outside the stable image facade -Status: Accepted
+Status: Superseded by D110
Authority: N3 package topology, D010 offline installation contract, D108 renderer selection Decision: PDF and multi-page processing ships as the explicit @@ -328,6 +328,38 @@ Consequence: - PDF processing is local at runtime. Installation-time prebuild retrieval is disclosed rather than described as offline. +### D110 — Bundle PDFium in the stable platform packages + +Status: Accepted
+Authority: Product installation contract, D010 offline installation contract, +D108 renderer selection + +Decision: `@arcships/light-ocr` owns the PDF and multi-page API and CLI. +Each of the six existing platform npm packages carries the pinned +`pdfium-native@0.6.1` addon and its adjacent PDFium shared library. The stable +facade has no install script and does not depend on the upstream +`pdfium-native` npm package. `@arcships/light-ocr-document` remains only as a +compatibility forwarder. + +Reason: A separate Document install exposed an implementation detail and the +upstream renderer package performed a second network download during +postinstall. Users require PDF support to arrive in the npm installation +itself, with no customer-machine fetch during installation or use. Reusing the +already platform-specific native packages avoids shipping all six PDFium +binaries to every user. + +Consequence: + +- `npm install @arcships/light-ocr` installs the model, OCR runtime, and exactly + one matching PDF renderer through ordinary npm package bytes. +- The supported install path passes with `--ignore-scripts`; runtime PDF smoke + runs in a disabled network namespace. +- Release CI, not the customer machine, obtains the checksum-pinned upstream + prebuild, records its licenses/SBOM identity, and embeds the verified files + in the immutable platform tarball. +- Omitting npm optional dependencies also omits both OCR and PDF native + execution; the error directs users to reinstall without `--omit=optional`. + ## 3. Deferred decisions ### D102 — Public native SDK and ABI policy diff --git a/docs/implementation-status.md b/docs/implementation-status.md index ceda680..5850a16 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 更新时间:2026-07-27
-结论:npm `0.5.3` 仅形成 `next` 候选,没有完成 stable promotion。`0.5.4` 已完成修复与发布:stable `@arcships/light-ocr` 保持图片 OCR 与 `doctor`;PDF/多页能力按 roadmap 拆到显式 `@arcships/light-ocr-document` Preview。六个平台均通过 stable 禁网安装/OCR 与 Document 真实 PDF render + OCR,随后完成 npm publication、stable promotion 和 GitHub Latest Release。S3 renderer 继续采用 `pdfium-native`(D108),包拓扑由 D109 锁定。 +结论:npm `0.5.4` 完成了上一轮发布修复。`0.5.5` 正在把 PDF/多页能力直接并入 stable `@arcships/light-ocr`:六个平台 npm 原生包内置 PDFium addon 与共享库,主包保持无安装脚本,安装和运行均不再从客户机二次下载。`@arcships/light-ocr-document@0.1.1` 仅保留兼容入口。S3 renderer 继续采用 `pdfium-native`(D108),新包拓扑由 D110 锁定。 状态含义: diff --git a/docs/monorepo-design.md b/docs/monorepo-design.md index 4369331..8447331 100644 --- a/docs/monorepo-design.md +++ b/docs/monorepo-design.md @@ -83,11 +83,14 @@ light-ocr/ └── 与 light-ocr 共享相同 JS API + 类型 @arcships/light-ocr-document(N3) -├── 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 流式处理 +├── exact: @arcships/light-ocr +├── bin: light-ocr-document(兼容) +└── 转发主包 Document API;不含 renderer 或安装脚本 + +六个平台 native package +├── OCR Node-API addon + ONNX Runtime +├── PDFium Node-API addon + 相邻共享库 +└── npm tarball 自包含;无 postinstall 或运行时下载 @arcships/light-ocr-layout(N4) ├── exact: @arcships/light-ocr-runtime diff --git a/docs/releases/npm-0.5.5.md b/docs/releases/npm-0.5.5.md new file mode 100644 index 0000000..2a011ff --- /dev/null +++ b/docs/releases/npm-0.5.5.md @@ -0,0 +1,24 @@ +# npm 0.5.5 发布记录 + +状态:候选实现与发布门已配置,等待六平台远端构建、npm publication 和 +stable promotion。 + +## 目标 + +- `@arcships/light-ocr@0.5.5` 直接支持图片、PDF 和多页图片。 +- runtime `0.1.5` 精确选择六个平台 native `0.5.5`。 +- 每个平台 native 包内置 `pdfium-native@0.6.1` 的 addon 与 PDFium + 共享库;客户机不运行安装脚本、不访问 GitHub、不本地编译。 +- `@arcships/light-ocr-document@0.1.1` 仅作旧 import/命令兼容层。 +- Tiny/Medium facade `0.1.4` 同步精确依赖 runtime `0.1.5`,避免组合安装 + 解析旧 runtime。 + +## 必须通过的发布门 + +- [ ] 六个平台 native 包包含 `pdfium.node` 与匹配的共享库 +- [ ] 六个平台使用 `npm install --offline --ignore-scripts` 安装完整 stable closure +- [ ] 六个平台从主包运行真实 PDF render + OCR +- [ ] Linux registry 验证在禁用网络命名空间内同时通过图片与 PDF OCR +- [ ] npm registry integrity 验证 +- [ ] stable promotion +- [ ] GitHub `v0.5.5` Release diff --git a/docs/roadmap.md b/docs/roadmap.md index 23ae3b4..470cead 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -152,10 +152,10 @@ Roadmap 结束时应形成以下入口,而不是一个不断膨胀的单包 AP | --- | --- | --- | | `light_ocr` C++ Core | 仓库内适配器;未来原生 SDK 候选 | raw pixels、OCR stage、几何、资源限制、模型 session;N5 前不承诺稳定 ABI/安装面 | | `@arcships/light-ocr-runtime` | 内部共享与高级集成 | Node adapter、调度、encoded decode、native 加载、模型 bundle 解析,不默认携带模型 | -| `@arcships/light-ocr` | 大多数 Node.js 用户 | small 默认模型、Node API;唯一拥有 `light-ocr` bin,保持开箱即用 | +| `@arcships/light-ocr` | 大多数 Node.js 用户 | small 默认模型、图片/PDF/多页 Node API;唯一拥有 `light-ocr` bin,保持开箱即用 | | `@arcships/light-ocr-tiny` | Edge、体积/速度优先用户 | 与主包相同 API,默认 tiny;如提供 bin,只能命名为 `light-ocr-tiny` | | `@arcships/light-ocr-medium` | 精度优先和服务端用户 | 与主包相同 API,默认 medium;如提供 bin,只能命名为 `light-ocr-medium` | -| `@arcships/light-ocr-document` | PDF、批量文档和 RAG 流程 | Document API、分页、格式转换、Layout 编排;Preview 期拥有独立 `light-ocr-document` bin | +| `@arcships/light-ocr-document` | 旧版兼容调用方 | 转发主包的 Document API 和旧 `light-ocr-document` bin;不再携带 renderer | | `@arcships/light-ocr-layout`(逻辑角色) | 显式选择 Layout 的用户 | Layout analyzer、公共 label 映射和 capability resolver;依赖 runtime 与一个精确 Layout model,不拥有 CLI bin | | `@arcships/light-ocr-model-`(逻辑角色) | 由 Layout capability 间接安装 | 纯数据 Layout bundle、manifest、license 和模型 identity;不包含编排代码 | | Agent Skill / Plugin | Codex 与其他可调用本地命令的 Agent | 选择正确命令、约束输出、处理错误,不实现 OCR | @@ -173,8 +173,8 @@ Roadmap 结束时应形成以下入口,而不是一个不断膨胀的单包 AP - 多个杯型可以同时安装,不得争用同一个 bin 名; - `light-ocr` 始终代表 small 默认入口,Skill 优先使用该命令; -- N1 的图片文件、stdin 和 EXIF 由 `light-ocr` image CLI 负责;N3 的 PDF、多页、Document JSON/Markdown 由 `light-ocr-document` 负责; -- Document Node API 接受调用方注入兼容的 engine factory,因此不强制 small;`light-ocr-document` CLI Preview 可以精确依赖 small 作为开箱即用默认值; +- N1 图片与 N3 PDF/多页均由 `light-ocr` 负责;`.pdf` 路径自动进入文档流程,`document` 子命令处理多输入; +- Document Node API 继续允许注入兼容 engine;stable 主包默认使用内置 small,旧 Document 包只作兼容转发; - Layout model 不成为 Document 包的默认依赖。`@arcships/light-ocr-layout` 精确锁定兼容 runtime 和 Layout model;Document 只接受注入的 versioned Layout analyzer interface,避免反向依赖和循环依赖; - 上述 Layout 包名是待 D109 接受的逻辑角色,不在 Roadmap 中提前冻结最终 registry 名称。 - `@arcships/light-ocr-server` 依赖 `@arcships/light-ocr`(默认 small),精确锁定兼容版本;server 的 HTTP API 与 CLI 共享相同的 OcrError 语义和 exit code 映射,但不要求 CLI 先达到 stable——server 可以独立 preview 发布。 diff --git a/package-lock.json b/package-lock.json index 09ba2dd..0351cca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,9 +18,9 @@ "link": true }, "node_modules/@arcships/light-ocr-darwin-arm64": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@arcships/light-ocr-darwin-arm64/-/light-ocr-darwin-arm64-0.3.4.tgz", - "integrity": "sha512-5gu/4LJTGY8D16fjWHBVXLs6Xr2rEXiy5EYNOIRlQj0zdCVIs/fkmFxti1Y3oYI3kNHOduWcDkN94YeF09xK/A==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-darwin-arm64/-/light-ocr-darwin-arm64-0.5.4.tgz", + "integrity": "sha512-3padwfqDk5mjehhJsdN/v9OgIN0adpETcQ/gnvTHbwEmEnVX1OEMVmdHV4AzAQL3eBN6zj/yGW/xjYsPcGATSQ==", "cpu": [ "arm64" ], @@ -34,9 +34,9 @@ } }, "node_modules/@arcships/light-ocr-darwin-x64": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@arcships/light-ocr-darwin-x64/-/light-ocr-darwin-x64-0.3.4.tgz", - "integrity": "sha512-YqNGinCzlKzCnCMRIO4KG5bF4XBPPY6jTkz8M7ypm2D3lahpZi5uQbNtnj+EZS7JvgTni0MWLy++4eff69a2Sg==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-darwin-x64/-/light-ocr-darwin-x64-0.5.4.tgz", + "integrity": "sha512-yHB23Ca8ESyo4I1SZaqRFy3cAEcUPfqOYKVr02Wr30VYEcgtA0a8/wpgzv8+tANoCIWFdh/7yzKVlHTnTs+uJA==", "cpu": [ "x64" ], @@ -54,9 +54,9 @@ "link": true }, "node_modules/@arcships/light-ocr-linux-arm64-gnu": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@arcships/light-ocr-linux-arm64-gnu/-/light-ocr-linux-arm64-gnu-0.3.4.tgz", - "integrity": "sha512-O5DA/RcobHsgEm4jXwIisBJkiIFArW//wHecGU58px/m7x/utrcx6WnuuigBl81nfw2+jQhRmkjfwMJMifLJJw==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-linux-arm64-gnu/-/light-ocr-linux-arm64-gnu-0.5.4.tgz", + "integrity": "sha512-FYQMstVHrwppmbvdJRWeDhjCPD97gsxOj8Vkm9hpbMLYSZ14gV9ftkm9qyvUY71+74Mu/yS5yubc+l+GarKQsw==", "cpu": [ "arm64" ], @@ -70,9 +70,9 @@ } }, "node_modules/@arcships/light-ocr-linux-x64-gnu": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@arcships/light-ocr-linux-x64-gnu/-/light-ocr-linux-x64-gnu-0.3.4.tgz", - "integrity": "sha512-hYH3ujFKU+Un7XVtafPuPDlvQPKFCcXUO9Q7PKmP612IlQ3u5v9uuP+9gzNPcZisRTpYgHhIzIi4JNwt2PbCxQ==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-linux-x64-gnu/-/light-ocr-linux-x64-gnu-0.5.4.tgz", + "integrity": "sha512-0RXKwM453lJDoXXBVB19g9Dolcj3VNfZO+WzHvHYbAMgMqz18OvlJ9f6UfvB6ntFFSZab6vxDNHqAt6GpJDYpA==", "cpu": [ "x64" ], @@ -116,9 +116,9 @@ "link": true }, "node_modules/@arcships/light-ocr-win32-arm64": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-arm64/-/light-ocr-win32-arm64-0.3.4.tgz", - "integrity": "sha512-hROMzPkCRIaEvMPkuLKUPKZE18EZXuOfyt0fgZMJtkaObC+/pvI98z8UsK7ycMSMNjjSRcvDSZMWxrqO88heNg==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-arm64/-/light-ocr-win32-arm64-0.5.4.tgz", + "integrity": "sha512-4x8c/bBQauRo0zDIBlLC2AtoYvAT5Cl0VYvKAUW17sLTmXQSuoWNIYUg+OQbSRsTiff01XPuCsmsT9du8GauLw==", "cpu": [ "arm64" ], @@ -132,9 +132,9 @@ } }, "node_modules/@arcships/light-ocr-win32-x64": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-x64/-/light-ocr-win32-x64-0.3.4.tgz", - "integrity": "sha512-O9shzQsEhhZ38gZJwjZLVkEjoaDKjN/6tH5NPf4d9I6DOtKAbCXP7um2QvS+sWADhQdDSQv+2XJo/Y1eLQvYwg==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-x64/-/light-ocr-win32-x64-0.5.4.tgz", + "integrity": "sha512-CD15sTr2WUITPIdjKmXzn+/wOKuVztaFulbLG7zIMCRK6T5gcFFdhcSoUxGkzHrS3MR1/+P0L18AYmyI4HjDVA==", "cpu": [ "x64" ], @@ -776,15 +776,6 @@ "node": ">= 0.6" } }, - "node_modules/node-addon-api": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", - "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -818,21 +809,6 @@ "wrappy": "1" } }, - "node_modules/p-limit": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", - "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.2.1" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -852,31 +828,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/pdfium-native": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/pdfium-native/-/pdfium-native-0.6.1.tgz", - "integrity": "sha512-ORaGta00LpUw8rbPJSivNPcYPOZBsbALkSpOetuD4KSvt7hcQJQM0mZNJZ9dyO2omN424qcvPhmxkId2AKgeQg==", - "cpu": [ - "x64", - "arm64", - "arm", - "ppc64" - ], - "hasInstallScript": true, - "license": "MIT", - "os": [ - "darwin", - "linux", - "win32" - ], - "dependencies": { - "node-addon-api": "^8.0.0", - "p-limit": "^7.3.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1236,25 +1187,13 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "packages/light-ocr": { "name": "@arcships/light-ocr", - "version": "0.5.4", + "version": "0.5.5", "license": "Apache-2.0", "dependencies": { "@arcships/light-ocr-model-ppocrv6-small": "0.3.4", - "@arcships/light-ocr-runtime": "0.1.4" + "@arcships/light-ocr-runtime": "0.1.5" }, "bin": { "light-ocr": "src/cli.cjs" @@ -1265,11 +1204,10 @@ }, "packages/light-ocr-document": { "name": "@arcships/light-ocr-document", - "version": "0.1.0", + "version": "0.1.1", "license": "Apache-2.0", "dependencies": { - "@arcships/light-ocr": "0.5.4", - "pdfium-native": "0.6.1" + "@arcships/light-ocr": "0.5.5" }, "bin": { "light-ocr-document": "src/cli.cjs" @@ -1280,11 +1218,11 @@ }, "packages/light-ocr-medium": { "name": "@arcships/light-ocr-medium", - "version": "0.1.3", + "version": "0.1.4", "license": "Apache-2.0", "dependencies": { "@arcships/light-ocr-model-ppocrv6-medium": "0.1.0", - "@arcships/light-ocr-runtime": "0.1.4" + "@arcships/light-ocr-runtime": "0.1.5" }, "bin": { "light-ocr-medium": "src/cli.cjs" @@ -1437,11 +1375,11 @@ }, "packages/light-ocr-tiny": { "name": "@arcships/light-ocr-tiny", - "version": "0.1.3", + "version": "0.1.4", "license": "Apache-2.0", "dependencies": { "@arcships/light-ocr-model-ppocrv6-tiny": "0.1.0", - "@arcships/light-ocr-runtime": "0.1.4" + "@arcships/light-ocr-runtime": "0.1.5" }, "bin": { "light-ocr-tiny": "src/cli.cjs" @@ -1462,18 +1400,18 @@ }, "packages/runtime": { "name": "@arcships/light-ocr-runtime", - "version": "0.1.4", + "version": "0.1.5", "license": "Apache-2.0", "engines": { "node": "^22.0.0 || ^24.0.0" }, "optionalDependencies": { - "@arcships/light-ocr-darwin-arm64": "0.3.4", - "@arcships/light-ocr-darwin-x64": "0.3.4", - "@arcships/light-ocr-linux-arm64-gnu": "0.3.4", - "@arcships/light-ocr-linux-x64-gnu": "0.3.4", - "@arcships/light-ocr-win32-arm64": "0.3.4", - "@arcships/light-ocr-win32-x64": "0.3.4" + "@arcships/light-ocr-darwin-arm64": "0.5.5", + "@arcships/light-ocr-darwin-x64": "0.5.5", + "@arcships/light-ocr-linux-arm64-gnu": "0.5.5", + "@arcships/light-ocr-linux-x64-gnu": "0.5.5", + "@arcships/light-ocr-win32-arm64": "0.5.5", + "@arcships/light-ocr-win32-x64": "0.5.5" } } } diff --git a/packages/light-ocr-document/README.md b/packages/light-ocr-document/README.md index f2969a6..569ab68 100644 --- a/packages/light-ocr-document/README.md +++ b/packages/light-ocr-document/README.md @@ -1,22 +1,19 @@ # @arcships/light-ocr-document -Preview PDF and multi-page image OCR for Node.js 22 and 24. +Compatibility entry for the PDF and multi-page API now built into +`@arcships/light-ocr`. ```bash -npm install @arcships/light-ocr-document@next +npm install @arcships/light-ocr light-ocr-document report.pdf --pages 1-10 --format jsonl light-ocr-document scan-1.png scan-2.jpg --format text ``` -This package is intentionally separate from stable `@arcships/light-ocr`. -Installation runs the pinned `pdfium-native` installer, which downloads a -checksum-verified prebuilt binary for supported platforms and may require a -compiler if no prebuild is available. Processing does not upload documents or -use a network service. - -On Windows, install from PowerShell or Command Prompt. The pinned renderer's -installer invokes `tar` with native Windows paths; Git Bash may resolve its own -GNU tar first and misinterpret the drive-letter colon. +New applications should import from `@arcships/light-ocr`. This package keeps +the former import and `light-ocr-document` command working, but has no PDF +renderer dependency or installer of its own. The renderer is already included +in the platform package selected by npm, with no postinstall or runtime +download. ```js const { recognizeDocument } = require('@arcships/light-ocr-document'); diff --git a/packages/light-ocr-document/package.json b/packages/light-ocr-document/package.json index 6568777..57345c1 100644 --- a/packages/light-ocr-document/package.json +++ b/packages/light-ocr-document/package.json @@ -1,8 +1,8 @@ { "name": "@arcships/light-ocr-document", - "version": "0.1.0", + "version": "0.1.1", "private": true, - "description": "Document entry for light-ocr — PDF and multi-page image processing", + "description": "Compatibility entry for the document API built into light-ocr", "license": "Apache-2.0", "repository": { "type": "git", @@ -33,8 +33,7 @@ "node": "^22.0.0 || ^24.0.0" }, "dependencies": { - "@arcships/light-ocr": "0.5.4", - "pdfium-native": "0.6.1" + "@arcships/light-ocr": "0.5.5" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr-document/src/cli.cjs b/packages/light-ocr-document/src/cli.cjs index 6dcbf08..228049e 100755 --- a/packages/light-ocr-document/src/cli.cjs +++ b/packages/light-ocr-document/src/cli.cjs @@ -1,212 +1,10 @@ #!/usr/bin/env node 'use strict'; -const { - createDocumentEngine, - getVersion, - hasPdfSupport, - OcrError, -} = require('./index.cjs'); - -const USAGE = `light-ocr-document - local PDF and multi-page image OCR (preview) - -Usage: - light-ocr-document [recognize] [options] - light-ocr-document info - light-ocr-document help - -Options: - --format Output format (default: json) - --pages Inclusive PDF page range - --dpi <36-600> PDF raster DPI (default: 150) - --max-pages Maximum pages (default: 100) - --max-page-pixels Maximum rendered pixels per page - --max-total-pixels Maximum rendered pixels for the request - --max-file-bytes Maximum bytes per input - --provider - --quiet Suppress progress output - -h, --help Show help - -v, --version Show version`; - -const EXIT_CODES = Object.freeze({ - invalid_argument: 65, - invalid_image: 66, - unsupported_capability: 67, - invalid_model_bundle: 68, - resource_limit_exceeded: 69, - package_load_failed: 70, - inference_failed: 71, - internal_error: 72, -}); - -function argumentError(message) { - return new OcrError('invalid_argument', message); -} - -function takeValue(args, index, flag) { - const value = args[index + 1]; - if (value === undefined || value.startsWith('-')) { - throw argumentError(`${flag} requires a value`); - } - return value; -} - -function parseInteger(value, flag) { - if (!/^[1-9]\d*$/.test(value)) { - throw argumentError(`${flag} must be a positive integer`); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) { - throw argumentError(`${flag} is too large`); - } - return parsed; -} - -function parseArgs(argv) { - const args = [...argv]; - if (args[0] === 'recognize') args.shift(); - const sources = []; - const documentOptions = {}; - let format = 'json'; - let provider = 'auto'; - let quiet = false; - - for (let index = 0; index < args.length; index++) { - const arg = args[index]; - if (arg === '--quiet') { - quiet = true; - } else if (arg === '--format') { - format = takeValue(args, index, arg); - index++; - if (!['json', 'jsonl', 'text'].includes(format)) { - throw argumentError('--format must be json, jsonl, or text'); - } - } else if (arg === '--pages') { - const value = takeValue(args, index, arg); - index++; - const match = /^([1-9]\d*)(?:-([1-9]\d*))?$/.exec(value); - if (!match) throw argumentError('--pages must be N or N-M'); - const start = parseInteger(match[1], '--pages'); - const end = parseInteger(match[2] ?? match[1], '--pages'); - if (end < start) throw argumentError('--pages end must not precede its start'); - documentOptions.pageRange = { start, end }; - } else if ( - [ - '--dpi', - '--max-pages', - '--max-page-pixels', - '--max-total-pixels', - '--max-file-bytes', - ].includes(arg) - ) { - const value = parseInteger(takeValue(args, index, arg), arg); - index++; - const keys = { - '--dpi': 'dpi', - '--max-pages': 'maxPages', - '--max-page-pixels': 'maxPagePixels', - '--max-total-pixels': 'maxTotalPixels', - '--max-file-bytes': 'maxFileBytes', - }; - documentOptions[keys[arg]] = value; - } else if (arg === '--provider') { - provider = takeValue(args, index, arg); - index++; - if (!['auto', 'cpu', 'apple', 'webgpu'].includes(provider)) { - throw argumentError('--provider must be auto, cpu, apple, or webgpu'); - } - } else if (arg.startsWith('-')) { - throw argumentError(`unknown option: ${arg}`); - } else { - sources.push(arg); - } - } - if (sources.length === 0) throw argumentError('at least one source is required'); - return { documentOptions, format, provider, quiet, sources }; -} - -function writeLine(stream, value = '') { - stream.write(`${value}\n`); -} - -async function main( - argv = process.argv.slice(2), - io = { stdout: process.stdout, stderr: process.stderr }, -) { - if ( - argv.length === 0 - || argv[0] === 'help' - || argv.includes('--help') - || argv.includes('-h') - ) { - writeLine(io.stdout, USAGE); - return 0; - } - if (argv.includes('--version') || argv.includes('-v')) { - writeLine(io.stdout, getVersion()); - return 0; - } - if (argv[0] === 'info') { - writeLine(io.stdout, JSON.stringify({ - name: '@arcships/light-ocr-document', - version: getVersion(), - pdfSupport: hasPdfSupport(), - })); - return 0; - } - - let parsed; - let engine; - try { - parsed = parseArgs(argv); - engine = await createDocumentEngine({ - engineOptions: { execution: { provider: parsed.provider } }, - }); - const source = parsed.sources.length === 1 ? parsed.sources[0] : parsed.sources; - const pages = parsed.format === 'json' ? [] : undefined; - let count = 0; - for await (const page of engine.recognizeDocument(source, parsed.documentOptions)) { - count++; - if (pages) pages.push(page); - if (parsed.format === 'jsonl') writeLine(io.stdout, JSON.stringify(page)); - if (parsed.format === 'text') { - if (count > 1) writeLine(io.stdout); - for (const line of page.lines) writeLine(io.stdout, line.text); - } - if (!parsed.quiet) io.stderr.write(`\rProcessed page ${count}`); - } - if (!parsed.quiet) writeLine(io.stderr); - if (pages) { - writeLine(io.stdout, JSON.stringify({ - schemaVersion: 1, - source: { - kind: pages[0]?.source.kind === 'pdf' ? 'pdf' : 'page-images', - mediaType: pages[0]?.source.mediaType ?? 'application/octet-stream', - identity: {}, - pageCount: pages.length, - }, - pages, - }, null, 2)); - } - return 0; - } catch (error) { - if (error?.name === 'AbortError') { - writeLine(io.stderr, 'The operation was aborted'); - return 72; - } - if (error instanceof OcrError) { - writeLine(io.stderr, `${error.code}: ${error.message}`); - return EXIT_CODES[error.code] ?? 72; - } - writeLine(io.stderr, `internal_error: ${error?.message ?? String(error)}`); - return 72; - } finally { - await engine?.close(); - } -} +const cli = require('@arcships/light-ocr/document-cli'); if (require.main === module) { - main().then( + cli.main(process.argv.slice(2)).then( (code) => { process.exitCode = code; }, @@ -217,4 +15,4 @@ if (require.main === module) { ); } -module.exports = { main, parseArgs, USAGE }; +module.exports = cli; diff --git a/packages/light-ocr-document/src/index.cjs b/packages/light-ocr-document/src/index.cjs index 8bfc9bd..3334caf 100644 --- a/packages/light-ocr-document/src/index.cjs +++ b/packages/light-ocr-document/src/index.cjs @@ -1,415 +1,11 @@ 'use strict'; -const fs = require('node:fs/promises'); -const path = require('node:path'); - -const { - createEngine, - OcrError, -} = require('@arcships/light-ocr'); - -const DEFAULTS = Object.freeze({ - dpi: 150, - maxPages: 100, - maxPagePixels: 4096 * 4096, - maxTotalPixels: 100 * 1024 * 1024, - maxFileBytes: 100 * 1024 * 1024, +const lightOcr = require('@arcships/light-ocr'); + +module.exports = Object.freeze({ + createDocumentEngine: lightOcr.createDocumentEngine, + getVersion: lightOcr.getVersion, + hasPdfSupport: lightOcr.hasPdfSupport, + recognizeDocument: lightOcr.recognizeDocument, + OcrError: lightOcr.OcrError, }); - -let pdfium; -let pdfiumLoaded = false; - -function loadPdfium() { - if (!pdfiumLoaded) { - pdfiumLoaded = true; - try { - pdfium = require('pdfium-native'); - } catch { - pdfium = undefined; - } - } - return pdfium; -} - -function hasPdfSupport() { - return loadPdfium() !== undefined; -} - -function getVersion() { - return require('../package.json').version; -} - -function invalidArgument(message) { - return new OcrError('invalid_argument', message); -} - -function positiveInteger(value, name, fallback, maximum = Number.MAX_SAFE_INTEGER) { - if (value === undefined) return fallback; - if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { - throw invalidArgument(`${name} must be an integer between 1 and ${maximum}`); - } - return value; -} - -function normalizeOptions(options) { - if (options === undefined) options = {}; - if (options === null || typeof options !== 'object' || Array.isArray(options)) { - throw invalidArgument('document options must be an object'); - } - const normalized = { - ...options, - dpi: positiveInteger(options.dpi, 'dpi', DEFAULTS.dpi, 600), - maxPages: positiveInteger(options.maxPages, 'maxPages', DEFAULTS.maxPages, 10000), - maxPagePixels: positiveInteger( - options.maxPagePixels, - 'maxPagePixels', - DEFAULTS.maxPagePixels, - ), - maxTotalPixels: positiveInteger( - options.maxTotalPixels, - 'maxTotalPixels', - DEFAULTS.maxTotalPixels, - ), - maxFileBytes: positiveInteger( - options.maxFileBytes, - 'maxFileBytes', - DEFAULTS.maxFileBytes, - ), - }; - if (normalized.dpi < 36) { - throw invalidArgument('dpi must be an integer between 36 and 600'); - } - if (options.pageRange !== undefined) { - const range = options.pageRange; - if ( - range === null - || typeof range !== 'object' - || Array.isArray(range) - || !Number.isSafeInteger(range.start) - || !Number.isSafeInteger(range.end) - || range.start < 1 - || range.end < range.start - ) { - throw invalidArgument('pageRange must contain 1-based integers with start <= end'); - } - } - return normalized; -} - -function throwIfAborted(signal) { - if (signal?.aborted) { - throw signal.reason === undefined - ? new DOMException('The operation was aborted', 'AbortError') - : signal.reason; - } -} - -function isBytes(value) { - return value instanceof Uint8Array; -} - -function isPdf(value) { - return value?.length >= 4 - && value[0] === 0x25 - && value[1] === 0x50 - && value[2] === 0x44 - && value[3] === 0x46; -} - -function mediaType(value) { - if ( - value?.length >= 4 - && value[0] === 0x89 - && value[1] === 0x50 - && value[2] === 0x4e - && value[3] === 0x47 - ) { - return 'image/png'; - } - if (value?.length >= 3 && value[0] === 0xff && value[1] === 0xd8 && value[2] === 0xff) { - return 'image/jpeg'; - } - return 'application/octet-stream'; -} - -async function readInput(source, maxFileBytes) { - if (typeof source === 'string') { - const stats = await fs.stat(source); - if (stats.size > maxFileBytes) { - throw new OcrError( - 'resource_limit_exceeded', - `File size ${stats.size} exceeds maxFileBytes ${maxFileBytes}`, - ); - } - return fs.readFile(source); - } - if (!isBytes(source)) { - throw invalidArgument('document inputs must be file paths or Uint8Array values'); - } - if (source.byteLength > maxFileBytes) { - throw new OcrError( - 'resource_limit_exceeded', - `Input size ${source.byteLength} exceeds maxFileBytes ${maxFileBytes}`, - ); - } - return source; -} - -function linesFrom(result) { - return result.lines.map((line, index) => ({ - id: `L${index}`, - text: line.text, - confidence: line.confidence, - box: line.box, - })); -} - -function pageRect(page, box) { - if ( - box - && [box.left, box.bottom, box.right, box.top].every(Number.isFinite) - ) { - return { - x: box.left, - y: box.bottom, - width: box.right - box.left, - height: box.top - box.bottom, - }; - } - return { x: 0, y: 0, width: page.width, height: page.height }; -} - -async function* processPdf(engine, input, options) { - const renderer = loadPdfium(); - if (!renderer) { - throw new OcrError( - 'unsupported_capability', - 'PDF support is unavailable; reinstall @arcships/light-ocr-document with scripts enabled', - ); - } - const pdf = await readInput(input, options.maxFileBytes); - const document = await renderer.loadDocument( - Buffer.from(pdf.buffer, pdf.byteOffset, pdf.byteLength), - ); - let totalPixels = 0; - try { - const start = options.pageRange?.start ?? 1; - const end = Math.min(options.pageRange?.end ?? document.pageCount, document.pageCount); - const requestedPages = Math.max(0, end - start + 1); - if (start > document.pageCount) { - throw invalidArgument(`pageRange starts after the document's ${document.pageCount} pages`); - } - if (requestedPages > options.maxPages) { - throw new OcrError( - 'resource_limit_exceeded', - `Page count ${requestedPages} exceeds maxPages ${options.maxPages}`, - ); - } - - for (let pageNumber = start; pageNumber <= end; pageNumber++) { - throwIfAborted(options.signal); - const page = await document.getPage(pageNumber - 1); - let result; - try { - const scale = options.dpi / 72; - const renderedWidth = Math.ceil(page.width * scale); - const renderedHeight = Math.ceil(page.height * scale); - const renderedPixels = renderedWidth * renderedHeight; - if ( - !Number.isSafeInteger(renderedPixels) - || renderedPixels > options.maxPagePixels - ) { - throw new OcrError( - 'resource_limit_exceeded', - `Page ${pageNumber} rendered pixels ${renderedPixels} ` - + `exceeds maxPagePixels ${options.maxPagePixels}`, - ); - } - totalPixels += renderedPixels; - if (!Number.isSafeInteger(totalPixels) || totalPixels > options.maxTotalPixels) { - throw new OcrError( - 'resource_limit_exceeded', - `Total rendered pixels ${totalPixels} exceeds maxTotalPixels ` - + `${options.maxTotalPixels}`, - ); - } - - const renderStart = performance.now(); - const png = await page.render({ scale }); - const renderUs = Math.round((performance.now() - renderStart) * 1000); - throwIfAborted(options.signal); - const ocrStart = performance.now(); - const ocr = await engine.recognizeEncoded(png, { - ...options.ocrOptions, - signal: options.signal, - }); - const ocrUs = Math.round((performance.now() - ocrStart) * 1000); - result = { - index: pageNumber - 1, - width: ocr.imageWidth, - height: ocr.imageHeight, - coordinateSpace: 'pageSpace', - structure: 'ocr-order', - lines: linesFrom(ocr), - source: { - kind: 'pdf', - mediaType: 'application/pdf', - identity: { pageIndex: pageNumber - 1 }, - appliedTransforms: { - pdf: { - rotation: Number(page.rotation ?? 0) * 90, - mediaBox: { x: 0, y: 0, width: page.width, height: page.height }, - cropBox: pageRect(page, page.cropBox), - dpi: options.dpi, - scale, - }, - }, - }, - timingUs: { total: renderUs + ocrUs, decode: renderUs, ocr: ocrUs }, - modelBundleId: ocr.modelBundleId, - }; - } finally { - await page.close(); - } - yield result; - } - } finally { - await document.destroy(); - } -} - -async function* processImages(engine, inputs, options) { - if (inputs.length > options.maxPages) { - throw new OcrError( - 'resource_limit_exceeded', - `Page count ${inputs.length} exceeds maxPages ${options.maxPages}`, - ); - } - let totalPixels = 0; - for (let index = 0; index < inputs.length; index++) { - throwIfAborted(options.signal); - const image = await readInput(inputs[index], options.maxFileBytes); - const started = performance.now(); - const ocr = await engine.recognizeEncoded(image, { - ...options.ocrOptions, - applyExif: true, - signal: options.signal, - }); - const ocrUs = Math.round((performance.now() - started) * 1000); - const pixels = ocr.imageWidth * ocr.imageHeight; - if (!Number.isSafeInteger(pixels) || pixels > options.maxPagePixels) { - throw new OcrError( - 'resource_limit_exceeded', - `Image ${index + 1} pixels ${pixels} exceeds maxPagePixels ${options.maxPagePixels}`, - ); - } - totalPixels += pixels; - if (!Number.isSafeInteger(totalPixels) || totalPixels > options.maxTotalPixels) { - throw new OcrError( - 'resource_limit_exceeded', - `Total image pixels ${totalPixels} exceeds maxTotalPixels ${options.maxTotalPixels}`, - ); - } - yield { - index, - width: ocr.imageWidth, - height: ocr.imageHeight, - coordinateSpace: 'pageSpace', - structure: 'ocr-order', - lines: linesFrom(ocr), - source: { - kind: 'image', - mediaType: mediaType(image), - identity: { index }, - appliedTransforms: {}, - }, - timingUs: { total: ocrUs, decode: 0, ocr: ocrUs }, - modelBundleId: ocr.modelBundleId, - }; - } -} - -class DocumentEngine { - #engine; - #ownsEngine; - #closed = false; - - constructor(engine, ownsEngine) { - this.#engine = engine; - this.#ownsEngine = ownsEngine; - } - - async *recognizeDocument(source, options) { - if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed'); - const normalized = normalizeOptions(options); - if (Array.isArray(source)) { - if (source.length === 0) throw invalidArgument('document source array must not be empty'); - yield* processImages(this.#engine, source, normalized); - return; - } - if (typeof source !== 'string' && !isBytes(source)) { - throw invalidArgument( - 'document source must be a file path, Uint8Array, or a non-empty array of them', - ); - } - if ( - (typeof source === 'string' && path.extname(source).toLowerCase() === '.pdf') - || (isBytes(source) && isPdf(source)) - ) { - yield* processPdf(this.#engine, source, normalized); - return; - } - yield* processImages(this.#engine, [source], normalized); - } - - recognizePdf(source, options) { - if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed'); - return processPdf(this.#engine, source, normalizeOptions(options)); - } - - recognizeImages(sources, options) { - if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed'); - if (!Array.isArray(sources) || sources.length === 0) { - throw invalidArgument('image sources must be a non-empty array'); - } - return processImages(this.#engine, sources, normalizeOptions(options)); - } - - async close() { - if (this.#closed) return; - this.#closed = true; - if (this.#ownsEngine) await this.#engine.close(); - } -} - -async function createDocumentEngine(options = {}) { - if (options === null || typeof options !== 'object' || Array.isArray(options)) { - throw invalidArgument('createDocumentEngine options must be an object'); - } - const ownsEngine = options.engine === undefined; - const engine = options.engine ?? await createEngine(options.engineOptions); - return new DocumentEngine(engine, ownsEngine); -} - -async function* recognizeDocument(source, options = {}) { - if (options === null || typeof options !== 'object' || Array.isArray(options)) { - throw invalidArgument('recognizeDocument options must be an object'); - } - const documentEngine = await createDocumentEngine({ - engine: options.engine, - engineOptions: options.engineOptions, - }); - try { - yield* documentEngine.recognizeDocument(source, options); - } finally { - await documentEngine.close(); - } -} - -module.exports = { - createDocumentEngine, - getVersion, - hasPdfSupport, - recognizeDocument, - OcrError, -}; diff --git a/packages/light-ocr-document/src/index.d.ts b/packages/light-ocr-document/src/index.d.ts index 27c9a8b..7a494d6 100644 --- a/packages/light-ocr-document/src/index.d.ts +++ b/packages/light-ocr-document/src/index.d.ts @@ -1,124 +1,21 @@ -/// - -import type { - CreateEngineOptions, - OcrEngine, +export { + createDocumentEngine, + getVersion, + hasPdfSupport, + recognizeDocument, OcrError, - RecognizeOptions, } from '@arcships/light-ocr'; -export type { OcrEngine, OcrError, RecognizeOptions }; - -export interface Point { - readonly x: number; - readonly y: number; -} - -export interface Rect { - readonly x: number; - readonly y: number; - readonly width: number; - readonly height: number; -} - -export interface DocumentLine { - readonly id: string; - readonly text: string; - readonly confidence: number; - readonly box: readonly [Point, Point, Point, Point]; -} - -export interface PdfTransform { - readonly rotation: number; - readonly mediaBox: Rect; - readonly cropBox: Rect; - readonly dpi: number; - readonly scale: number; -} - -export interface PageSource { - readonly kind: 'image' | 'pdf'; - readonly mediaType: string; - readonly identity: Readonly>; - readonly appliedTransforms: { - readonly pdf?: PdfTransform; - }; -} - -export interface DocumentPage { - readonly index: number; - readonly width: number; - readonly height: number; - readonly coordinateSpace: 'pageSpace'; - readonly structure: 'ocr-order'; - readonly lines: ReadonlyArray; - readonly source: PageSource; - readonly timingUs: { - readonly total: number; - readonly decode: number; - readonly ocr: number; - }; - readonly modelBundleId?: string; -} - -export type DocumentInput = string | Uint8Array; - -export interface DocumentOptions { - /** Inclusive, one-based PDF page range. */ - readonly pageRange?: { - readonly start: number; - readonly end: number; - }; - /** PDF raster resolution. Must be an integer from 36 through 600. Default: 150. */ - readonly dpi?: number; - /** Maximum bytes accepted for each input. Default: 100 MiB. */ - readonly maxFileBytes?: number; - /** Maximum number of pages. Default: 100. */ - readonly maxPages?: number; - /** Maximum rendered pixels for one page. Default: 4096 × 4096. */ - readonly maxPagePixels?: number; - /** Maximum rendered pixels across the request. Default: 100 Mi pixels. */ - readonly maxTotalPixels?: number; - readonly signal?: AbortSignal; - readonly ocrOptions?: RecognizeOptions; -} - -export interface DocumentEngine { - recognizePdf( - source: DocumentInput, - options?: DocumentOptions, - ): AsyncGenerator; - recognizeImages( - sources: ReadonlyArray, - options?: DocumentOptions, - ): AsyncGenerator; - recognizeDocument( - source: DocumentInput | ReadonlyArray, - options?: DocumentOptions, - ): AsyncGenerator; - close(): Promise; -} - -export interface CreateDocumentEngineOptions { - /** Reuse an existing OCR engine. A borrowed engine is not closed automatically. */ - readonly engine?: OcrEngine; - /** Options used when this package creates and owns the OCR engine. */ - readonly engineOptions?: CreateEngineOptions; -} - -export interface RecognizeDocumentOptions extends DocumentOptions { - readonly engine?: OcrEngine; - readonly engineOptions?: CreateEngineOptions; -} - -export function createDocumentEngine( - options?: CreateDocumentEngineOptions, -): Promise; - -export function recognizeDocument( - source: DocumentInput | ReadonlyArray, - options?: RecognizeDocumentOptions, -): AsyncGenerator; - -export function getVersion(): string; -export function hasPdfSupport(): boolean; +export type { + CreateDocumentEngineOptions, + DocumentEngine, + DocumentInput, + DocumentLine, + DocumentOptions, + DocumentPage, + PageSource, + PdfTransform, + Point, + RecognizeDocumentOptions, + Rect, +} from '@arcships/light-ocr'; diff --git a/packages/light-ocr-document/test/cli.test.cjs b/packages/light-ocr-document/test/cli.test.cjs index 6b2ecc6..3d278b8 100644 --- a/packages/light-ocr-document/test/cli.test.cjs +++ b/packages/light-ocr-document/test/cli.test.cjs @@ -65,7 +65,7 @@ describe('light-ocr-document CLI', () => { const infoOut = capture(); assert.equal(await main(['info'], { stdout: infoOut.stream, stderr: stderr.stream }), 0); const info = JSON.parse(infoOut.read()); - assert.equal(info.name, '@arcships/light-ocr-document'); + assert.equal(info.name, '@arcships/light-ocr'); assert.equal(typeof info.pdfSupport, 'boolean'); }); diff --git a/packages/light-ocr-medium/package.json b/packages/light-ocr-medium/package.json index f8094cc..c7aeadb 100644 --- a/packages/light-ocr-medium/package.json +++ b/packages/light-ocr-medium/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr-medium", - "version": "0.1.3", + "version": "0.1.4", "private": true, "description": "Offline PP-OCRv6 Medium OCR for Node.js — preview quality tier", "license": "Apache-2.0", @@ -29,7 +29,7 @@ }, "dependencies": { "@arcships/light-ocr-model-ppocrv6-medium": "0.1.0", - "@arcships/light-ocr-runtime": "0.1.4" + "@arcships/light-ocr-runtime": "0.1.5" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr-tiny/package.json b/packages/light-ocr-tiny/package.json index e5e0f1a..8940ef2 100644 --- a/packages/light-ocr-tiny/package.json +++ b/packages/light-ocr-tiny/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr-tiny", - "version": "0.1.3", + "version": "0.1.4", "private": true, "description": "Offline PP-OCRv6 Tiny OCR for Node.js — preview size-first tier", "license": "Apache-2.0", @@ -29,7 +29,7 @@ }, "dependencies": { "@arcships/light-ocr-model-ppocrv6-tiny": "0.1.0", - "@arcships/light-ocr-runtime": "0.1.4" + "@arcships/light-ocr-runtime": "0.1.5" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr/README.md b/packages/light-ocr/README.md index 8be3197..98b3510 100644 --- a/packages/light-ocr/README.md +++ b/packages/light-ocr/README.md @@ -1,16 +1,18 @@ # @arcships/light-ocr -The stable PP-OCRv6 Small entry for local image OCR on Node.js 22 and 24. +The stable PP-OCRv6 Small entry for local image and PDF OCR on Node.js 22 and 24. ```bash npm install @arcships/light-ocr light-ocr image.png --format text +light-ocr report.pdf --pages 1-10 --format jsonl light-ocr doctor --json ``` The package exact-pins one model-free runtime, the Small model, and the native -component for the current platform. It has no install script and its complete -release closure is tested with npm offline and scripts disabled. +component and PDFium renderer for the current platform. It has no install +script and its complete release closure is tested with npm offline and scripts +disabled. ```js const { createEngine } = require('@arcships/light-ocr'); @@ -24,13 +26,6 @@ try { } ``` -PDF and multi-page processing are intentionally separate: - -```bash -npm install @arcships/light-ocr-document@next -light-ocr-document report.pdf --format jsonl -``` - -The Document package is preview software and runs its pinned PDF renderer's -prebuild installer. Keeping that dependency explicit prevents PDF tooling from -changing the stable image package's installation contract. +The main package exports `recognizeDocument()` and `createDocumentEngine()`. +PDFium's native files are inside the platform npm package, so neither install +nor runtime performs a secondary download. diff --git a/packages/light-ocr/package.json b/packages/light-ocr/package.json index e5ac828..5aae132 100644 --- a/packages/light-ocr/package.json +++ b/packages/light-ocr/package.json @@ -1,8 +1,8 @@ { "name": "@arcships/light-ocr", - "version": "0.5.4", + "version": "0.5.5", "private": true, - "description": "Offline PP-OCRv6 Small OCR for Node.js — the stable default tier", + "description": "Offline image and PDF OCR for Node.js — the stable PP-OCRv6 Small tier", "license": "Apache-2.0", "type": "commonjs", "main": "./src/index.cjs", @@ -16,7 +16,8 @@ "types": "./src/index.d.ts", "import": "./src/index.mjs", "require": "./src/index.cjs" - } + }, + "./document-cli": "./src/document-cli.cjs" }, "files": [ "src/", @@ -29,7 +30,7 @@ }, "dependencies": { "@arcships/light-ocr-model-ppocrv6-small": "0.3.4", - "@arcships/light-ocr-runtime": "0.1.4" + "@arcships/light-ocr-runtime": "0.1.5" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr/src/cli.cjs b/packages/light-ocr/src/cli.cjs index 0f300a6..b5f38cb 100755 --- a/packages/light-ocr/src/cli.cjs +++ b/packages/light-ocr/src/cli.cjs @@ -35,10 +35,40 @@ const cli = createCli({ loadNative, }); +function shouldUseDocumentCli(argv) { + if (argv[0] === 'document') return true; + const source = argv[0] === 'recognize' ? argv[1] : argv[0]; + return typeof source === 'string' && /\.pdf$/i.test(source); +} + +async function main(argv) { + if (shouldUseDocumentCli(argv)) { + const selectedArgs = argv[0] === 'document' ? argv.slice(1) : argv; + return require('./document-cli.cjs').main(selectedArgs); + } + const code = await cli.main(argv); + if ( + code === cli.EXIT.success + && argv.includes('--help') + && !argv.some((argument) => ['recognize', 'detect', 'info', 'doctor'].includes(argument)) + ) { + process.stdout.write( + '\nPDF: light-ocr [--pages N-M] or ' + + 'light-ocr document [options]\n', + ); + } + return code; +} + if (require.main === module) { - cli.main(process.argv.slice(2)).then((code) => { + const argv = process.argv.slice(2); + main(argv).then((code) => { if (code !== cli.EXIT.success) process.exitCode = code; }); } -module.exports = cli; +module.exports = { ...cli, main }; +Object.defineProperty(module.exports, 'shouldUseDocumentCli', { + value: shouldUseDocumentCli, + enumerable: false, +}); diff --git a/packages/light-ocr/src/document-cli.cjs b/packages/light-ocr/src/document-cli.cjs new file mode 100755 index 0000000..7738c10 --- /dev/null +++ b/packages/light-ocr/src/document-cli.cjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +'use strict'; + +const { + createDocumentEngine, + getVersion, + hasPdfSupport, + OcrError, +} = require('./index.cjs'); + +const USAGE = `light-ocr - local image, PDF, and multi-page OCR + +Usage: + light-ocr [recognize] [options] + light-ocr document [options] + +Options: + --format Output format (default: json) + --pages Inclusive PDF page range + --dpi <36-600> PDF raster DPI (default: 150) + --max-pages Maximum pages (default: 100) + --max-page-pixels Maximum rendered pixels per page + --max-total-pixels Maximum rendered pixels for the request + --max-file-bytes Maximum bytes per input + --provider + --quiet Suppress progress output + -h, --help Show help + -v, --version Show version`; + +const EXIT_CODES = Object.freeze({ + invalid_argument: 65, + invalid_image: 66, + unsupported_capability: 67, + invalid_model_bundle: 68, + resource_limit_exceeded: 69, + package_load_failed: 70, + inference_failed: 71, + internal_error: 72, +}); + +function argumentError(message) { + return new OcrError('invalid_argument', message); +} + +function takeValue(args, index, flag) { + const value = args[index + 1]; + if (value === undefined || value.startsWith('-')) { + throw argumentError(`${flag} requires a value`); + } + return value; +} + +function parseInteger(value, flag) { + if (!/^[1-9]\d*$/.test(value)) { + throw argumentError(`${flag} must be a positive integer`); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw argumentError(`${flag} is too large`); + } + return parsed; +} + +function parseArgs(argv) { + const args = [...argv]; + if (args[0] === 'recognize') args.shift(); + const sources = []; + const documentOptions = {}; + let format = 'json'; + let provider = 'auto'; + let quiet = false; + + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--quiet') { + quiet = true; + } else if (arg === '--format') { + format = takeValue(args, index, arg); + index++; + if (!['json', 'jsonl', 'text'].includes(format)) { + throw argumentError('--format must be json, jsonl, or text'); + } + } else if (arg === '--pages') { + const value = takeValue(args, index, arg); + index++; + const match = /^([1-9]\d*)(?:-([1-9]\d*))?$/.exec(value); + if (!match) throw argumentError('--pages must be N or N-M'); + const start = parseInteger(match[1], '--pages'); + const end = parseInteger(match[2] ?? match[1], '--pages'); + if (end < start) throw argumentError('--pages end must not precede its start'); + documentOptions.pageRange = { start, end }; + } else if ( + [ + '--dpi', + '--max-pages', + '--max-page-pixels', + '--max-total-pixels', + '--max-file-bytes', + ].includes(arg) + ) { + const value = parseInteger(takeValue(args, index, arg), arg); + index++; + const keys = { + '--dpi': 'dpi', + '--max-pages': 'maxPages', + '--max-page-pixels': 'maxPagePixels', + '--max-total-pixels': 'maxTotalPixels', + '--max-file-bytes': 'maxFileBytes', + }; + documentOptions[keys[arg]] = value; + } else if (arg === '--provider') { + provider = takeValue(args, index, arg); + index++; + if (!['auto', 'cpu', 'apple', 'webgpu'].includes(provider)) { + throw argumentError('--provider must be auto, cpu, apple, or webgpu'); + } + } else if (arg.startsWith('-')) { + throw argumentError(`unknown option: ${arg}`); + } else { + sources.push(arg); + } + } + if (sources.length === 0) throw argumentError('at least one source is required'); + return { documentOptions, format, provider, quiet, sources }; +} + +function writeLine(stream, value = '') { + stream.write(`${value}\n`); +} + +async function main( + argv = process.argv.slice(2), + io = { stdout: process.stdout, stderr: process.stderr }, +) { + if ( + argv.length === 0 + || argv[0] === 'help' + || argv.includes('--help') + || argv.includes('-h') + ) { + writeLine(io.stdout, USAGE); + return 0; + } + if (argv.includes('--version') || argv.includes('-v')) { + writeLine(io.stdout, getVersion()); + return 0; + } + if (argv[0] === 'info') { + writeLine(io.stdout, JSON.stringify({ + name: '@arcships/light-ocr', + version: getVersion(), + pdfSupport: hasPdfSupport(), + })); + return 0; + } + + let parsed; + let engine; + try { + parsed = parseArgs(argv); + engine = await createDocumentEngine({ + engineOptions: { execution: { provider: parsed.provider } }, + }); + const source = parsed.sources.length === 1 ? parsed.sources[0] : parsed.sources; + const pages = parsed.format === 'json' ? [] : undefined; + let count = 0; + for await (const page of engine.recognizeDocument(source, parsed.documentOptions)) { + count++; + if (pages) pages.push(page); + if (parsed.format === 'jsonl') writeLine(io.stdout, JSON.stringify(page)); + if (parsed.format === 'text') { + if (count > 1) writeLine(io.stdout); + for (const line of page.lines) writeLine(io.stdout, line.text); + } + if (!parsed.quiet) io.stderr.write(`\rProcessed page ${count}`); + } + if (!parsed.quiet) writeLine(io.stderr); + if (pages) { + writeLine(io.stdout, JSON.stringify({ + schemaVersion: 1, + source: { + kind: pages[0]?.source.kind === 'pdf' ? 'pdf' : 'page-images', + mediaType: pages[0]?.source.mediaType ?? 'application/octet-stream', + identity: {}, + pageCount: pages.length, + }, + pages, + }, null, 2)); + } + return 0; + } catch (error) { + if (error?.name === 'AbortError') { + writeLine(io.stderr, 'The operation was aborted'); + return 72; + } + if (error instanceof OcrError) { + writeLine(io.stderr, `${error.code}: ${error.message}`); + return EXIT_CODES[error.code] ?? 72; + } + writeLine(io.stderr, `internal_error: ${error?.message ?? String(error)}`); + return 72; + } finally { + await engine?.close(); + } +} + +if (require.main === module) { + main().then( + (code) => { + process.exitCode = code; + }, + (error) => { + console.error(error); + process.exitCode = 72; + }, + ); +} + +module.exports = { main, parseArgs, USAGE }; diff --git a/packages/light-ocr/src/document.cjs b/packages/light-ocr/src/document.cjs new file mode 100644 index 0000000..0e39726 --- /dev/null +++ b/packages/light-ocr/src/document.cjs @@ -0,0 +1,447 @@ +'use strict'; + +const fs = require('node:fs/promises'); +const path = require('node:path'); + +const { OcrError } = require('@arcships/light-ocr-runtime'); + +const DEFAULTS = Object.freeze({ + dpi: 150, + maxPages: 100, + maxPagePixels: 4096 * 4096, + maxTotalPixels: 100 * 1024 * 1024, + maxFileBytes: 100 * 1024 * 1024, +}); + +let pdfium; +let pdfiumLoaded = false; +let defaultCreateEngine; + +function platformPdfiumPackage() { + const packages = { + 'darwin-arm64': '@arcships/light-ocr-darwin-arm64', + 'darwin-x64': '@arcships/light-ocr-darwin-x64', + 'linux-arm64': '@arcships/light-ocr-linux-arm64-gnu', + 'linux-x64': '@arcships/light-ocr-linux-x64-gnu', + 'win32-arm64': '@arcships/light-ocr-win32-arm64', + 'win32-x64': '@arcships/light-ocr-win32-x64', + }; + return packages[`${process.platform}-${process.arch}`]; +} + +function loadPdfium() { + if (!pdfiumLoaded) { + pdfiumLoaded = true; + try { + const developmentModule = process.env.LIGHT_OCR_PDFIUM_MODULE; + if (developmentModule) { + pdfium = require(path.resolve(developmentModule)); + } else { + const packageName = platformPdfiumPackage(); + if (!packageName) { + throw new Error(`unsupported platform ${process.platform}-${process.arch}`); + } + pdfium = require(`${packageName}/pdfium`); + } + } catch { + // Workspace tests may use the upstream module directly. Published packages + // do not depend on it: production PDFium lives in the platform npm package. + try { + pdfium = require('pdfium-native'); + } catch { + pdfium = undefined; + } + } + } + return pdfium; +} + +function hasPdfSupport() { + return loadPdfium() !== undefined; +} + +function getVersion() { + return require('../package.json').version; +} + +function invalidArgument(message) { + return new OcrError('invalid_argument', message); +} + +function positiveInteger(value, name, fallback, maximum = Number.MAX_SAFE_INTEGER) { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value < 1 || value > maximum) { + throw invalidArgument(`${name} must be an integer between 1 and ${maximum}`); + } + return value; +} + +function normalizeOptions(options) { + if (options === undefined) options = {}; + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw invalidArgument('document options must be an object'); + } + const normalized = { + ...options, + dpi: positiveInteger(options.dpi, 'dpi', DEFAULTS.dpi, 600), + maxPages: positiveInteger(options.maxPages, 'maxPages', DEFAULTS.maxPages, 10000), + maxPagePixels: positiveInteger( + options.maxPagePixels, + 'maxPagePixels', + DEFAULTS.maxPagePixels, + ), + maxTotalPixels: positiveInteger( + options.maxTotalPixels, + 'maxTotalPixels', + DEFAULTS.maxTotalPixels, + ), + maxFileBytes: positiveInteger( + options.maxFileBytes, + 'maxFileBytes', + DEFAULTS.maxFileBytes, + ), + }; + if (normalized.dpi < 36) { + throw invalidArgument('dpi must be an integer between 36 and 600'); + } + if (options.pageRange !== undefined) { + const range = options.pageRange; + if ( + range === null + || typeof range !== 'object' + || Array.isArray(range) + || !Number.isSafeInteger(range.start) + || !Number.isSafeInteger(range.end) + || range.start < 1 + || range.end < range.start + ) { + throw invalidArgument('pageRange must contain 1-based integers with start <= end'); + } + } + return normalized; +} + +function throwIfAborted(signal) { + if (signal?.aborted) { + throw signal.reason === undefined + ? new DOMException('The operation was aborted', 'AbortError') + : signal.reason; + } +} + +function isBytes(value) { + return value instanceof Uint8Array; +} + +function isPdf(value) { + return value?.length >= 4 + && value[0] === 0x25 + && value[1] === 0x50 + && value[2] === 0x44 + && value[3] === 0x46; +} + +function mediaType(value) { + if ( + value?.length >= 4 + && value[0] === 0x89 + && value[1] === 0x50 + && value[2] === 0x4e + && value[3] === 0x47 + ) { + return 'image/png'; + } + if (value?.length >= 3 && value[0] === 0xff && value[1] === 0xd8 && value[2] === 0xff) { + return 'image/jpeg'; + } + return 'application/octet-stream'; +} + +async function readInput(source, maxFileBytes) { + if (typeof source === 'string') { + const stats = await fs.stat(source); + if (stats.size > maxFileBytes) { + throw new OcrError( + 'resource_limit_exceeded', + `File size ${stats.size} exceeds maxFileBytes ${maxFileBytes}`, + ); + } + return fs.readFile(source); + } + if (!isBytes(source)) { + throw invalidArgument('document inputs must be file paths or Uint8Array values'); + } + if (source.byteLength > maxFileBytes) { + throw new OcrError( + 'resource_limit_exceeded', + `Input size ${source.byteLength} exceeds maxFileBytes ${maxFileBytes}`, + ); + } + return source; +} + +function linesFrom(result) { + return result.lines.map((line, index) => ({ + id: `L${index}`, + text: line.text, + confidence: line.confidence, + box: line.box, + })); +} + +function pageRect(page, box) { + if ( + box + && [box.left, box.bottom, box.right, box.top].every(Number.isFinite) + ) { + return { + x: box.left, + y: box.bottom, + width: box.right - box.left, + height: box.top - box.bottom, + }; + } + return { x: 0, y: 0, width: page.width, height: page.height }; +} + +async function* processPdf(engine, input, options) { + const renderer = loadPdfium(); + if (!renderer) { + throw new OcrError( + 'unsupported_capability', + 'PDF support is unavailable; reinstall @arcships/light-ocr without --omit=optional', + ); + } + const pdf = await readInput(input, options.maxFileBytes); + const document = await renderer.loadDocument( + Buffer.from(pdf.buffer, pdf.byteOffset, pdf.byteLength), + ); + let totalPixels = 0; + try { + const start = options.pageRange?.start ?? 1; + const end = Math.min(options.pageRange?.end ?? document.pageCount, document.pageCount); + const requestedPages = Math.max(0, end - start + 1); + if (start > document.pageCount) { + throw invalidArgument(`pageRange starts after the document's ${document.pageCount} pages`); + } + if (requestedPages > options.maxPages) { + throw new OcrError( + 'resource_limit_exceeded', + `Page count ${requestedPages} exceeds maxPages ${options.maxPages}`, + ); + } + + for (let pageNumber = start; pageNumber <= end; pageNumber++) { + throwIfAborted(options.signal); + const page = await document.getPage(pageNumber - 1); + let result; + try { + const scale = options.dpi / 72; + const renderedWidth = Math.ceil(page.width * scale); + const renderedHeight = Math.ceil(page.height * scale); + const renderedPixels = renderedWidth * renderedHeight; + if ( + !Number.isSafeInteger(renderedPixels) + || renderedPixels > options.maxPagePixels + ) { + throw new OcrError( + 'resource_limit_exceeded', + `Page ${pageNumber} rendered pixels ${renderedPixels} ` + + `exceeds maxPagePixels ${options.maxPagePixels}`, + ); + } + totalPixels += renderedPixels; + if (!Number.isSafeInteger(totalPixels) || totalPixels > options.maxTotalPixels) { + throw new OcrError( + 'resource_limit_exceeded', + `Total rendered pixels ${totalPixels} exceeds maxTotalPixels ` + + `${options.maxTotalPixels}`, + ); + } + + const renderStart = performance.now(); + const png = await page.render({ scale }); + const renderUs = Math.round((performance.now() - renderStart) * 1000); + throwIfAborted(options.signal); + const ocrStart = performance.now(); + const ocr = await engine.recognizeEncoded(png, { + ...options.ocrOptions, + signal: options.signal, + }); + const ocrUs = Math.round((performance.now() - ocrStart) * 1000); + result = { + index: pageNumber - 1, + width: ocr.imageWidth, + height: ocr.imageHeight, + coordinateSpace: 'pageSpace', + structure: 'ocr-order', + lines: linesFrom(ocr), + source: { + kind: 'pdf', + mediaType: 'application/pdf', + identity: { pageIndex: pageNumber - 1 }, + appliedTransforms: { + pdf: { + rotation: Number(page.rotation ?? 0) * 90, + mediaBox: { x: 0, y: 0, width: page.width, height: page.height }, + cropBox: pageRect(page, page.cropBox), + dpi: options.dpi, + scale, + }, + }, + }, + timingUs: { total: renderUs + ocrUs, decode: renderUs, ocr: ocrUs }, + modelBundleId: ocr.modelBundleId, + }; + } finally { + await page.close(); + } + yield result; + } + } finally { + await document.destroy(); + } +} + +async function* processImages(engine, inputs, options) { + if (inputs.length > options.maxPages) { + throw new OcrError( + 'resource_limit_exceeded', + `Page count ${inputs.length} exceeds maxPages ${options.maxPages}`, + ); + } + let totalPixels = 0; + for (let index = 0; index < inputs.length; index++) { + throwIfAborted(options.signal); + const image = await readInput(inputs[index], options.maxFileBytes); + const started = performance.now(); + const ocr = await engine.recognizeEncoded(image, { + ...options.ocrOptions, + applyExif: true, + signal: options.signal, + }); + const ocrUs = Math.round((performance.now() - started) * 1000); + const pixels = ocr.imageWidth * ocr.imageHeight; + if (!Number.isSafeInteger(pixels) || pixels > options.maxPagePixels) { + throw new OcrError( + 'resource_limit_exceeded', + `Image ${index + 1} pixels ${pixels} exceeds maxPagePixels ${options.maxPagePixels}`, + ); + } + totalPixels += pixels; + if (!Number.isSafeInteger(totalPixels) || totalPixels > options.maxTotalPixels) { + throw new OcrError( + 'resource_limit_exceeded', + `Total image pixels ${totalPixels} exceeds maxTotalPixels ${options.maxTotalPixels}`, + ); + } + yield { + index, + width: ocr.imageWidth, + height: ocr.imageHeight, + coordinateSpace: 'pageSpace', + structure: 'ocr-order', + lines: linesFrom(ocr), + source: { + kind: 'image', + mediaType: mediaType(image), + identity: { index }, + appliedTransforms: {}, + }, + timingUs: { total: ocrUs, decode: 0, ocr: ocrUs }, + modelBundleId: ocr.modelBundleId, + }; + } +} + +class DocumentEngine { + #engine; + #ownsEngine; + #closed = false; + + constructor(engine, ownsEngine) { + this.#engine = engine; + this.#ownsEngine = ownsEngine; + } + + async *recognizeDocument(source, options) { + if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed'); + const normalized = normalizeOptions(options); + if (Array.isArray(source)) { + if (source.length === 0) throw invalidArgument('document source array must not be empty'); + yield* processImages(this.#engine, source, normalized); + return; + } + if (typeof source !== 'string' && !isBytes(source)) { + throw invalidArgument( + 'document source must be a file path, Uint8Array, or a non-empty array of them', + ); + } + if ( + (typeof source === 'string' && path.extname(source).toLowerCase() === '.pdf') + || (isBytes(source) && isPdf(source)) + ) { + yield* processPdf(this.#engine, source, normalized); + return; + } + yield* processImages(this.#engine, [source], normalized); + } + + recognizePdf(source, options) { + if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed'); + return processPdf(this.#engine, source, normalizeOptions(options)); + } + + recognizeImages(sources, options) { + if (this.#closed) throw new OcrError('invalid_engine', 'Document engine is closed'); + if (!Array.isArray(sources) || sources.length === 0) { + throw invalidArgument('image sources must be a non-empty array'); + } + return processImages(this.#engine, sources, normalizeOptions(options)); + } + + async close() { + if (this.#closed) return; + this.#closed = true; + if (this.#ownsEngine) await this.#engine.close(); + } +} + +async function createDocumentEngine(options = {}) { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw invalidArgument('createDocumentEngine options must be an object'); + } + const ownsEngine = options.engine === undefined; + const engine = options.engine ?? await defaultCreateEngine(options.engineOptions); + return new DocumentEngine(engine, ownsEngine); +} + +async function* recognizeDocument(source, options = {}) { + if (options === null || typeof options !== 'object' || Array.isArray(options)) { + throw invalidArgument('recognizeDocument options must be an object'); + } + const documentEngine = await createDocumentEngine({ + engine: options.engine, + engineOptions: options.engineOptions, + }); + try { + yield* documentEngine.recognizeDocument(source, options); + } finally { + await documentEngine.close(); + } +} + +function createDocumentApi(createEngine) { + if (typeof createEngine !== 'function') { + throw new TypeError('createDocumentApi requires createEngine'); + } + defaultCreateEngine = createEngine; + return Object.freeze({ + createDocumentEngine, + getVersion, + hasPdfSupport, + recognizeDocument, + }); +} + +module.exports = { createDocumentApi }; diff --git a/packages/light-ocr/src/index.cjs b/packages/light-ocr/src/index.cjs index 7efb0c6..9cfcb27 100644 --- a/packages/light-ocr/src/index.cjs +++ b/packages/light-ocr/src/index.cjs @@ -1,8 +1,9 @@ 'use strict'; const { createModelFacade } = require('@arcships/light-ocr-runtime/facade'); +const { createDocumentApi } = require('./document.cjs'); -module.exports = createModelFacade({ +const facade = createModelFacade({ model: 'ppocrv6-small', modelPackage: '@arcships/light-ocr-model-ppocrv6-small', compatibleBundleIds: [ @@ -25,3 +26,8 @@ module.exports = createModelFacade({ maturity: 'stable', }, }); + +module.exports = Object.freeze({ + ...facade, + ...createDocumentApi(facade.createEngine), +}); diff --git a/packages/light-ocr/src/index.d.ts b/packages/light-ocr/src/index.d.ts index f9103a4..16cc972 100644 --- a/packages/light-ocr/src/index.d.ts +++ b/packages/light-ocr/src/index.d.ts @@ -19,3 +19,112 @@ export const modelProfile: ModelProfile & { readonly model: BuiltInModel; readonly maturity: 'stable'; }; + +export interface Point { + readonly x: number; + readonly y: number; +} + +export interface Rect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface DocumentLine { + readonly id: string; + readonly text: string; + readonly confidence: number; + readonly box: readonly [Point, Point, Point, Point]; +} + +export interface PdfTransform { + readonly rotation: number; + readonly mediaBox: Rect; + readonly cropBox: Rect; + readonly dpi: number; + readonly scale: number; +} + +export interface PageSource { + readonly kind: 'image' | 'pdf'; + readonly mediaType: string; + readonly identity: Readonly>; + readonly appliedTransforms: { + readonly pdf?: PdfTransform; + }; +} + +export interface DocumentPage { + readonly index: number; + readonly width: number; + readonly height: number; + readonly coordinateSpace: 'pageSpace'; + readonly structure: 'ocr-order'; + readonly lines: ReadonlyArray; + readonly source: PageSource; + readonly timingUs: { + readonly total: number; + readonly decode: number; + readonly ocr: number; + }; + readonly modelBundleId?: string; +} + +export type DocumentInput = string | Uint8Array; + +export interface DocumentOptions { + /** Inclusive, one-based PDF page range. */ + readonly pageRange?: { readonly start: number; readonly end: number }; + /** PDF raster resolution. Must be an integer from 36 through 600. Default: 150. */ + readonly dpi?: number; + /** Maximum bytes accepted for each input. Default: 100 MiB. */ + readonly maxFileBytes?: number; + /** Maximum number of pages. Default: 100. */ + readonly maxPages?: number; + /** Maximum rendered pixels for one page. Default: 4096 × 4096. */ + readonly maxPagePixels?: number; + /** Maximum rendered pixels across the request. Default: 100 Mi pixels. */ + readonly maxTotalPixels?: number; + readonly signal?: AbortSignal; + readonly ocrOptions?: import('@arcships/light-ocr-runtime').RecognizeOptions; +} + +export interface DocumentEngine { + recognizePdf( + source: DocumentInput, + options?: DocumentOptions, + ): AsyncGenerator; + recognizeImages( + sources: ReadonlyArray, + options?: DocumentOptions, + ): AsyncGenerator; + recognizeDocument( + source: DocumentInput | ReadonlyArray, + options?: DocumentOptions, + ): AsyncGenerator; + close(): Promise; +} + +export interface CreateDocumentEngineOptions { + readonly engine?: OcrEngine; + readonly engineOptions?: CreateEngineOptions; +} + +export interface RecognizeDocumentOptions extends DocumentOptions { + readonly engine?: OcrEngine; + readonly engineOptions?: CreateEngineOptions; +} + +export function createDocumentEngine( + options?: CreateDocumentEngineOptions, +): Promise; + +export function recognizeDocument( + source: DocumentInput | ReadonlyArray, + options?: RecognizeDocumentOptions, +): AsyncGenerator; + +export function getVersion(): string; +export function hasPdfSupport(): boolean; diff --git a/packages/light-ocr/src/index.mjs b/packages/light-ocr/src/index.mjs index 4e75a66..c7a036e 100644 --- a/packages/light-ocr/src/index.mjs +++ b/packages/light-ocr/src/index.mjs @@ -3,3 +3,7 @@ import cjs from './index.cjs'; export const createEngine = cjs.createEngine; export const OcrError = cjs.OcrError; export const modelProfile = cjs.modelProfile; +export const createDocumentEngine = cjs.createDocumentEngine; +export const getVersion = cjs.getVersion; +export const hasPdfSupport = cjs.hasPdfSupport; +export const recognizeDocument = cjs.recognizeDocument; diff --git a/packages/light-ocr/test/facade.test.cjs b/packages/light-ocr/test/facade.test.cjs index 3b8deb4..ca18e53 100644 --- a/packages/light-ocr/test/facade.test.cjs +++ b/packages/light-ocr/test/facade.test.cjs @@ -22,6 +22,13 @@ test('small facade reuses the runtime API and error identity', () => { assert.strictEqual(facade.OcrError, runtime.OcrError); }); +test('stable facade includes the document API and local PDF capability probe', () => { + assert.equal(typeof facade.createDocumentEngine, 'function'); + assert.equal(typeof facade.recognizeDocument, 'function'); + assert.equal(typeof facade.hasPdfSupport(), 'boolean'); + assert.equal(facade.getVersion(), packageMetadata.version); +}); + test('small facade rejects an unknown built-in model before native loading', async () => { await assert.rejects( facade.createEngine({ model: 'ppocrv6-medium' }), diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 8882c9e..8877fc4 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr-runtime", - "version": "0.1.4", + "version": "0.1.5", "private": true, "description": "Model-free Node.js runtime for light-ocr", "license": "Apache-2.0", @@ -28,12 +28,12 @@ "node": "^22.0.0 || ^24.0.0" }, "optionalDependencies": { - "@arcships/light-ocr-darwin-arm64": "0.3.4", - "@arcships/light-ocr-darwin-x64": "0.3.4", - "@arcships/light-ocr-linux-arm64-gnu": "0.3.4", - "@arcships/light-ocr-linux-x64-gnu": "0.3.4", - "@arcships/light-ocr-win32-arm64": "0.3.4", - "@arcships/light-ocr-win32-x64": "0.3.4" + "@arcships/light-ocr-darwin-arm64": "0.5.5", + "@arcships/light-ocr-darwin-x64": "0.5.5", + "@arcships/light-ocr-linux-arm64-gnu": "0.5.5", + "@arcships/light-ocr-linux-x64-gnu": "0.5.5", + "@arcships/light-ocr-win32-arm64": "0.5.5", + "@arcships/light-ocr-win32-x64": "0.5.5" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/tests/python/npm_release_fixtures.py b/tests/python/npm_release_fixtures.py index f4f2dde..5e2f37f 100644 --- a/tests/python/npm_release_fixtures.py +++ b/tests/python/npm_release_fixtures.py @@ -31,6 +31,14 @@ def stage_cpu_native_packages(root: Path) -> Path: (binaries / platform["runtime"]).write_bytes(platform["runtime"].encode()) metadata = release_metadata(root) + pdfium = root / "pdfium-native" + pdfium_release = pdfium / "build" / "Release" + pdfium_release.mkdir(parents=True) + (pdfium_release / "pdfium.node").write_bytes(b"pdfium-addon") + for library in set(npm_release.PDFIUM_LIBRARIES.values()): + (pdfium_release / library).write_bytes(library.encode()) + (pdfium / "LICENSE").write_text("MIT\n", "utf-8") + (pdfium / "THIRD-PARTY-NOTICES.md").write_text("PDFium notices\n", "utf-8") native_root = root / "native" for platform_id in npm_release.PLATFORMS: npm_release.stage_native( @@ -38,6 +46,7 @@ def stage_cpu_native_packages(root: Path) -> Path: platform_id=platform_id, build_dir=build_dir, metadata_dir=metadata, + pdfium_dir=pdfium, output_dir=native_root / platform_id, ) ) diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index f5cc790..1800017 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -227,13 +227,30 @@ def test_stages_and_packs_the_independently_versioned_release_set(self) -> None: document["dependencies"][npm_release.FACADE_PACKAGE], npm_release.CORE_VERSION, ) - self.assertEqual(document["dependencies"]["pdfium-native"], "0.6.1") + self.assertNotIn("pdfium-native", document["dependencies"]) self.assertTrue( (staging / "light-ocr-document" / "src" / "cli.cjs").is_file() ) self.assertTrue( (staging / "light-ocr-document" / "LICENSE").is_file() ) + for platform_id in npm_release.PLATFORMS: + native = staging / platform_id + self.assertTrue((native / "pdfium" / "pdfium.node").is_file()) + self.assertTrue( + ( + native + / "pdfium" + / npm_release.PDFIUM_LIBRARIES[platform_id] + ).is_file() + ) + native_package = json.loads( + (native / "package.json").read_text("utf-8") + ) + self.assertEqual( + native_package["exports"]["./pdfium"], + "./pdfium/index.cjs", + ) runtime = json.loads( (staging / "runtime" / "package.json").read_text("utf-8") ) diff --git a/tools/npm/document-smoke.cjs b/tools/npm/document-smoke.cjs index f854904..d0f7bb0 100644 --- a/tools/npm/document-smoke.cjs +++ b/tools/npm/document-smoke.cjs @@ -8,11 +8,11 @@ const consumerRequire = createRequire(path.join(process.cwd(), 'package.json')); const { createDocumentEngine, hasPdfSupport, -} = consumerRequire('@arcships/light-ocr-document'); +} = consumerRequire('@arcships/light-ocr'); const { createTextPdf } = require('./pdf-fixture.cjs'); async function main() { - assert.equal(hasPdfSupport(), true, 'pdfium-native did not install correctly'); + assert.equal(hasPdfSupport(), true, 'bundled PDFium runtime is unavailable'); const engine = await createDocumentEngine({ engineOptions: { execution: { provider: 'cpu' } }, }); @@ -30,7 +30,7 @@ async function main() { ); console.log(JSON.stringify({ ok: true, - package: '@arcships/light-ocr-document', + package: '@arcships/light-ocr', pages: pages.length, text: pages[0].lines.map((line) => line.text).join(' '), })); diff --git a/tools/npm/pdfium-loader.cjs b/tools/npm/pdfium-loader.cjs new file mode 100644 index 0000000..be5ac70 --- /dev/null +++ b/tools/npm/pdfium-loader.cjs @@ -0,0 +1,16 @@ +'use strict'; + +const path = require('node:path'); + +const addonDirectory = __dirname; +if (process.platform === 'win32') { + process.env.PATH = `${addonDirectory};${process.env.PATH ?? ''}`; +} + +const addon = require(path.join(addonDirectory, 'pdfium.node')); + +module.exports = Object.freeze({ + loadDocument(input, password) { + return addon.loadDocument(input, password); + }, +}); diff --git a/tools/npm_release.py b/tools/npm_release.py index 08eabc8..9651ea0 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -61,12 +61,12 @@ }, "tiny": { "name": "@arcships/light-ocr-tiny", - "version": "0.1.3", + "version": "0.1.4", "workspace": "light-ocr-tiny", }, "medium": { "name": "@arcships/light-ocr-medium", - "version": "0.1.3", + "version": "0.1.4", "workspace": "light-ocr-medium", }, } @@ -119,6 +119,15 @@ "runtime": "onnxruntime.dll", }, } +PDFIUM_VERSION = "0.6.1" +PDFIUM_LIBRARIES = { + "macos-arm64": "libpdfium.dylib", + "macos-x64": "libpdfium.dylib", + "linux-x64": "libpdfium.so", + "linux-arm64": "libpdfium.so", + "windows-x64": "pdfium.dll", + "windows-arm64": "pdfium.dll", +} def sha256(path: Path) -> str: @@ -541,6 +550,8 @@ def stage_native(arguments: argparse.Namespace) -> None: runtime_flavor = getattr(arguments, "runtime_flavor", "cpu") qualification_build = bool(getattr(arguments, "qualification_build", False)) configuration = getattr(arguments, "configuration", "Release") + pdfium_argument = getattr(arguments, "pdfium_dir", None) + pdfium_source = pdfium_argument.resolve() if pdfium_argument else None if runtime_flavor not in {"cpu", "webgpu"}: raise RuntimeError("runtime flavor must be cpu or webgpu") if runtime_flavor == "webgpu" and arguments.platform_id not in { @@ -647,6 +658,61 @@ def stage_native(arguments: argparse.Namespace) -> None: copy_file(metadata / "sbom.spdx.json", stage / "sbom.spdx.json") shutil.copytree(metadata / "licenses", stage / "licenses") + if pdfium_source is not None: + pdfium = stage / "pdfium" + pdfium.mkdir() + pdfium_release = pdfium_source / "build" / "Release" + copy_file(pdfium_release / "pdfium.node", pdfium / "pdfium.node") + copy_file( + pdfium_release / PDFIUM_LIBRARIES[arguments.platform_id], + pdfium / PDFIUM_LIBRARIES[arguments.platform_id], + ) + copy_file(ROOT / "tools" / "npm" / "pdfium-loader.cjs", pdfium / "index.cjs") + pdfium_license = stage / "licenses" / "pdfium-native-MIT.txt" + pdfium_notices = stage / "licenses" / "pdfium-native-THIRD-PARTY-NOTICES.md" + copy_file(pdfium_source / "LICENSE", pdfium_license) + copy_file(pdfium_source / "THIRD-PARTY-NOTICES.md", pdfium_notices) + inventory = read_json(stage / "license-inventory.json") + inventory.setdefault("files", []).extend( + [ + { + "component": f"pdfium-native-{PDFIUM_VERSION}", + "file": f"licenses/{pdfium_license.name}", + "sha256": sha256(pdfium_license), + }, + { + "component": f"pdfium-native-{PDFIUM_VERSION}", + "file": f"licenses/{pdfium_notices.name}", + "sha256": sha256(pdfium_notices), + }, + ] + ) + write_json(stage / "license-inventory.json", inventory) + sbom = read_json(stage / "sbom.spdx.json") + sbom.setdefault("packages", []).append( + { + "name": "pdfium-native", + "SPDXID": "SPDXRef-Package-pdfium-native", + "versionInfo": PDFIUM_VERSION, + "downloadLocation": ( + "https://github.com/xonaman/nodejs-pdfium-native" + f"/releases/tag/v{PDFIUM_VERSION}" + ), + "filesAnalyzed": False, + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "copyrightText": "Copyright (c) 2026 xonaman", + } + ) + sbom.setdefault("relationships", []).append( + { + "spdxElementId": "SPDXRef-Package-light-ocr-core", + "relationshipType": "DEPENDS_ON", + "relatedSpdxElement": "SPDXRef-Package-pdfium-native", + } + ) + write_json(stage / "sbom.spdx.json", sbom) + descriptor = { "schemaVersion": "2.0", "platform": { @@ -1026,6 +1092,7 @@ def assemble(arguments: argparse.Namespace) -> None: package = output / platform_id package.mkdir() copy_tree(source / "native", package / "native") + copy_tree(source / "pdfium", package / "pdfium") copy_tree(source / "licenses", package / "licenses") copy_file(source / "license-inventory.json", package / "license-inventory.json") copy_file(source / "sbom.spdx.json", package / "sbom.spdx.json") @@ -1037,11 +1104,15 @@ def assemble(arguments: argparse.Namespace) -> None: package_json.update( { "main": "./native/light_ocr_node.node", - "exports": {".": "./native/light_ocr_node.node"}, + "exports": { + ".": "./native/light_ocr_node.node", + "./pdfium": "./pdfium/index.cjs", + }, "os": platform["os"], "cpu": platform["cpu"], "files": [ "native/", + "pdfium/", "licenses/", "license-inventory.json", "sbom.spdx.json", @@ -1430,6 +1501,7 @@ def main() -> int: native.add_argument("--build-dir", type=Path, required=True) native.add_argument("--configuration", default="Release") native.add_argument("--metadata-dir", type=Path, required=True) + native.add_argument("--pdfium-dir", type=Path) native.add_argument("--output-dir", type=Path, required=True) native.add_argument("--runtime-flavor", choices=["cpu", "webgpu"], default="cpu") native.add_argument("--webgpu-artifact-manifest", type=Path)