From 449fe9c7f83d4b8f27e5697ecf1a4b9b528ef40d Mon Sep 17 00:00:00 2001 From: eric8810 Date: Mon, 27 Jul 2026 11:52:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(release):=20=E4=BA=91=E7=BF=B3=E5=B0=BD?= =?UTF-8?q?=E6=89=AB=EF=BC=8C=E6=96=87=E8=88=9F=E5=BD=92=E8=88=AA=20=C2=B7?= =?UTF-8?q?=20repair=20N3=20release?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the stable image facade and its offline installation contract.\nMove PDF and multi-page OCR into the explicit Document preview.\n\nAdd real PDF rendering tests, package gates, honest release records,\nand privacy-safe doctor output for the 0.5.4 repair candidate. --- .agents/skills/local-ocr/SKILL.md | 16 +- .github/workflows/npm-release.yml | 24 +- CHANGELOG.md | 21 + README.md | 29 +- README.zh-CN.md | 27 +- VERSION | 2 +- bindings/node/README.md | 4 + bindings/node/test/cli.test.cjs | 59 +- .../node/test/document-integration.test.cjs | 84 --- docs/decisions.md | 30 + docs/implementation-status.md | 4 +- docs/releases/npm-0.5.3.md | 108 +-- docs/releases/npm-0.5.4.md | 26 + package-lock.json | 235 ++++++- package.json | 2 +- packages/light-ocr-document/README.md | 48 ++ packages/light-ocr-document/package.json | 14 +- packages/light-ocr-document/src/cli.cjs | 348 +++++----- packages/light-ocr-document/src/index.cjs | 628 ++++++++++-------- packages/light-ocr-document/src/index.d.ts | 162 ++--- packages/light-ocr-document/src/index.mjs | 8 +- packages/light-ocr-document/test/cli.test.cjs | 78 +++ .../light-ocr-document/test/document.test.cjs | 175 +++-- packages/light-ocr-document/test/types.ts | 27 + packages/light-ocr-medium/package.json | 4 +- packages/light-ocr-tiny/package.json | 4 +- packages/light-ocr/README.md | 32 +- packages/light-ocr/package.json | 7 +- packages/light-ocr/src/index.cjs | 263 +------- packages/light-ocr/test/facade.test.cjs | 5 - packages/runtime/package.json | 2 +- packages/runtime/src/cli.cjs | 117 +--- tests/python/test_npm_release.py | 35 +- tools/npm/document-smoke.cjs | 45 ++ tools/npm/pdf-fixture.cjs | 32 + tools/npm_release.py | 51 +- 36 files changed, 1464 insertions(+), 1292 deletions(-) delete mode 100644 bindings/node/test/document-integration.test.cjs create mode 100644 docs/releases/npm-0.5.4.md create mode 100644 packages/light-ocr-document/README.md mode change 100644 => 100755 packages/light-ocr-document/src/cli.cjs create mode 100644 packages/light-ocr-document/test/cli.test.cjs create mode 100644 packages/light-ocr-document/test/types.ts create mode 100644 tools/npm/document-smoke.cjs create mode 100644 tools/npm/pdf-fixture.cjs diff --git a/.agents/skills/local-ocr/SKILL.md b/.agents/skills/local-ocr/SKILL.md index bf13279..f5263ed 100644 --- a/.agents/skills/local-ocr/SKILL.md +++ b/.agents/skills/local-ocr/SKILL.md @@ -75,29 +75,29 @@ 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-document report.pdf --format json # Page range with streaming JSONL -light-ocr document report.pdf --pages 1-10 --format jsonl +light-ocr-document 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 doctor --json +light-ocr-document info ``` -PDF rendering uses `pdfium-native` (optional dependency). If unavailable, use image-only workflows or install `pdfium-native` separately. +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. ### System diagnostics -Need to check hardware, execution providers, or PDF support status. +Need to check hardware or execution provider status. ```bash light-ocr doctor --json ``` -Returns system info (Node.js version, OS, CPU, memory), native runtime status, available providers, and module availability. No user content is collected; hostname is SHA-256 hashed. +Returns system info (Node.js version, OS, CPU, memory), native runtime status, and available providers. No user content, hostname, username, path, or stable device identifier is collected. ## Decision flow @@ -108,7 +108,7 @@ 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? → document --format json +├── PDF or 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 d252c87..84e669d 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.4.0 + default: 0.5.4 type: string publish_to_registry: description: Publish the tested candidate to npm under next @@ -255,7 +255,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.3.tgz \ + ../dist/release/arcships-light-ocr-runtime-0.1.4.tgz \ ../dist/release/arcships-light-ocr-${RELEASE_VERSION}.tgz cp ../tools/npm/smoke.cjs . - name: Run stable Small OCR @@ -264,6 +264,13 @@ 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 + 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 preview tiers if: matrix.preview shell: bash @@ -274,8 +281,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.2.tgz \ - ../dist/release/arcships-light-ocr-medium-0.1.2.tgz + ../dist/release/arcships-light-ocr-tiny-0.1.3.tgz \ + ../dist/release/arcships-light-ocr-medium-0.1.3.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 \ @@ -323,6 +330,15 @@ jobs: --tarball-dir dist/release --phase facade --tag next + - name: Verify the published Document preview with install scripts + 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" + node ../tools/npm/document-smoke.cjs - name: Verify the published stable package offline shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index f15582a..84e3001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,29 @@ This file records user-visible changes to `light-ocr`. Published artifact detail ## [Unreleased] +## [0.5.4] - 2026-07-27 + +### Fixed + +- Restored the stable facade to image OCR plus `doctor`; the unfinished + Document surface no longer changes the default package's offline, + no-install-script contract. +- Moved PDF and multi-page OCR to the explicit + `@arcships/light-ocr-document@next` preview and its + `light-ocr-document` command. +- Added real PDF rendering, DPI-aware pixel limits, cancellation, deterministic + cleanup, standalone CLI parsing, TypeScript declarations, and release smoke + coverage for the Document preview. +- Removed the stable hostname hash from voluntary diagnostics; `doctor` reports + no hostname or stable device identifier. +- Corrected README and release records that had described the next-only 0.5.3 + candidate as a complete stable release. + ## [0.5.3] - 2026-07-26 +> Superseded next-channel candidate. It was not promoted as a complete stable +> release; see the 0.5.4 repair record. + ### Fixed - Fixed corrupted UTF-8 characters in tiny/medium package.json descriptions caused by encoding issues during version bump. diff --git a/README.md b/README.md index ed44f3d..7f97faf 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, PDF, 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 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. ## Quick start @@ -74,36 +74,33 @@ light-ocr recognize image.png --region 100,80,640,320 --format json # Engine info light-ocr info --version -# Process PDF or multiple images -light-ocr document report.pdf --format json -light-ocr document report.pdf --pages 1-5 --format jsonl -light-ocr document page1.png page2.png --format text - -# System diagnostics (hardware, providers, PDF support) +# System diagnostics (hardware and providers) light-ocr doctor --json ``` -Five subcommands: `recognize` (default), `detect` (boxes only), `document` (PDF/multi-page), `info` (version diagnostics), `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. +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. ### PDF and multi-page documents -`light-ocr document` processes PDF files and multiple images in one call. PDF rendering uses [pdfium-native](https://www.npmjs.com/package/pdfium-native) (an optional dependency that auto-installs on supported platforms). If PDFium is unavailable, image-only document workflows still work. +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. ```bash +npm install @arcships/light-ocr-document@next + # Single PDF with default 150 DPI -light-ocr document report.pdf +light-ocr-document report.pdf # Page range with streaming JSONL output -light-ocr document report.pdf --pages 1-10 --format jsonl +light-ocr-document 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"; +import { recognizeDocument } from "@arcships/light-ocr-document"; // Stream pages from a PDF for await (const page of recognizeDocument("report.pdf", { dpi: 200 })) { @@ -127,9 +124,9 @@ An [Agent Skill](.agents/skills/local-ocr/SKILL.md) is included for AI agents th ## What you get -- **Local processing.** Images, PDFs, and OCR results stay on your machine. +- **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. -- **PDF and multi-page support.** Process PDFs and multiple images with streaming output. +- **Opt-in document support.** The separate Document preview processes PDFs and multiple images with streaming output. - **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. @@ -150,7 +147,7 @@ The npm package provides the following six builds. The default `createEngine()` | Windows x64 | WebGPU through D3D12, then CPU | | Windows arm64 | CPU | -Applications that need explicit control can choose `auto`, `cpu`, `apple`, or `webgpu` through the [`execution` option](bindings/node/README.md#使用). +Applications that need explicit control can choose `auto`, `cpu`, `apple`, or `webgpu` through the [`execution` option](bindings/node/README.md#with-options). ## Measured performance diff --git a/README.zh-CN.md b/README.zh-CN.md index 03e9568..3c2c5cc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,7 @@ **面向 Node.js 与 C++ 的快速离线 OCR。** -直接在本机识别 JPEG、PNG、PDF 或像素数据,返回按阅读顺序排列的文字、置信度和四边形坐标。Node.js 用户安装的 npm 包内置 PP-OCRv6 Small 模型,并提供 macOS、Linux 和 Windows 的预编译组件。 +直接在本机识别 JPEG、PNG 或像素数据,返回按阅读顺序排列的文字、置信度和四边形坐标。Node.js 用户安装的 npm 包内置 PP-OCRv6 Small 模型,并提供 macOS、Linux 和 Windows 的预编译组件。 ## 快速开始 @@ -72,36 +72,33 @@ light-ocr recognize image.png --region 100,80,640,320 --format json # 引擎信息 light-ocr info --version -# 处理 PDF 或多页图片 -light-ocr document report.pdf --format json -light-ocr document report.pdf --pages 1-5 --format jsonl -light-ocr document page1.png page2.png --format text - -# 系统诊断(硬件、加速器、PDF 支持) +# 系统诊断(硬件与加速器) light-ocr doctor --json ``` -五个子命令:`recognize`(默认)、`detect`(只检测框)、`document`(PDF/多页)、`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`(系统诊断)。输出使用 `schemaVersion: 1` 版本化 envelope,带稳定 line/detection ID。EXIF 方向自动修正。完整参考见 [CLI 设计](docs/cli-design.md) 和 [npm README](bindings/node/README.md#cli)。 ### PDF 和多页文档 -`light-ocr document` 一次调用处理 PDF 文件和多张图片。PDF 渲染使用 [pdfium-native](https://www.npmjs.com/package/pdfium-native)(可选依赖,支持平台会自动安装)。如果 PDFium 不可用,仅图片的文档流程仍然可用。 +PDF 和多页 OCR 位于显式安装的 Preview 包中,稳定默认包因此继续保持无安装脚本、可离线安装的依赖闭包。安装 Preview 时会运行 `pdfium-native` 的已校验 prebuild 安装器;实际 PDF 处理仍完全在本机完成。 ```bash +npm install @arcships/light-ocr-document@next + # 单个 PDF,默认 150 DPI -light-ocr document report.pdf +light-ocr-document report.pdf # 指定页码范围,流式 JSONL 输出 -light-ocr document report.pdf --pages 1-10 --format jsonl +light-ocr-document 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"; +import { recognizeDocument } from "@arcships/light-ocr-document"; // 从 PDF 流式获取页面 for await (const page of recognizeDocument("report.pdf", { dpi: 200 })) { @@ -125,9 +122,9 @@ for await (const page of recognizeDocument([buf1, buf2, buf3])) { ## 主要能力 -- **本地处理。**图片、PDF 和 OCR 结果始终留在本机。 +- **本地处理。**图片和 OCR 结果始终留在本机;显式安装的 Document Preview 也只在本机处理 PDF。 - **只需安装一个包。**模型和当前平台的预编译组件会随 npm 包一起安装。 -- **PDF 和多页支持。**处理 PDF 和多张图片,流式输出结果。 +- **按需安装文档能力。**独立 Document Preview 支持 PDF、多张图片和流式输出。 - **直接得到可用结果。**每一行都包含识别文字、置信度和原图位置。 - **默认使用硬件加速。**Auto 在 macOS 15+ Apple Silicon 上优先使用 Core ML,在下表的 Linux 和 Windows 版本中优先使用 WebGPU。 - **适合应用内调用。**识别任务在 JavaScript 主线程之外执行,并支持队列、取消和明确释放资源。 diff --git a/VERSION b/VERSION index be14282..7d85683 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.3 +0.5.4 diff --git a/bindings/node/README.md b/bindings/node/README.md index 8a6468e..df67395 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -26,6 +26,9 @@ light-ocr image.png --format text # Engine info light-ocr info --version light-ocr info --model-info + +# Voluntary system/provider diagnostics +light-ocr doctor --json ``` ### Subcommands @@ -35,6 +38,7 @@ light-ocr info --model-info | `recognize [flags]` | Full OCR: detection + recognition (default) | | `detect [flags]` | Detection only: text region boxes, no recognition | | `info --model-info \| --version` | Engine/version diagnostics, no image read | +| `doctor [--json]` | System/provider diagnostics; never reports a hostname or stable device ID | `light-ocr image.png` without a subcommand is implicit `recognize`. diff --git a/bindings/node/test/cli.test.cjs b/bindings/node/test/cli.test.cjs index 33d4411..d7a1591 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.0'); + assert.equal(info.core, '0.5.4'); assert.equal(info.tier, 'small'); assert.equal(info.maturity, 'stable'); assert.equal(info.model, 'ppocrv6-small-native-20260719.1'); @@ -464,7 +464,7 @@ test('doctor: modules section reports runtime and model availability', async () const result = JSON.parse(stdout); assert.equal(typeof result.modules.runtime, 'boolean'); assert.equal(typeof result.modules.model, 'boolean'); - assert.equal(typeof result.modules.pdfium, 'boolean'); + assert.equal('pdfium' in result.modules, false); }); test('doctor: native section has status field', async () => { @@ -506,59 +506,14 @@ test('doctor: top-level help includes doctor subcommand', async () => { assert.match(stdout, /doctor/); }); -test('doctor: hostHash is 16-char hex and privacy-safe', async () => { +test('doctor: omits hostname-derived stable identifiers', async () => { const { stdout } = await runCli(['doctor']); const result = JSON.parse(stdout); - assert.match(result.system.hostHash, /^[a-f0-9]{16}$/, - 'hostHash should be 16-char hex (privacy-safe hash)'); + assert.equal(Object.hasOwn(result.system, 'hostHash'), false); + assert.equal(Object.hasOwn(result.system, 'hostname'), false); }); -// --- document subcommand tests --- - -test('help: document subcommand prints flags', async () => { - const { code, stdout } = await runCli(['document', '--help']); - assert.equal(code, EXIT.success); - assert.match(stdout, /light-ocr document — process PDF or multiple images/); - assert.match(stdout, /--format json\|jsonl\|text/); - assert.match(stdout, /--pages N-M/); - assert.match(stdout, /--dpi /); - assert.match(stdout, /--max-pages /); - assert.match(stdout, /--quiet/); - assert.match(stdout, /--provider/); -}); - -test('help: top-level help includes document subcommand', async () => { +test('main CLI keeps document work in the independent preview command', async () => { const { stdout } = await runCli(['--help']); - assert.match(stdout, /document.*Process PDF or multiple images/); -}); - -test('document: rejects no arguments exit 64', async () => { - const { code, stderr } = await runCli(['document']); - assert.equal(code, EXIT.usage); - assert.match(stderr, /expected a PDF or image file path/); -}); - -test('document: rejects invalid --format exit 65', async () => { - const { code, stderr } = await runCli(['document', 'x.png', '--format', 'csv']); - assert.equal(code, EXIT.invalid_argument); - assert.match(stderr, /unsupported --format/); -}); - -test('document: rejects invalid --pages exit 65', async () => { - const { code, stderr } = await runCli(['document', 'x.png', '--pages', 'abc']); - assert.equal(code, EXIT.invalid_argument); - assert.match(stderr, /--pages expects N or N-M/); -}); - -test('document: rejects unknown subcommand before file-not-found', async () => { - // document is a valid subcommand, so it processes its args - // invalid --format should be caught before file-not-found - const { code } = await runCli(['document', 'x.png', '--format', 'csv']); - assert.equal(code, EXIT.invalid_argument); -}); - -test('document: pdfium status reported in doctor', async () => { - const { stdout } = await runCli(['doctor', '--json']); - const result = JSON.parse(stdout); - assert.equal(typeof result.modules.pdfium, 'boolean'); + assert.doesNotMatch(stdout, /document/); }); diff --git a/bindings/node/test/document-integration.test.cjs b/bindings/node/test/document-integration.test.cjs deleted file mode 100644 index c89b6fa..0000000 --- a/bindings/node/test/document-integration.test.cjs +++ /dev/null @@ -1,84 +0,0 @@ -'use strict'; - -// Integration tests for document/OCR functionality. -// These tests require the native runtime and model bundle. -// Separated from cli.test.cjs because they are slower (~1.7s per test). - -const assert = require('node:assert/strict'); -const test = require('node:test'); -const path = require('node:path'); -const fs = require('node:fs'); -const os = require('node:os'); - -const FIXTURE = path.resolve(__dirname, '../../../packages/light-ocr-server/test/fixtures/hello-123.png'); - -// Test the recognizeDocument API directly (no stdout capture issues) -test('recognizeDocument: processes single image buffer', async () => { - const facade = require('../../../packages/light-ocr/src/index.cjs'); - const imageBuffer = fs.readFileSync(FIXTURE); - const pages = []; - for await (const page of facade.recognizeDocument(imageBuffer, { engine: undefined })) { - pages.push(page); - } - assert.equal(pages.length, 1); - assert.equal(pages[0].index, 0); - assert.equal(pages[0].coordinateSpace, 'pageSpace'); - assert.equal(pages[0].structure, 'ocr-order'); - assert.ok(pages[0].lines.length > 0); - assert.equal(pages[0].lines[0].text, 'HELLO 123'); - assert.ok(pages[0].lines[0].confidence > 0.5); - assert.equal(pages[0].lines[0].box.length, 4); - assert.equal(pages[0].source.kind, 'image'); - assert.equal(typeof pages[0].timingUs.total, 'number'); - assert.equal(typeof pages[0].modelBundleId, 'string'); -}); - -test('recognizeDocument: processes file path', async () => { - const facade = require('../../../packages/light-ocr/src/index.cjs'); - const pages = []; - for await (const page of facade.recognizeDocument(FIXTURE)) { - pages.push(page); - } - assert.equal(pages.length, 1); - assert.equal(pages[0].lines[0].text, 'HELLO 123'); -}); - -test('recognizeDocument: processes multiple images', async () => { - const facade = require('../../../packages/light-ocr/src/index.cjs'); - const pages = []; - for await (const page of facade.recognizeDocument([FIXTURE, FIXTURE])) { - pages.push(page); - } - assert.equal(pages.length, 2); - assert.equal(pages[0].index, 0); - assert.equal(pages[1].index, 1); - assert.equal(pages[0].lines[0].text, 'HELLO 123'); - assert.equal(pages[1].lines[0].text, 'HELLO 123'); -}); - -test('recognizeDocument: line IDs are stable', async () => { - const facade = require('../../../packages/light-ocr/src/index.cjs'); - const pages = []; - for await (const page of facade.recognizeDocument(FIXTURE)) { - pages.push(page); - } - const line = pages[0].lines[0]; - assert.match(line.id, /^L\d+$/); -}); - -test('recognizeDocument: hasPdfSupport returns boolean', () => { - const facade = require('../../../packages/light-ocr/src/index.cjs'); - assert.equal(typeof facade.hasPdfSupport, 'function'); - const supported = facade.hasPdfSupport(); - assert.equal(typeof supported, 'boolean'); -}); - -test('recognizeDocument: rejects nonexistent file', async () => { - const facade = require('../../../packages/light-ocr/src/index.cjs'); - await assert.rejects( - async () => { - for await (const _ of facade.recognizeDocument('nonexistent.png')) { /* drain */ } - }, - (err) => err.code === 'ENOENT', - ); -}); diff --git a/docs/decisions.md b/docs/decisions.md index 29f011f..f6951c0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -296,6 +296,36 @@ Consequence: - 单一维护者风险,必要时可 fork 或切换到 clawpdf WASM 备选 - Spike 阶段需验证 PDFium 版本是否覆盖目标 PDF 特性(加密、表单、嵌入图片等) +### D109 — Keep Document preview outside the stable image facade + +Status: Accepted
+Authority: N3 package topology, D010 offline installation contract, D108 renderer selection + +Decision: PDF and multi-page processing ships as the explicit +`@arcships/light-ocr-document` preview package with its own +`light-ocr-document` command. The stable `@arcships/light-ocr` facade remains +image-only and keeps its no-install-script, offline-installable release closure. + +Reason: `pdfium-native@0.6.1` uses an install script to download a +checksum-verified platform prebuild and falls back to a local compilation when +needed. Making it an optional dependency of the stable facade would still run +that script during a normal install and would contradict the stable package's +published installation contract. A separate package also matches the topology +already locked in the roadmap and CLI design. + +Consequence: + +- Document depends exactly on the current stable Small facade for its + out-of-box CLI, while its Node API can reuse a caller-provided compatible + engine. +- The Document package is published only to `next` until G3 evidence is met; it + is not promoted with the native/runtime/Small stable closure. +- Release gates keep the stable closure offline with scripts disabled, and add + a separate supported-platform smoke that installs the Document package with + scripts enabled, renders a real PDF, and runs OCR. +- PDF processing is local at runtime. Installation-time prebuild retrieval is + disclosed rather than described as offline. + ## 3. Deferred decisions ### D102 — Public native SDK and ABI policy diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 8162eb1..4fb910f 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,7 +1,7 @@ # C++ Core 与 Node-API 实施状态 -更新时间:2026-07-26
-结论:npm `0.5.3` 已发布。N3 文档入口完成并发布,包含 PDF 渲染(pdfium-native)、`light-ocr document` CLI 子命令、`light-ocr doctor` 系统诊断命令、CPU 性能 baseline 脚本。S3 PDF 可行性 Spike 决策选择 `pdfium-native`(D108)。 +更新时间:2026-07-27
+结论:npm `0.5.3` 仅形成 `next` 候选,没有完成 stable promotion,且其 N3 实现与发布证据不完整。`0.5.4` 正在修复发布边界:stable `@arcships/light-ocr` 保持图片 OCR 与 `doctor`;PDF/多页能力按 roadmap 拆到显式 `@arcships/light-ocr-document` Preview,并增加真实 PDF render + OCR release gate。S3 renderer 继续采用 `pdfium-native`(D108),包拓扑由 D109 锁定。 状态含义: diff --git a/docs/releases/npm-0.5.3.md b/docs/releases/npm-0.5.3.md index 5c8cb09..61fb2d0 100644 --- a/docs/releases/npm-0.5.3.md +++ b/docs/releases/npm-0.5.3.md @@ -1,99 +1,23 @@ -# npm 0.5.3 发布记录 +# npm 0.5.3 候选记录 -状态:已发布 +状态:已废弃,未完成 stable promotion
日期:2026-07-26 -## 发布身份 +`0.5.3` 是一次发布到 `next` 的 N3 候选,不是完整的 stable release。其 +Small/runtime/native 候选制品已经进入 npm registry,因此这些版本号不可复用; +但 stable `latest` 依赖闭包仍停留在 Small `0.4.0`、runtime `0.1.0` 和 native +`0.4.0`。 -- 版本:`0.5.3` -- 变更类型:Minor(新功能) -- 关联:N3 文档入口(roadmap §8) +该候选存在以下阻塞问题: -## 范围 +- README 将默认安装得到的 `0.4.0` 描述成尚未 promoted 的 `0.5.3`; +- PDF 能力被并入 stable facade,违反默认包无安装脚本、可离线安装的合同; +- 发布 smoke 使用 `--ignore-scripts`,没有安装 PDF renderer,也没有渲染真实 PDF; +- Document integration tests 是空占位; +- GitHub Release 被提前标记为 Latest,正文也错误宣称已经完成 promotion; +- `doctor` 输出稳定 hostname hash,不符合 roadmap 的无稳定设备标识要求。 -`0.5.3` 完成 N3 文档入口功能并发布到 npm。Small facade 新增 `recognizeDocument()` API -和 `light-ocr document` CLI 子命令,支持 PDF 和多页图片的统一文档 OCR。PDF -渲染通过 `pdfium-native`(lazy-loaded、optional)实现;当 PDFium 不可用时 -`hasPdfSupport()` 返回 `false`,API 以明确的 `unsupported_capability` 错误 -降级,不影响已有的单图 `recognize` 路径。 +修复由 `0.5.4` 承接。`0.5.0`–`0.5.3` 的 registry bytes 保持不可变,不覆盖、 +不重发;GitHub Release 改为 prerelease/superseded,以免继续误导用户。 -| 角色 | package/version | 变化 | channel | -| --- | --- | --- | --- | -| Stable facade | `@arcships/light-ocr@0.5.3` | + `recognizeDocument` / `hasPdfSupport` / `document` CLI / `doctor` CLI | `next` → `latest` | -| Shared runtime | `@arcships/light-ocr-runtime@0.1.3` | + `loadNative` 导出 | `next` → `latest` | -| Native runtime | 六个 platform packages `0.5.3` | 无变化 | `next` → `latest` | -| Small model | `@arcships/light-ocr-model-ppocrv6-small@0.3.4` | 无变化 | 不变 | -| Tiny facade | `@arcships/light-ocr-tiny@0.1.2` | 无功能变化 | `next` | -| Medium facade | `@arcships/light-ocr-medium@0.1.2` | 无功能变化 | `next` | - -## N3 功能详情 - -### `recognizeDocument()` API - -```js -const { createEngine, recognizeDocument, hasPdfSupport } = require('@arcships/light-ocr'); - -// 单个 PDF 文件 -for await (const page of recognizeDocument('/path/to/file.pdf', { dpi: 200 })) { - console.log(page.index, page.lines.length, page.source.kind); -} - -// 多页图片 -for await (const page of recognizeDocument([buf1, buf2, buf3])) { - console.log(page.index, page.lines); -} - -// 手动 engine 注入(避免重复初始化) -const engine = await createEngine(); -for await (const page of recognizeDocument('report.pdf', { engine, pageRange: { start: 2, end: 5 } })) { - // ... -} -await engine.close(); -``` - -### `light-ocr document` CLI - -```bash -# 处理 PDF -light-ocr document report.pdf --format json - -# 指定页码范围,流式输出 -light-ocr document report.pdf --pages 1-10 --format jsonl - -# 多张图片 -light-ocr document page1.png page2.png --format text - -# 指定 DPI -light-ocr document report.pdf --dpi 300 --format json -``` - -### `light-ocr doctor` CLI - -```bash -light-ocr doctor --json -``` - -收集系统信息(Node.js 版本、OS、CPU、内存、native runtime 状态、可用 provider)。 -不收集用户内容;hostname 使用 SHA-256 哈希。 - -### PDF 资源限制 - -- `maxPages`:默认 100 -- `maxPagePixels`:默认 16,777,216(4096×4096) -- `maxTotalPixels`:默认 100,000,000 -- `maxFileBytes`:默认 100,000,000 -- 超限返回 `resource_limit_exceeded` 错误 - -## 版本历史 - -- `0.5.0`:N3 功能实现 -- `0.5.1`:修复 native package integrity 冲突 -- `0.5.2`:修复 runtime/tiny/medium integrity 冲突 -- `0.5.3`:修复 package.json 编码问题和 workflow 版本引用 - -## 关联 - -- D108: PDF renderer selection (pdfium-native) -- N3: 图片与 PDF 文档入口 -- Perf-0: 硬件覆盖审计 -- Perf-1: CPU 性能 baseline +关联决策:[D108 与 D109](../decisions.md)。 diff --git a/docs/releases/npm-0.5.4.md b/docs/releases/npm-0.5.4.md new file mode 100644 index 0000000..476ed76 --- /dev/null +++ b/docs/releases/npm-0.5.4.md @@ -0,0 +1,26 @@ +# npm 0.5.4 发布记录 + +状态:候选修复中
+日期:2026-07-27 + +## 发布边界 + +- Stable:`@arcships/light-ocr@0.5.4`、runtime `0.1.4` 与六个平台 native + `0.5.4`;Small model 继续复用不可变的 `0.3.4`。 +- Preview:Tiny/Medium facade `0.1.3` 以及 + `@arcships/light-ocr-document@0.1.0` 只发布到 `next`。 +- Document Preview 精确依赖 Small `0.5.4` 和 `pdfium-native@0.6.1`。 + +## 必须通过的门禁 + +- [ ] Workspace Node tests 与 TypeScript declarations +- [ ] Python release/package contract tests +- [ ] 六个平台 stable closure 离线、禁脚本安装与真实 OCR +- [ ] 六个平台 Document Preview 启用安装脚本、真实 PDF render + OCR +- [ ] npm tarball README、bin、依赖版本与 release manifest 审计 +- [ ] `next` 发布后按 integrity 验证 +- [ ] stable closure promotion 后逐包验证 `latest` +- [ ] GitHub `v0.5.4` Release 仅在 promotion 完成后标记 Latest + +远端 workflow run、tarball integrity 和 promotion 证据在实际完成后填写;在此 +之前不得将本记录标记为“已发布”。 diff --git a/package-lock.json b/package-lock.json index c2f73c7..09ba2dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,6 +49,10 @@ "node": "^22.0.0 || ^24.0.0" } }, + "node_modules/@arcships/light-ocr-document": { + "resolved": "packages/light-ocr-document", + "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", @@ -772,6 +776,15 @@ "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", @@ -805,6 +818,21 @@ "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", @@ -824,6 +852,31 @@ "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", @@ -1183,13 +1236,25 @@ "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.4.0", + "version": "0.5.4", "license": "Apache-2.0", "dependencies": { "@arcships/light-ocr-model-ppocrv6-small": "0.3.4", - "@arcships/light-ocr-runtime": "0.1.0" + "@arcships/light-ocr-runtime": "0.1.4" }, "bin": { "light-ocr": "src/cli.cjs" @@ -1198,13 +1263,28 @@ "node": "^22.0.0 || ^24.0.0" } }, + "packages/light-ocr-document": { + "name": "@arcships/light-ocr-document", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@arcships/light-ocr": "0.5.4", + "pdfium-native": "0.6.1" + }, + "bin": { + "light-ocr-document": "src/cli.cjs" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, "packages/light-ocr-medium": { "name": "@arcships/light-ocr-medium", - "version": "0.1.0", + "version": "0.1.3", "license": "Apache-2.0", "dependencies": { "@arcships/light-ocr-model-ppocrv6-medium": "0.1.0", - "@arcships/light-ocr-runtime": "0.1.0" + "@arcships/light-ocr-runtime": "0.1.4" }, "bin": { "light-ocr-medium": "src/cli.cjs" @@ -1226,13 +1306,142 @@ "node": "^22.0.0 || ^24.0.0" } }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr/-/light-ocr-0.4.0.tgz", + "integrity": "sha512-NWj3MN/Rj/z7ZYJZtUBK6ZPoQ5R1PA1BCxkDkotpJ0M9H/P9Z2RJBirJAw22xZuJGME94TVmTboGdzFrn9/RJA==", + "license": "Apache-2.0", + "dependencies": { + "@arcships/light-ocr-model-ppocrv6-small": "0.3.4", + "@arcships/light-ocr-runtime": "0.1.0" + }, + "bin": { + "light-ocr": "src/cli.cjs" + }, + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-darwin-arm64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-darwin-arm64/-/light-ocr-darwin-arm64-0.4.0.tgz", + "integrity": "sha512-1DqlCbjUK9otCuhyqOO03bj+bu10zLtxHefuBt04S8nFFxrdW9qhSghMMU/Q2HKafpuTdMZSkYhErqDss9DPfA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-darwin-x64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-darwin-x64/-/light-ocr-darwin-x64-0.4.0.tgz", + "integrity": "sha512-wpmtpSduUM4hvVReo3fQlMUu7ox5m+gMBJ5btV2pmrXopl49Sl+TwhjQ7BzJN7RaY0fmiPzgwFZ0I89eUNp2kA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-linux-arm64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-linux-arm64-gnu/-/light-ocr-linux-arm64-gnu-0.4.0.tgz", + "integrity": "sha512-oCPS5s+S8oaOas9HCIkVt4obTM9thircRrHi4nDpOMiEM3WQs0xu9jQrMRvEZTuhDSu/iFq0YVw4S6bt2UTEOw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-linux-x64-gnu": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-linux-x64-gnu/-/light-ocr-linux-x64-gnu-0.4.0.tgz", + "integrity": "sha512-QIUiGXp/T6Rm4tlYHC+qqkrAPhJRV6Mxbayff2A08ZQ+pizwDdoNX2W4CRCcebiljVmfIGJzAJvVpO7LSuCk/w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-runtime": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-runtime/-/light-ocr-runtime-0.1.0.tgz", + "integrity": "sha512-WNBG0SL6EmYnp9JCuxRD6XyX0wD2G0rseYjXn7iGms5XSy/RkI1UAMDnPe7rRtHXO6wRPug+oNR2s5Epan5JCQ==", + "license": "Apache-2.0", + "engines": { + "node": "^22.0.0 || ^24.0.0" + }, + "optionalDependencies": { + "@arcships/light-ocr-darwin-arm64": "0.4.0", + "@arcships/light-ocr-darwin-x64": "0.4.0", + "@arcships/light-ocr-linux-arm64-gnu": "0.4.0", + "@arcships/light-ocr-linux-x64-gnu": "0.4.0", + "@arcships/light-ocr-win32-arm64": "0.4.0", + "@arcships/light-ocr-win32-x64": "0.4.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-win32-arm64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-arm64/-/light-ocr-win32-arm64-0.4.0.tgz", + "integrity": "sha512-Jhr8evCFA8G8bHxWRprMmI4q5dHrGLmv9nVZrvtE/LZgnUDJwDoOOMzJGfjyUaUWYPdhqank3ykSwwMDlD5Rtw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, + "packages/light-ocr-server/node_modules/@arcships/light-ocr-win32-x64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@arcships/light-ocr-win32-x64/-/light-ocr-win32-x64-0.4.0.tgz", + "integrity": "sha512-1YBxUH2gHBAXRr21P/lWQ2YAkIL9+jLBfL0AemCmz50HgsetPui5oAG/LHVER/XY/28DaA0hs6SVGaOz8TiPwA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.0.0 || ^24.0.0" + } + }, "packages/light-ocr-tiny": { "name": "@arcships/light-ocr-tiny", - "version": "0.1.0", + "version": "0.1.3", "license": "Apache-2.0", "dependencies": { "@arcships/light-ocr-model-ppocrv6-tiny": "0.1.0", - "@arcships/light-ocr-runtime": "0.1.0" + "@arcships/light-ocr-runtime": "0.1.4" }, "bin": { "light-ocr-tiny": "src/cli.cjs" @@ -1253,18 +1462,18 @@ }, "packages/runtime": { "name": "@arcships/light-ocr-runtime", - "version": "0.1.0", + "version": "0.1.4", "license": "Apache-2.0", "engines": { "node": "^22.0.0 || ^24.0.0" }, "optionalDependencies": { - "@arcships/light-ocr-darwin-arm64": "0.4.0", - "@arcships/light-ocr-darwin-x64": "0.4.0", - "@arcships/light-ocr-linux-arm64-gnu": "0.4.0", - "@arcships/light-ocr-linux-x64-gnu": "0.4.0", - "@arcships/light-ocr-win32-arm64": "0.4.0", - "@arcships/light-ocr-win32-x64": "0.4.0" + "@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" } } } diff --git a/package.json b/package.json index 6b09b2c..44fb071 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "scripts": { "test": "npm test --workspaces --if-present", "test:workspace": "npm test --workspace @arcships/light-ocr-runtime && npm test --workspace @arcships/light-ocr && npm run test:contract --workspace @arcships/light-ocr-server", - "typecheck": "tsc --strict --noEmit --skipLibCheck false --target ES2022 --module NodeNext --moduleResolution NodeNext --types node tools/npm/smoke.ts packages/runtime/test/types.ts packages/light-ocr-tiny/test/types.ts packages/light-ocr-medium/test/types.ts" + "typecheck": "tsc --strict --noEmit --skipLibCheck false --target ES2022 --module NodeNext --moduleResolution NodeNext --types node tools/npm/smoke.ts packages/runtime/test/types.ts packages/light-ocr-tiny/test/types.ts packages/light-ocr-medium/test/types.ts packages/light-ocr-document/test/types.ts" }, "devDependencies": { "@types/node": "22.20.1", diff --git a/packages/light-ocr-document/README.md b/packages/light-ocr-document/README.md new file mode 100644 index 0000000..a8af0fd --- /dev/null +++ b/packages/light-ocr-document/README.md @@ -0,0 +1,48 @@ +# @arcships/light-ocr-document + +Preview PDF and multi-page image OCR for Node.js 22 and 24. + +```bash +npm install @arcships/light-ocr-document@next +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. + +```js +const { recognizeDocument } = require('@arcships/light-ocr-document'); + +for await (const page of recognizeDocument('report.pdf', { + dpi: 150, + pageRange: { start: 1, end: 10 }, + maxPages: 100, +})) { + console.log(page.index, page.lines); +} +``` + +For repeated work, create one engine and close it explicitly: + +```js +const { createDocumentEngine } = require('@arcships/light-ocr-document'); + +const engine = await createDocumentEngine(); +try { + for await (const page of engine.recognizeDocument(['one.png', 'two.jpg'])) { + console.log(page); + } +} finally { + await engine.close(); +} +``` + +Default limits are 100 pages, 100 MiB per input, 4096 × 4096 rendered pixels +per page, 100 Mi rendered pixels per request, and 150 DPI. Limits can only be +raised explicitly. The CLI returns stable non-zero exit codes for invalid +arguments, unsupported capability, resource limits, package loading, inference, +and internal failures. diff --git a/packages/light-ocr-document/package.json b/packages/light-ocr-document/package.json index 22975a6..6568777 100644 --- a/packages/light-ocr-document/package.json +++ b/packages/light-ocr-document/package.json @@ -1,6 +1,7 @@ { "name": "@arcships/light-ocr-document", "version": "0.1.0", + "private": true, "description": "Document entry for light-ocr — PDF and multi-page image processing", "license": "Apache-2.0", "repository": { @@ -32,20 +33,9 @@ "node": "^22.0.0 || ^24.0.0" }, "dependencies": { + "@arcships/light-ocr": "0.5.4", "pdfium-native": "0.6.1" }, - "peerDependencies": { - "@arcships/light-ocr-runtime": "^0.1.3", - "@arcships/light-ocr-model-ppocrv6-small": "^0.3.4" - }, - "peerDependenciesMeta": { - "@arcships/light-ocr-runtime": { - "optional": true - }, - "@arcships/light-ocr-model-ppocrv6-small": { - "optional": true - } - }, "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 old mode 100644 new mode 100755 index d850def..6dcbf08 --- a/packages/light-ocr-document/src/cli.cjs +++ b/packages/light-ocr-document/src/cli.cjs @@ -1,192 +1,220 @@ #!/usr/bin/env node 'use strict'; -const { createDocumentEngine, getVersion, hasPdfSupport, OcrError } = require('./index.cjs'); +const { + createDocumentEngine, + getVersion, + hasPdfSupport, + OcrError, +} = require('./index.cjs'); -const USAGE = ` -light-ocr-document - PDF and multi-page image OCR +const USAGE = `light-ocr-document - local PDF and multi-page image OCR (preview) Usage: - light-ocr-document [options] - -Commands: - recognize Process a PDF or image file(s) - info Show engine and PDF support info - help Show this help + light-ocr-document [recognize] [options] + light-ocr-document info + light-ocr-document help Options: - --format Output format: json, jsonl, text, markdown (default: json) - --pages Page range, e.g., 1-5 or 3 (PDF only) - --dpi PDF raster DPI (default: 150) - --max-pages Maximum pages to process (default: 100) - --max-file-bytes Maximum file size in bytes (default: 104857600) - --quiet Suppress progress output - --version Show version + --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`; -Examples: - light-ocr-document recognize document.pdf - light-ocr-document recognize document.pdf --format jsonl --pages 1-10 - light-ocr-document recognize image1.png image2.jpg --format text - light-ocr-document info -`.trim(); +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, +}); -async function main() { - const args = process.argv.slice(2); - - if (args.length === 0 || args.includes('--help') || args.includes('-h') || args.includes('help')) { - console.log(USAGE); - process.exit(0); - } - - if (args.includes('--version') || args.includes('-v')) { - console.log(getVersion()); - process.exit(0); +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`); } - - const command = args[0]; - - if (command === 'info') { - console.log(`light-ocr-document v${getVersion()}`); - console.log(`PDF support: ${hasPdfSupport() ? 'yes' : 'no (install pdfium-native)'}`); - process.exit(0); + return value; +} + +function parseInteger(value, flag) { + if (!/^[1-9]\d*$/.test(value)) { + throw argumentError(`${flag} must be a positive integer`); } - - if (command !== 'recognize') { - console.error(`Unknown command: ${command}`); - console.error('Run "light-ocr-document help" for usage'); - process.exit(64); + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw argumentError(`${flag} is too large`); } - - // Parse options - const options = {}; + return parsed; +} + +function parseArgs(argv) { + const args = [...argv]; + if (args[0] === 'recognize') args.shift(); const sources = []; - - for (let i = 1; i < args.length; i++) { - const arg = args[i]; - - if (arg === '--format' && i + 1 < args.length) { - options.format = args[++i]; - } else if (arg === '--pages' && i + 1 < args.length) { - const range = args[++i]; - const match = range.match(/^(\d+)(?:-(\d+))?$/); - if (match) { - options.pageRange = { - start: parseInt(match[1]), - end: match[2] ? parseInt(match[2]) : parseInt(match[1]) - }; - } else { - console.error(`Invalid page range: ${range}`); - process.exit(65); + 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 === '--dpi' && i + 1 < args.length) { - options.dpi = parseInt(args[++i]); - } else if (arg === '--max-pages' && i + 1 < args.length) { - options.maxPages = parseInt(args[++i]); - } else if (arg === '--max-file-bytes' && i + 1 < args.length) { - options.maxFileBytes = parseInt(args[++i]); - } else if (arg === '--quiet') { - options.quiet = true; - } else if (!arg.startsWith('-')) { + } else if (arg.startsWith('-')) { + throw argumentError(`unknown option: ${arg}`); + } else { sources.push(arg); } } - - if (sources.length === 0) { - console.error('No source files specified'); - process.exit(65); + 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; } - - // Create engine - let engine; - try { - engine = await createDocumentEngine(); - } catch (err) { - console.error(`Failed to create engine: ${err.message}`); - process.exit(70); + 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 { - const format = options.format || 'json'; - const pages = []; - - // Process documents - const source = sources.length === 1 ? sources[0] : sources; - - for await (const page of engine.recognizeDocument(source, options)) { - pages.push(page); - - // Output JSONL as we go - if (format === 'jsonl') { - console.log(JSON.stringify(page)); - } - - // Progress output - if (!options.quiet) { - process.stderr.write(`\rProcessed page ${pages.length}...`); + 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 (!options.quiet) { - process.stderr.write('\n'); - } - - // Output final result for non-JSONL formats - if (format === 'json') { - const result = { + if (!parsed.quiet) writeLine(io.stderr); + if (pages) { + writeLine(io.stdout, JSON.stringify({ schemaVersion: 1, source: { - kind: sources.some(s => s.endsWith('.pdf')) ? 'pdf' : 'page-images', - mediaType: sources.some(s => s.endsWith('.pdf')) ? 'application/pdf' : 'image/*', - identity: { files: sources }, - pageCount: pages.length + kind: pages[0]?.source.kind === 'pdf' ? 'pdf' : 'page-images', + mediaType: pages[0]?.source.mediaType ?? 'application/octet-stream', + identity: {}, + pageCount: pages.length, }, - pages - }; - console.log(JSON.stringify(result, null, 2)); - } else if (format === 'text') { - for (const page of pages) { - console.log(page.lines.map(l => l.text).join('\n')); - } - } else if (format === 'markdown') { - console.log('# Document OCR Result\n'); - for (const page of pages) { - console.log(`## Page ${page.index + 1}\n`); - console.log(page.lines.map(l => l.text).join('\n\n')); - console.log(''); - } + pages, + }, null, 2)); } - - process.exit(0); - } catch (err) { - if (err instanceof OcrError) { - console.error(`OCR Error: ${err.code} - ${err.message}`); - if (err.detail) { - console.error(`Detail: ${err.detail}`); - } - - // Map error codes to exit codes - const exitCodeMap = { - '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 - }; - - process.exit(exitCodeMap[err.code] || 72); + return 0; + } catch (error) { + if (error?.name === 'AbortError') { + writeLine(io.stderr, 'The operation was aborted'); + return 72; } - - console.error(`Error: ${err.message}`); - process.exit(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(); + await engine?.close(); } } -main().catch(err => { - console.error(err); - process.exit(72); -}); +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-document/src/index.cjs b/packages/light-ocr-document/src/index.cjs index 37f0490..8bfc9bd 100644 --- a/packages/light-ocr-document/src/index.cjs +++ b/packages/light-ocr-document/src/index.cjs @@ -1,361 +1,415 @@ 'use strict'; -// Lazy load dependencies -let createEngine = null; -let pdfium = null; +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, +}); + +let pdfium; let pdfiumLoaded = false; -let fs = null; -let path = null; -function loadDependencies() { - if (!createEngine) { - try { - ({ createEngine } = require('@arcships/light-ocr-runtime')); - } catch { - // Runtime not available - will be provided via engine option - } - } - +function loadPdfium() { if (!pdfiumLoaded) { pdfiumLoaded = true; try { pdfium = require('pdfium-native'); } catch { - // pdfium-native not available - pdfium = null; + pdfium = undefined; } } - - if (!fs) { - fs = require('node:fs/promises'); - } - - if (!path) { - path = require('node:path'); - } + return pdfium; +} + +function hasPdfSupport() { + return loadPdfium() !== undefined; } function getVersion() { - try { - const pkg = require('../package.json'); - return pkg.version; - } catch { - return '0.0.0'; - } + return require('../package.json').version; } -function hasPdfSupport() { - loadDependencies(); - return pdfium !== null; +function invalidArgument(message) { + return new OcrError('invalid_argument', message); } -async function* processPdf(engine, pdfBuffer, options = {}) { - loadDependencies(); - if (!pdfium) { - throw new OcrError('unsupported_capability', 'PDF support not available. Install pdfium-native.'); +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; +} - const { - format = 'json', - pageRange, - dpi = 150, - maxPages = 100, - maxPagePixels = 4096 * 4096, - maxTotalPixels = 100 * 1024 * 1024, - signal, - ocrOptions = {} - } = options; +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; +} - let totalPixels = 0; - let processedPages = 0; +function throwIfAborted(signal) { + if (signal?.aborted) { + throw signal.reason === undefined + ? new DOMException('The operation was aborted', 'AbortError') + : signal.reason; + } +} - // Open PDF document - const doc = await pdfium.loadDocument(pdfBuffer); - - try { - const pageCount = doc.pageCount; - - // Apply page range - const start = pageRange?.start ? Math.max(1, pageRange.start) : 1; - const end = pageRange?.end ? Math.min(pageCount, pageRange.end) : pageCount; - - // Check page limits - if (end - start + 1 > maxPages) { - throw new OcrError('resource_limit_exceeded', `Page count ${end - start + 1} exceeds maxPages ${maxPages}`); - } +function isBytes(value) { + return value instanceof Uint8Array; +} - for (let i = start; i <= end; i++) { - // Check abort signal - if (signal?.aborted) { - throw new OcrError('internal_error', 'Operation aborted'); - } +function isPdf(value) { + return value?.length >= 4 + && value[0] === 0x25 + && value[1] === 0x50 + && value[2] === 0x44 + && value[3] === 0x46; +} - const page = await doc.getPage(i - 1); // 0-indexed - - // Get page dimensions - const { width, height } = page; - const pagePixels = width * height; - - // Check pixel limits - if (pagePixels > maxPagePixels) { - throw new OcrError('resource_limit_exceeded', `Page ${i} pixels ${pagePixels} exceeds maxPagePixels ${maxPagePixels}`); - } - - totalPixels += pagePixels; - if (totalPixels > maxTotalPixels) { - throw new OcrError('resource_limit_exceeded', `Total pixels ${totalPixels} exceeds maxTotalPixels ${maxTotalPixels}`); - } +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'; +} - // Render page to PNG - const renderStart = Date.now(); - const scale = dpi / 72; // PDF default is 72 DPI - const pngBuffer = await page.render({ scale }); - const renderTime = (Date.now() - renderStart) * 1000; +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; +} - // OCR the rendered image - const ocrStart = Date.now(); - const ocrResult = await engine.recognizeEncoded(pngBuffer, ocrOptions); - const ocrTime = (Date.now() - ocrStart) * 1000; +function linesFrom(result) { + return result.lines.map((line, index) => ({ + id: `L${index}`, + text: line.text, + confidence: line.confidence, + box: line.box, + })); +} - // Close page to free native memory - await page.close(); +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 }; +} - processedPages++; +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}`, + ); + } - // Build page result - const pageResult = { - index: i - 1, // 0-indexed - width: ocrResult.imageWidth, - height: ocrResult.imageHeight, - coordinateSpace: 'pageSpace', - structure: 'ocr-order', - lines: ocrResult.lines.map((line, idx) => ({ - id: `L${idx}`, - text: line.text, - confidence: line.confidence, - box: line.box - })), - source: { - kind: 'pdf', - mediaType: 'application/pdf', - identity: { pageIndex: i - 1 }, - appliedTransforms: { - pdf: { - rotation: 0, // TODO: Get from PDF metadata - mediaBox: { x: 0, y: 0, width, height }, - cropBox: { x: 0, y: 0, width, height }, - dpi, - scale: dpi / 72 // PDF default is 72 DPI - } - } - }, - timingUs: { - total: renderTime + ocrTime, - decode: renderTime, - ocr: ocrTime + 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}`, + ); } - }; - yield pageResult; + 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 { - doc.destroy(); + await document.destroy(); } } -async function* processImages(engine, imageBuffers, options = {}) { - const { - format = 'json', - maxPagePixels = 4096 * 4096, - signal, - ocrOptions = {} - } = options; - - for (let i = 0; i < imageBuffers.length; i++) { - // Check abort signal - if (signal?.aborted) { - throw new OcrError('internal_error', 'Operation aborted'); - } - - const buffer = imageBuffers[i]; - - // OCR the image - const ocrStart = Date.now(); - const ocrResult = await engine.recognizeEncoded(buffer, { - ...ocrOptions, - applyExif: true +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 ocrTime = (Date.now() - ocrStart) * 1000; // Convert to microseconds - - // Build page result - const pageResult = { - index: i, - width: ocrResult.imageWidth, - height: ocrResult.imageHeight, + 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: ocrResult.lines.map((line, idx) => ({ - id: `L${idx}`, - text: line.text, - confidence: line.confidence, - box: line.box - })), + lines: linesFrom(ocr), source: { kind: 'image', - mediaType: 'image/png', // TODO: Detect actual media type - identity: { index: i }, - appliedTransforms: { - exif: { - orientation: 1, // TODO: Get from EXIF - applied: false - } - } + mediaType: mediaType(image), + identity: { index }, + appliedTransforms: {}, }, - timingUs: { - total: ocrTime, - decode: 0, - ocr: ocrTime - } + timingUs: { total: ocrUs, decode: 0, ocr: ocrUs }, + modelBundleId: ocr.modelBundleId, }; - - yield pageResult; - } -} - -class OcrError extends Error { - constructor(code, message, detail) { - super(message); - this.name = 'OcrError'; - this.code = code; - this.detail = detail; } } -class DocumentEngineImpl { - constructor(engine, pdfiumOptions = {}) { - this._engine = engine; - this._pdfiumOptions = pdfiumOptions; - this._closed = false; - } - - async *_recognizePdf(source, options = {}) { - if (this._closed) { - throw new OcrError('invalid_engine', 'Engine is closed'); - } - - let pdfBuffer; - if (typeof source === 'string') { - pdfBuffer = await fs.readFile(source); - } else { - pdfBuffer = source; - } +class DocumentEngine { + #engine; + #ownsEngine; + #closed = false; - // Check file size - const maxFileBytes = options.maxFileBytes || 100 * 1024 * 1024; // 100MB - if (pdfBuffer.byteLength > maxFileBytes) { - throw new OcrError('resource_limit_exceeded', `File size ${pdfBuffer.byteLength} exceeds maxFileBytes ${maxFileBytes}`); - } - - yield* processPdf(this._engine, pdfBuffer, options); + constructor(engine, ownsEngine) { + this.#engine = engine; + this.#ownsEngine = ownsEngine; } - async *_recognizeImages(sources, options = {}) { - if (this._closed) { - throw new OcrError('invalid_engine', 'Engine is closed'); - } - - const buffers = []; - for (const source of sources) { - if (typeof source === 'string') { - buffers.push(await fs.readFile(source)); - } else { - buffers.push(source); - } + 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; } - - yield* processImages(this._engine, buffers, options); - } - - async *_recognizeDocument(source, options = {}) { - if (this._closed) { - throw new OcrError('invalid_engine', 'Engine is closed'); + if (typeof source !== 'string' && !isBytes(source)) { + throw invalidArgument( + 'document source must be a file path, Uint8Array, or a non-empty array of them', + ); } - - // Determine source type - if (Array.isArray(source)) { - yield* this._recognizeImages(source, options); - } else if (typeof source === 'string') { - // Detect file type by extension - const ext = path.extname(source).toLowerCase(); - if (ext === '.pdf') { - yield* this._recognizePdf(source, options); - } else { - // Treat as single image - yield* this._recognizeImages([source], options); - } - } else { - // Buffer - try to detect PDF magic bytes - const isPdf = source[0] === 0x25 && source[1] === 0x50 && source[2] === 0x44 && source[3] === 0x46; - if (isPdf) { - yield* this._recognizePdf(source, options); - } else { - yield* this._recognizeImages([source], options); - } + 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) { - return this._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) { - return this._recognizeImages(sources, options); - } - - recognizeDocument(source, options) { - return this._recognizeDocument(source, 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; - // Note: We don't close the engine here since it might be shared - // The caller is responsible for closing the engine they provided + if (this.#closed) return; + this.#closed = true; + if (this.#ownsEngine) await this.#engine.close(); } } async function createDocumentEngine(options = {}) { - loadDependencies(); - - let engine = options.engine; - - if (!engine) { - if (!createEngine) { - throw new OcrError('package_load_failed', - 'Could not create OCR engine. Provide engine option or install @arcships/light-ocr-runtime.' - ); - } - - // Try to load from peer dependency - try { - const path = require('node:path'); - const bundlePath = options.bundlePath || - require.resolve('@arcships/light-ocr-model-ppocrv6-small'); - engine = await createEngine({ bundlePath }); - } catch (err) { - throw new OcrError('package_load_failed', - 'Could not create OCR engine. Provide engine option or install @arcships/light-ocr.', - err.message - ); - } + 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); +} - return new DocumentEngineImpl(engine, options.pdfium || {}); +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, - OcrError + recognizeDocument, + OcrError, }; diff --git a/packages/light-ocr-document/src/index.d.ts b/packages/light-ocr-document/src/index.d.ts index ec83b34..27c9a8b 100644 --- a/packages/light-ocr-document/src/index.d.ts +++ b/packages/light-ocr-document/src/index.d.ts @@ -1,27 +1,24 @@ /// -import type { OcrEngine, OcrResult, RecognizeOptions, OcrError } from '@arcships/light-ocr-runtime'; +import type { + CreateEngineOptions, + OcrEngine, + OcrError, + RecognizeOptions, +} from '@arcships/light-ocr'; -// Re-export runtime types for convenience -export type { OcrEngine, OcrResult, RecognizeOptions, OcrError }; +export type { OcrEngine, OcrError, RecognizeOptions }; -export interface Point { readonly x: number; readonly y: number } -export interface Rect { +export interface Point { readonly x: number; readonly y: number; - readonly width: number; - readonly height: number; } -export interface DocumentPage { - readonly index: number; +export interface Rect { + readonly x: number; + readonly y: number; readonly width: number; readonly height: number; - readonly coordinateSpace: 'pageSpace'; - readonly structure: 'ocr-order'; - readonly lines: ReadonlyArray; - readonly source: PageSource; - readonly timingUs: PageTimingUs; } export interface DocumentLine { @@ -31,23 +28,6 @@ export interface DocumentLine { readonly box: readonly [Point, Point, Point, Point]; } -export interface PageSource { - readonly kind: 'image' | 'pdf'; - readonly mediaType: string; - readonly identity: Record; - readonly appliedTransforms: AppliedTransforms; -} - -export interface AppliedTransforms { - readonly exif?: ExifTransform; - readonly pdf?: PdfTransform; -} - -export interface ExifTransform { - readonly orientation: number; - readonly applied: boolean; -} - export interface PdfTransform { readonly rotation: number; readonly mediaBox: Rect; @@ -56,89 +36,89 @@ export interface PdfTransform { readonly scale: number; } -export interface PageTimingUs { - readonly total: number; - readonly decode: number; - readonly ocr: number; -} - -export interface DocumentResult { - readonly schemaVersion: 1; - readonly source: DocumentSource; - readonly pages: ReadonlyArray; +export interface PageSource { + readonly kind: 'image' | 'pdf'; + readonly mediaType: string; + readonly identity: Readonly>; + readonly appliedTransforms: { + readonly pdf?: PdfTransform; + }; } -export interface DocumentSource { - readonly kind: 'image' | 'pdf' | 'page-images'; - readonly mediaType: string; - readonly identity: Record; - readonly pageCount: number; +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 OutputFormat = 'json' | 'jsonl' | 'text' | 'markdown'; +export type DocumentInput = string | Uint8Array; export interface DocumentOptions { - /** Output format. Default: 'json' */ - readonly format?: OutputFormat; - - /** Page range to process (1-indexed). Default: all pages */ - readonly pageRange?: { start: number; end: number }; - - /** PDF raster DPI. Default: 150 */ + /** 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 file size in bytes. Default: 100MB */ + /** Maximum bytes accepted for each input. Default: 100 MiB. */ readonly maxFileBytes?: number; - - /** Maximum number of pages to process. Default: 100 */ + /** Maximum number of pages. Default: 100. */ readonly maxPages?: number; - - /** Maximum pixels per page. Default: 4096*4096 */ + /** Maximum rendered pixels for one page. Default: 4096 × 4096. */ readonly maxPagePixels?: number; - - /** Maximum total pixels across all pages. Default: 100MP */ + /** Maximum rendered pixels across the request. Default: 100 Mi pixels. */ readonly maxTotalPixels?: number; - - /** Abort signal for cancellation */ readonly signal?: AbortSignal; - - /** OCR options passed to the engine */ readonly ocrOptions?: RecognizeOptions; } export interface DocumentEngine { - /** Process a PDF file or buffer */ - recognizePdf(source: string | Uint8Array, options?: DocumentOptions): AsyncGenerator; - - /** Process multiple image files or buffers */ - recognizeImages(sources: ReadonlyArray, options?: DocumentOptions): AsyncGenerator; - - /** Process any supported document source */ - recognizeDocument(source: string | Uint8Array | ReadonlyArray, options?: DocumentOptions): AsyncGenerator; - - /** Close the engine and release resources */ + recognizePdf( + source: DocumentInput, + options?: DocumentOptions, + ): AsyncGenerator; + recognizeImages( + sources: ReadonlyArray, + options?: DocumentOptions, + ): AsyncGenerator; + recognizeDocument( + source: DocumentInput | ReadonlyArray, + options?: DocumentOptions, + ): AsyncGenerator; close(): Promise; } export interface CreateDocumentEngineOptions { - /** Path to the model bundle. If not provided, uses the default from the peer dependency */ - readonly bundlePath?: string; - - /** OCR engine instance to reuse. If not provided, creates a new one */ + /** Reuse an existing OCR engine. A borrowed engine is not closed automatically. */ readonly engine?: OcrEngine; - - /** PDFium options */ - readonly pdfium?: { - /** Maximum memory for PDFium in bytes. Default: 512MB */ - readonly maxMemory?: number; - }; + /** Options used when this package creates and owns the OCR engine. */ + readonly engineOptions?: CreateEngineOptions; } -/** Create a document processing engine */ -export function createDocumentEngine(options?: CreateDocumentEngineOptions): Promise; +export interface RecognizeDocumentOptions extends DocumentOptions { + readonly engine?: OcrEngine; + readonly engineOptions?: CreateEngineOptions; +} -/** Get the version of this package */ -export function getVersion(): string; +export function createDocumentEngine( + options?: CreateDocumentEngineOptions, +): Promise; -/** Check if PDF support is available */ +export function recognizeDocument( + source: DocumentInput | ReadonlyArray, + options?: RecognizeDocumentOptions, +): AsyncGenerator; + +export function getVersion(): string; export function hasPdfSupport(): boolean; diff --git a/packages/light-ocr-document/src/index.mjs b/packages/light-ocr-document/src/index.mjs index 9c2e692..3803f21 100644 --- a/packages/light-ocr-document/src/index.mjs +++ b/packages/light-ocr-document/src/index.mjs @@ -1 +1,7 @@ -export { createDocumentEngine, getVersion, hasPdfSupport, OcrError } from './index.cjs'; +export { + createDocumentEngine, + getVersion, + hasPdfSupport, + recognizeDocument, + OcrError, +} from './index.cjs'; diff --git a/packages/light-ocr-document/test/cli.test.cjs b/packages/light-ocr-document/test/cli.test.cjs new file mode 100644 index 0000000..6b2ecc6 --- /dev/null +++ b/packages/light-ocr-document/test/cli.test.cjs @@ -0,0 +1,78 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); + +const { main, parseArgs, USAGE } = require('../src/cli.cjs'); + +function capture() { + let value = ''; + return { + stream: { + write(chunk) { + value += chunk; + }, + }, + read() { + return value; + }, + }; +} + +describe('light-ocr-document CLI', () => { + it('parses implicit recognize sources and bounded options', () => { + assert.deepEqual( + parseArgs([ + 'report.pdf', + '--format', 'jsonl', + '--pages', '2-4', + '--dpi', '200', + '--provider', 'cpu', + '--quiet', + ]), + { + documentOptions: { + pageRange: { start: 2, end: 4 }, + dpi: 200, + }, + format: 'jsonl', + provider: 'cpu', + quiet: true, + sources: ['report.pdf'], + }, + ); + }); + + it('rejects unknown options, invalid ranges, and missing sources', () => { + assert.throws(() => parseArgs(['--wat']), /unknown option/); + assert.throws(() => parseArgs(['x.pdf', '--pages', '4-2']), /must not precede/); + assert.throws(() => parseArgs(['--dpi', '150']), /source is required/); + }); + + it('prints help, version, and machine-readable info without exiting the process', async () => { + const stdout = capture(); + const stderr = capture(); + assert.equal(await main(['help'], { stdout: stdout.stream, stderr: stderr.stream }), 0); + assert.equal(stdout.read().trim(), USAGE); + + const versionOut = capture(); + assert.equal(await main(['--version'], { + stdout: versionOut.stream, + stderr: stderr.stream, + }), 0); + assert.match(versionOut.read(), /^\d+\.\d+\.\d+\n$/); + + 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(typeof info.pdfSupport, 'boolean'); + }); + + it('maps usage failures to EX_DATAERR', async () => { + const stdout = capture(); + const stderr = capture(); + assert.equal(await main(['--wat'], { stdout: stdout.stream, stderr: stderr.stream }), 65); + assert.match(stderr.read(), /^invalid_argument:/); + }); +}); diff --git a/packages/light-ocr-document/test/document.test.cjs b/packages/light-ocr-document/test/document.test.cjs index 5dbbaf2..e155f0d 100644 --- a/packages/light-ocr-document/test/document.test.cjs +++ b/packages/light-ocr-document/test/document.test.cjs @@ -3,55 +3,144 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); -// Skip tests if pdfium-native is not available -let pdfiumAvailable = false; -try { - require('pdfium-native'); - pdfiumAvailable = true; -} catch {} - -describe('light-ocr-document', () => { - it('should export expected functions', () => { - const doc = require('../src/index.cjs'); - assert.equal(typeof doc.createDocumentEngine, 'function'); - assert.equal(typeof doc.getVersion, 'function'); - assert.equal(typeof doc.hasPdfSupport, 'function'); - assert.equal(typeof doc.OcrError, 'function'); +const { + createDocumentEngine, + getVersion, + hasPdfSupport, + recognizeDocument, + OcrError, +} = require('../src/index.cjs'); +const { createTextPdf } = require('../../../tools/npm/pdf-fixture.cjs'); + +const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x00]); + +function fakeResult(width = 20, height = 10) { + return { + imageWidth: width, + imageHeight: height, + modelBundleId: 'test-bundle', + lines: [{ + text: 'HELLO 123', + confidence: 0.99, + box: [ + { x: 0, y: 0 }, + { x: width, y: 0 }, + { x: width, y: height }, + { x: 0, y: height }, + ], + }], + }; +} + +function fakeEngine({ width = 20, height = 10 } = {}) { + let closes = 0; + return { + get closes() { + return closes; + }, + async recognizeEncoded(_input, options) { + if (options?.signal?.aborted) { + throw new DOMException('aborted', 'AbortError'); + } + return fakeResult(width, height); + }, + async close() { + closes++; + }, + }; +} + +async function collect(generator) { + const values = []; + for await (const value of generator) values.push(value); + return values; +} + +describe('light-ocr-document surface', () => { + it('exports the complete preview surface', () => { + assert.equal(typeof createDocumentEngine, 'function'); + assert.equal(typeof recognizeDocument, 'function'); + assert.equal(typeof hasPdfSupport(), 'boolean'); + assert.match(getVersion(), /^\d+\.\d+\.\d+$/); + assert.equal(typeof OcrError, 'function'); }); - it('should return version string', () => { - const { getVersion } = require('../src/index.cjs'); - const version = getVersion(); - assert.equal(typeof version, 'string'); - assert.match(version, /^\d+\.\d+\.\d+$/); + it('processes image pages without closing a borrowed OCR engine', async () => { + const borrowed = fakeEngine(); + const engine = await createDocumentEngine({ engine: borrowed }); + const pages = await collect(engine.recognizeImages([PNG, PNG])); + await engine.close(); + + assert.equal(pages.length, 2); + assert.equal(pages[0].source.kind, 'image'); + assert.equal(pages[0].source.mediaType, 'image/png'); + assert.deepEqual(pages[0].source.identity, { index: 0 }); + assert.equal(pages[0].lines[0].text, 'HELLO 123'); + assert.equal(borrowed.closes, 0); }); - it('should report PDF support status', () => { - const { hasPdfSupport } = require('../src/index.cjs'); - const supported = hasPdfSupport(); - assert.equal(typeof supported, 'boolean'); - // Note: We can't assert the exact value since it depends on pdfium-native installation + it('enforces page, file, page-pixel, and total-pixel limits', async () => { + const engine = await createDocumentEngine({ engine: fakeEngine({ width: 20, height: 10 }) }); + + await assert.rejects( + collect(engine.recognizeImages([PNG, PNG], { maxPages: 1 })), + (error) => error.code === 'resource_limit_exceeded', + ); + await assert.rejects( + collect(engine.recognizeImages([PNG], { maxFileBytes: 4 })), + (error) => error.code === 'resource_limit_exceeded', + ); + await assert.rejects( + collect(engine.recognizeImages([PNG], { maxPagePixels: 199 })), + (error) => error.code === 'resource_limit_exceeded', + ); + await assert.rejects( + collect(engine.recognizeImages([PNG, PNG], { maxTotalPixels: 399 })), + (error) => error.code === 'resource_limit_exceeded', + ); }); - it('should throw OcrError with correct properties', () => { - const { OcrError } = require('../src/index.cjs'); - const err = new OcrError('test_code', 'test message', 'test detail'); - assert.equal(err.name, 'OcrError'); - assert.equal(err.code, 'test_code'); - assert.equal(err.message, 'test message'); - assert.equal(err.detail, 'test detail'); - assert.ok(err instanceof Error); + it('validates options and propagates cancellation', async () => { + const engine = await createDocumentEngine({ engine: fakeEngine() }); + await assert.rejects( + collect(engine.recognizeDocument(PNG, { dpi: 35 })), + (error) => error.code === 'invalid_argument', + ); + await assert.rejects( + collect(engine.recognizeDocument([])), + (error) => error.code === 'invalid_argument', + ); + const controller = new AbortController(); + controller.abort(); + await assert.rejects( + collect(engine.recognizeDocument(PNG, { signal: controller.signal })), + (error) => error.name === 'AbortError', + ); }); -}); -// Integration tests (require actual engine and PDF) -if (pdfiumAvailable) { - describe('DocumentEngine integration', () => { - // These tests would require actual PDF files and OCR engine - // For now, just verify the engine can be created - it('should be able to create engine with options', async () => { - // This test would need actual dependencies - // Skipping for now - }); + it('renders a real in-memory PDF when pdfium is installed', { + skip: !hasPdfSupport() && 'pdfium-native is not installed', + }, async () => { + const engine = await createDocumentEngine({ engine: fakeEngine() }); + const pages = await collect(engine.recognizePdf(createTextPdf(), { dpi: 72 })); + await engine.close(); + + assert.equal(pages.length, 1); + assert.equal(pages[0].source.kind, 'pdf'); + assert.equal(pages[0].source.appliedTransforms.pdf.dpi, 72); + assert.equal(pages[0].lines[0].text, 'HELLO 123'); }); -} + + it('checks PDF pixel limits after applying DPI scale', { + skip: !hasPdfSupport() && 'pdfium-native is not installed', + }, async () => { + const engine = await createDocumentEngine({ engine: fakeEngine() }); + await assert.rejects( + collect(engine.recognizePdf(createTextPdf(), { + dpi: 600, + maxPagePixels: 1_000_000, + })), + (error) => error.code === 'resource_limit_exceeded', + ); + }); +}); diff --git a/packages/light-ocr-document/test/types.ts b/packages/light-ocr-document/test/types.ts new file mode 100644 index 0000000..812c4a4 --- /dev/null +++ b/packages/light-ocr-document/test/types.ts @@ -0,0 +1,27 @@ +import { + createDocumentEngine, + recognizeDocument, + type DocumentPage, + type RecognizeDocumentOptions, +} from '@arcships/light-ocr-document'; + +const options: RecognizeDocumentOptions = { + dpi: 150, + pageRange: { start: 1, end: 2 }, + maxPages: 2, + engineOptions: { execution: { provider: 'cpu' } }, +}; + +async function consume(): Promise { + for await (const page of recognizeDocument('report.pdf', options)) { + const typed: DocumentPage = page; + void typed.lines; + } + + const engine = await createDocumentEngine({ + engineOptions: { execution: { provider: 'auto' } }, + }); + await engine.close(); +} + +void consume; diff --git a/packages/light-ocr-medium/package.json b/packages/light-ocr-medium/package.json index 7376404..f8094cc 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.2", + "version": "0.1.3", "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.3" + "@arcships/light-ocr-runtime": "0.1.4" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr-tiny/package.json b/packages/light-ocr-tiny/package.json index 1b54ba0..e5e0f1a 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.2", + "version": "0.1.3", "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.3" + "@arcships/light-ocr-runtime": "0.1.4" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr/README.md b/packages/light-ocr/README.md index 5563afc..8be3197 100644 --- a/packages/light-ocr/README.md +++ b/packages/light-ocr/README.md @@ -1,10 +1,36 @@ # @arcships/light-ocr -The default PP-OCRv6 Small entry for `light-ocr`. It exact-pins one compatible model-free runtime and one Small model package, and remains the only owner of the `light-ocr` command. +The stable PP-OCRv6 Small entry for local image OCR on Node.js 22 and 24. ```bash npm install @arcships/light-ocr -light-ocr info --version +light-ocr image.png --format text +light-ocr doctor --json ``` -`0.4.0` is the N2 topology cutover: the facade contains only Small model configuration and delegates the API, native loading, EXIF handling, and CLI implementation to the shared runtime. Tiny and Medium use separate packages and commands, so installing this package still installs only Small. +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. + +```js +const { createEngine } = require('@arcships/light-ocr'); + +const engine = await createEngine(); +try { + const result = await engine.recognizeEncoded(imageBytes); + console.log(result.lines); +} finally { + await engine.close(); +} +``` + +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. diff --git a/packages/light-ocr/package.json b/packages/light-ocr/package.json index 6687296..e5ac828 100644 --- a/packages/light-ocr/package.json +++ b/packages/light-ocr/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr", - "version": "0.5.3", + "version": "0.5.4", "private": true, "description": "Offline PP-OCRv6 Small OCR for Node.js — the stable default tier", "license": "Apache-2.0", @@ -29,10 +29,7 @@ }, "dependencies": { "@arcships/light-ocr-model-ppocrv6-small": "0.3.4", - "@arcships/light-ocr-runtime": "0.1.3" - }, - "optionalDependencies": { - "pdfium-native": "0.6.1" + "@arcships/light-ocr-runtime": "0.1.4" }, "scripts": { "test": "node --test test/*.test.cjs" diff --git a/packages/light-ocr/src/index.cjs b/packages/light-ocr/src/index.cjs index 5f847b6..7efb0c6 100644 --- a/packages/light-ocr/src/index.cjs +++ b/packages/light-ocr/src/index.cjs @@ -1,20 +1,8 @@ 'use strict'; -const path = require('node:path'); +const { createModelFacade } = require('@arcships/light-ocr-runtime/facade'); -// Try to use workspace dependencies, fallback to local paths -let createModelFacade; -try { - ({ createModelFacade } = require('@arcships/light-ocr-runtime/facade')); -} catch { - // Fallback to local runtime - const facadePath = path.join(__dirname, '..', '..', 'runtime', 'src', 'facade.cjs'); - ({ createModelFacade } = require(facadePath)); -} - -const fs = require('node:fs'); - -const facade = createModelFacade({ +module.exports = createModelFacade({ model: 'ppocrv6-small', modelPackage: '@arcships/light-ocr-model-ppocrv6-small', compatibleBundleIds: [ @@ -37,250 +25,3 @@ const facade = createModelFacade({ maturity: 'stable', }, }); - -// Lazy load pdfium-native -let pdfium = null; -let pdfiumLoaded = false; - -function loadPdfium() { - if (pdfiumLoaded) return pdfium; - pdfiumLoaded = true; - try { - pdfium = require('pdfium-native'); - } catch { - pdfium = null; - } - return pdfium; -} - -function hasPdfSupport() { - return loadPdfium() !== null; -} - -async function* processPdf(engine, pdfBuffer, options = {}) { - const pdfiumNative = loadPdfium(); - if (!pdfiumNative) { - throw new facade.OcrError('unsupported_capability', 'PDF support not available. Install pdfium-native.'); - } - - const { - pageRange, - dpi = 150, - maxPages = 100, - maxPagePixels = 4096 * 4096, - maxTotalPixels = 100 * 1024 * 1024, - maxFileBytes = 100 * 1024 * 1024, - signal, - ocrOptions = {} - } = options; - - // Check file size - if (pdfBuffer.byteLength > maxFileBytes) { - throw new facade.OcrError('resource_limit_exceeded', - `File size ${pdfBuffer.byteLength} exceeds maxFileBytes ${maxFileBytes}`); - } - - let totalPixels = 0; - - // Open PDF document - const doc = await pdfiumNative.loadDocument(pdfBuffer); - - try { - const pageCount = doc.pageCount; - - // Apply page range - const start = pageRange?.start ? Math.max(1, pageRange.start) : 1; - const end = pageRange?.end ? Math.min(pageCount, pageRange.end) : pageCount; - - // Check page limits - if (end - start + 1 > maxPages) { - throw new facade.OcrError('resource_limit_exceeded', - `Page count ${end - start + 1} exceeds maxPages ${maxPages}`); - } - - for (let i = start; i <= end; i++) { - // Check abort signal - if (signal?.aborted) { - throw new facade.OcrError('internal_error', 'Operation aborted'); - } - - const page = await doc.getPage(i - 1); // 0-indexed - - // Get page dimensions - const { width, height } = page; - const pagePixels = width * height; - - // Check pixel limits - if (pagePixels > maxPagePixels) { - throw new facade.OcrError('resource_limit_exceeded', - `Page ${i} pixels ${pagePixels} exceeds maxPagePixels ${maxPagePixels}`); - } - - totalPixels += pagePixels; - if (totalPixels > maxTotalPixels) { - throw new facade.OcrError('resource_limit_exceeded', - `Total pixels ${totalPixels} exceeds maxTotalPixels ${maxTotalPixels}`); - } - - // Render page to PNG - const renderStart = Date.now(); - const scale = dpi / 72; // PDF default is 72 DPI - const pngBuffer = await page.render({ scale }); - const renderTime = (Date.now() - renderStart) * 1000; - - // OCR the rendered image - const ocrStart = Date.now(); - const ocrResult = await engine.recognizeEncoded(pngBuffer, ocrOptions); - const ocrTime = (Date.now() - ocrStart) * 1000; - - // Close page to free native memory - await page.close(); - - // Build page result - const pageResult = { - index: i - 1, - width: ocrResult.imageWidth, - height: ocrResult.imageHeight, - coordinateSpace: 'pageSpace', - structure: 'ocr-order', - lines: ocrResult.lines.map((line, idx) => ({ - id: `L${idx}`, - text: line.text, - confidence: line.confidence, - box: line.box - })), - source: { - kind: 'pdf', - mediaType: 'application/pdf', - identity: { pageIndex: i - 1 }, - appliedTransforms: { - pdf: { - rotation: 0, - mediaBox: { x: 0, y: 0, width, height }, - cropBox: { x: 0, y: 0, width, height }, - dpi, - scale: dpi / 72 - } - } - }, - timingUs: { - total: renderTime + ocrTime, - decode: renderTime, - ocr: ocrTime - }, - modelBundleId: ocrResult.modelBundleId - }; - - yield pageResult; - } - } finally { - doc.destroy(); - } -} - -async function* processImages(engine, imageBuffers, options = {}) { - const { signal, ocrOptions = {} } = options; - - for (let i = 0; i < imageBuffers.length; i++) { - if (signal?.aborted) { - throw new facade.OcrError('internal_error', 'Operation aborted'); - } - - const buffer = imageBuffers[i]; - - const ocrStart = Date.now(); - const ocrResult = await engine.recognizeEncoded(buffer, { - ...ocrOptions, - applyExif: true - }); - const ocrTime = (Date.now() - ocrStart) * 1000; - - const pageResult = { - index: i, - width: ocrResult.imageWidth, - height: ocrResult.imageHeight, - coordinateSpace: 'pageSpace', - structure: 'ocr-order', - lines: ocrResult.lines.map((line, idx) => ({ - id: `L${idx}`, - text: line.text, - confidence: line.confidence, - box: line.box - })), - source: { - kind: 'image', - mediaType: 'image/png', - identity: { index: i }, - appliedTransforms: { - exif: { orientation: 1, applied: false } - } - }, - timingUs: { - total: ocrTime, - decode: 0, - ocr: ocrTime - }, - modelBundleId: ocrResult.modelBundleId - }; - - yield pageResult; - } -} - -async function* recognizeDocument(source, options = {}) { - // Create engine if not provided - let engine = options.engine; - let engineCreated = false; - - if (!engine) { - engine = await facade.createEngine(); - engineCreated = true; - } - - try { - let buffers; - let isPdf = false; - - if (Array.isArray(source)) { - // Multiple images - buffers = []; - for (const s of source) { - if (typeof s === 'string') { - buffers.push(fs.readFileSync(s)); - } else { - buffers.push(s); - } - } - } else if (typeof source === 'string') { - // File path - const ext = path.extname(source).toLowerCase(); - if (ext === '.pdf') { - isPdf = true; - buffers = [fs.readFileSync(source)]; - } else { - buffers = [fs.readFileSync(source)]; - } - } else { - // Buffer - isPdf = source[0] === 0x25 && source[1] === 0x50 && source[2] === 0x44 && source[3] === 0x46; - buffers = [source]; - } - - if (isPdf) { - yield* processPdf(engine, buffers[0], options); - } else { - yield* processImages(engine, buffers, options); - } - } finally { - if (engineCreated) { - await engine.close(); - } - } -} - -// Export facade + document capabilities -module.exports = { - ...facade, - hasPdfSupport, - recognizeDocument -}; diff --git a/packages/light-ocr/test/facade.test.cjs b/packages/light-ocr/test/facade.test.cjs index a597aa5..3b8deb4 100644 --- a/packages/light-ocr/test/facade.test.cjs +++ b/packages/light-ocr/test/facade.test.cjs @@ -22,11 +22,6 @@ test('small facade reuses the runtime API and error identity', () => { assert.strictEqual(facade.OcrError, runtime.OcrError); }); -test('small facade exposes PDF and document capabilities', () => { - assert.equal(typeof facade.hasPdfSupport, 'function'); - assert.equal(typeof facade.recognizeDocument, 'function'); -}); - test('small facade rejects an unknown built-in model before native loading', async () => { await assert.rejects( facade.createEngine({ model: 'ppocrv6-medium' }), diff --git a/packages/runtime/package.json b/packages/runtime/package.json index c2fff54..8882c9e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@arcships/light-ocr-runtime", - "version": "0.1.3", + "version": "0.1.4", "private": true, "description": "Model-free Node.js runtime for light-ocr", "license": "Apache-2.0", diff --git a/packages/runtime/src/cli.cjs b/packages/runtime/src/cli.cjs index f95ef16..b807af3 100755 --- a/packages/runtime/src/cli.cjs +++ b/packages/runtime/src/cli.cjs @@ -12,14 +12,13 @@ // stdout = machine results only; stderr = logs/warnings/usage (cli-design.md §5). // Exit codes are a stable surface (cli-design.md §10, D106). -const crypto = require('node:crypto'); const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { parseExifOrientation } = require('./exif.cjs'); -const SUBCOMMANDS = new Set(['recognize', 'detect', 'info', 'document', 'doctor']); +const SUBCOMMANDS = new Set(['recognize', 'detect', 'info', 'doctor']); const EXIT = { success: 0, usage: 64, @@ -514,7 +513,6 @@ async function runDoctor(rest, flags, stdout, stderr, config) { platform: process.platform, arch: process.arch, release: os.release(), - hostHash: crypto.createHash('sha256').update(os.hostname()).digest('hex').slice(0, 16), cpuModel: os.cpus()[0]?.model || 'unknown', cpuCores: os.cpus().length, totalMemoryGB: +(os.totalmem() / (1024 ** 3)).toFixed(1), @@ -523,7 +521,6 @@ async function runDoctor(rest, flags, stdout, stderr, config) { modules: { runtime: safeRequireResolve('@arcships/light-ocr-runtime') !== undefined, model: safeRequireResolve(config.modelProfile.model ? `@arcships/light-ocr-model-${config.modelProfile.model}` : '@arcships/light-ocr-model-ppocrv6-small') !== undefined, - pdfium: false, }, }; @@ -554,106 +551,9 @@ async function runDoctor(rest, flags, stdout, stderr, config) { if (gpuInfo) info.gpu = gpuInfo; } - // PDF support - if (typeof config.hasPdfSupport === 'function') { - info.modules.pdfium = config.hasPdfSupport(); - } - stdout.write(JSON.stringify(info, null, 2) + '\n'); } -// --- document subcommand (PDF and multi-page support) --- -function parsePageRange(rangeStr) { - if (!rangeStr) return undefined; - const match = String(rangeStr).match(/^(\d+)(?:-(\d+))?$/); - if (!match) { - throw { code: EXIT.invalid_argument, message: `--pages expects N or N-M (got ${rangeStr})` }; - } - return { - start: parseInt(match[1]), - end: match[2] ? parseInt(match[2]) : parseInt(match[1]) - }; -} - -async function runDocument(rest, flags, stdout, stderr, config) { - const format = resolveFormat(flags, 'recognize'); - const provider = resolveProvider(flags); - - if (rest.length === 0) { - throw { code: EXIT.usage, message: 'expected a PDF or image file path' }; - } - - // Parse document-specific flags - const pageRange = parsePageRange(flags.pages); - const dpi = flags.dpi ? parseInt(flags.dpi) : 150; - const maxPages = flags['max-pages'] ? parseInt(flags['max-pages']) : 100; - const quiet = flags.quiet === true; - - // Check if recognizeDocument is available - if (typeof config.recognizeDocument !== 'function') { - throw { code: EXIT.unsupported_capability, message: 'PDF/document support not available' }; - } - - const source = rest.length === 1 ? rest[0] : rest; - - const pages = []; - let pageCount = 0; - - try { - for await (const page of config.recognizeDocument(source, { - pageRange, - dpi, - maxPages, - engine: undefined // Will use default - })) { - pages.push(page); - pageCount++; - - // Output JSONL as we go - if (format === 'jsonl') { - stdout.write(JSON.stringify({ - schemaVersion: SUPPORTED_SCHEMA_VERSION, - source: { kind: page.source.kind }, - pageIndex: page.index, - status: 'ok', - page - }) + '\n'); - } - - // Progress output - if (!quiet) { - stderr.write(`\rProcessed page ${pageCount}...`); - } - } - - if (!quiet && pageCount > 0) { - stderr.write('\n'); - } - - // Output final result for non-JSONL formats - if (format === 'json') { - const result = { - schemaVersion: SUPPORTED_SCHEMA_VERSION, - source: { - kind: Array.isArray(source) ? 'page-images' : - (typeof source === 'string' && source.endsWith('.pdf') ? 'pdf' : 'image'), - mediaType: typeof source === 'string' && source.endsWith('.pdf') ? 'application/pdf' : 'image/*', - identity: { files: Array.isArray(source) ? source : [source] }, - pageCount: pages.length - }, - pages - }; - stdout.write(JSON.stringify(result, null, 2) + '\n'); - } else if (format === 'text') { - for (const page of pages) { - stdout.write(page.lines.map(l => l.text).join('\n') + '\n'); - } - } - } catch (e) { - throw e; - } -} - // --- help --- function printHelp(stdout, verbose, config) { const command = config.commandName; @@ -661,7 +561,6 @@ function printHelp(stdout, verbose, config) { stdout.write('Usage:\n'); stdout.write(` ${command} recognize [flags] Recognize text in an image (default)\n`); stdout.write(` ${command} detect [flags] Detect text regions only\n`); - stdout.write(` ${command} document [flags] Process PDF or multiple images\n`); stdout.write(` ${command} info --model-info | --version Show engine/version info\n`); stdout.write(` ${command} doctor [--json] System diagnostics\n`); stdout.write(` ${command} [flags] Implicit recognize\n\n`); @@ -704,18 +603,6 @@ function printSubcommandHelp(stdout, subcommand, config) { stdout.write(' --version Print npm/core/model version triple\n'); return; } - if (subcommand === 'document') { - stdout.write(`${command} document — process PDF or multiple images\n\n`); - stdout.write(`Usage:\n ${command} document [flags]\n ${command} document [flags]\n\n`); - stdout.write('Flags:\n'); - stdout.write(' --format json|jsonl|text Output format (default: json)\n'); - stdout.write(' --pages N-M Page range for PDF (e.g., 1-5 or 3)\n'); - stdout.write(' --dpi PDF raster DPI (default: 150)\n'); - stdout.write(' --max-pages Maximum pages to process (default: 100)\n'); - stdout.write(' --provider auto|cpu|apple|webgpu Execution provider (default: auto)\n'); - stdout.write(' --quiet Suppress progress output\n'); - return; - } if (subcommand === 'doctor') { stdout.write(`${command} doctor — system diagnostics for troubleshooting\n\n`); stdout.write(`Usage:\n ${command} doctor [--json]\n\n`); @@ -772,8 +659,6 @@ async function main(argv, config) { }; } await runDetect(rest, parsed.flags, stdout, stderr, config); - } else if (subcommand === 'document') { - await runDocument(rest, parsed.flags, stdout, stderr, config); } else { die(stderr, config.commandName, `unknown subcommand: ${subcommand}`); return EXIT.usage; diff --git a/tests/python/test_npm_release.py b/tests/python/test_npm_release.py index 1af7079..f5cc790 100644 --- a/tests/python/test_npm_release.py +++ b/tests/python/test_npm_release.py @@ -88,9 +88,16 @@ def test_unpublished_release_can_enter_the_expensive_pipeline( argparse.Namespace(version=npm_release.SOURCE_VERSION, npm="npm") ) - integrity.assert_called_once_with( - "npm", f"{npm_release.FACADE_PACKAGE}@{npm_release.SOURCE_VERSION}" + checked = {call.args[1] for call in integrity.call_args_list} + self.assertIn( + f"{npm_release.FACADE_PACKAGE}@{npm_release.SOURCE_VERSION}", + checked, ) + self.assertIn( + f"{npm_release.DOCUMENT_PACKAGE}@{npm_release.DOCUMENT_VERSION}", + checked, + ) + self.assertEqual(len(checked), len(npm_release.PLATFORMS) + 5) @mock.patch( "tools.npm_release.npm_integrity", return_value="sha512-published-integrity" @@ -99,7 +106,7 @@ def test_published_release_must_use_the_promotion_workflow( self, integrity: mock.Mock ) -> None: with self.assertRaisesRegex( - RuntimeError, "already published.*npm promote workflow" + RuntimeError, "already published.*original release artifact" ): npm_release.ensure_unpublished( argparse.Namespace(version=npm_release.SOURCE_VERSION, npm="npm") @@ -212,6 +219,21 @@ def test_stages_and_packs_the_independently_versioned_release_set(self) -> None: facade["dependencies"][npm_release.RUNTIME_PACKAGE], npm_release.RUNTIME_VERSION, ) + document = json.loads( + (staging / "light-ocr-document" / "package.json").read_text("utf-8") + ) + self.assertEqual(document["name"], npm_release.DOCUMENT_PACKAGE) + self.assertEqual( + document["dependencies"][npm_release.FACADE_PACKAGE], + npm_release.CORE_VERSION, + ) + self.assertEqual(document["dependencies"]["pdfium-native"], "0.6.1") + self.assertTrue( + (staging / "light-ocr-document" / "src" / "cli.cjs").is_file() + ) + self.assertTrue( + (staging / "light-ocr-document" / "LICENSE").is_file() + ) runtime = json.loads( (staging / "runtime" / "package.json").read_text("utf-8") ) @@ -237,7 +259,7 @@ def test_stages_and_packs_the_independently_versioned_release_set(self) -> None: (tarballs / "release-manifest.json").read_text("utf-8") ) self.assertEqual(release["version"], source_version) - expected_packages = len(npm_release.PLATFORMS) + 6 + expected_packages = len(npm_release.PLATFORMS) + 7 self.assertEqual(len(release["packages"]), expected_packages) self.assertEqual( len(list(tarballs.glob("*.tgz"))), expected_packages @@ -295,7 +317,10 @@ def test_stages_and_packs_the_independently_versioned_release_set(self) -> None: encoding="utf-8", ) version_info = json.loads(completed.stdout) - self.assertEqual(version_info["npm"], "0.1.0") + self.assertEqual( + version_info["npm"], + npm_release.FACADE_PACKAGES["tiny"]["version"], + ) self.assertEqual(version_info["tier"], "tiny") def test_runtime_descriptor_rejects_mutated_payload_and_qualification_release( diff --git a/tools/npm/document-smoke.cjs b/tools/npm/document-smoke.cjs new file mode 100644 index 0000000..f854904 --- /dev/null +++ b/tools/npm/document-smoke.cjs @@ -0,0 +1,45 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createRequire } = require('node:module'); + +const consumerRequire = createRequire(path.join(process.cwd(), 'package.json')); +const { + createDocumentEngine, + hasPdfSupport, +} = consumerRequire('@arcships/light-ocr-document'); +const { createTextPdf } = require('./pdf-fixture.cjs'); + +async function main() { + assert.equal(hasPdfSupport(), true, 'pdfium-native did not install correctly'); + const engine = await createDocumentEngine({ + engineOptions: { execution: { provider: 'cpu' } }, + }); + try { + const pages = []; + for await (const page of engine.recognizePdf(createTextPdf(), { dpi: 150 })) { + pages.push(page); + } + assert.equal(pages.length, 1); + assert.equal(pages[0].source.kind, 'pdf'); + assert.match( + pages[0].lines.map((line) => line.text).join(' '), + /HELLO\s*123/i, + 'real PDF render did not survive OCR', + ); + console.log(JSON.stringify({ + ok: true, + package: '@arcships/light-ocr-document', + pages: pages.length, + text: pages[0].lines.map((line) => line.text).join(' '), + })); + } finally { + await engine.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tools/npm/pdf-fixture.cjs b/tools/npm/pdf-fixture.cjs new file mode 100644 index 0000000..73cda1e --- /dev/null +++ b/tools/npm/pdf-fixture.cjs @@ -0,0 +1,32 @@ +'use strict'; + +function createTextPdf(text = 'HELLO 123', width = 300, height = 100) { + const escapedText = String(text).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)'); + const content = `BT /F1 24 Tf 20 45 Td (${escapedText}) Tj ET`; + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${width} ${height}] ` + + '/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + + let body = '%PDF-1.4\n'; + const offsets = [0]; + for (let index = 0; index < objects.length; index++) { + offsets.push(Buffer.byteLength(body)); + body += `${index + 1} 0 obj\n${objects[index]}\nendobj\n`; + } + const xrefOffset = Buffer.byteLength(body); + body += `xref\n0 ${objects.length + 1}\n`; + body += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + body += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + body += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`; + body += `startxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(body); +} + +module.exports = { createTextPdf }; diff --git a/tools/npm_release.py b/tools/npm_release.py index 1ba0333..08eabc8 100644 --- a/tools/npm_release.py +++ b/tools/npm_release.py @@ -35,6 +35,10 @@ )["version"] MODEL_PACKAGE = "@arcships/light-ocr-model-ppocrv6-small" FACADE_PACKAGE = "@arcships/light-ocr" +DOCUMENT_PACKAGE = "@arcships/light-ocr-document" +DOCUMENT_VERSION = json.loads( + (ROOT / "packages" / "light-ocr-document" / "package.json").read_text("utf-8") +)["version"] MODEL_PACKAGES = { "tiny": { "name": "@arcships/light-ocr-model-ppocrv6-tiny", @@ -57,12 +61,12 @@ }, "tiny": { "name": "@arcships/light-ocr-tiny", - "version": "0.1.2", + "version": "0.1.3", "workspace": "light-ocr-tiny", }, "medium": { "name": "@arcships/light-ocr-medium", - "version": "0.1.2", + "version": "0.1.3", "workspace": "light-ocr-medium", }, } @@ -834,6 +838,8 @@ def stage_workspace_package(workspace: str, output: Path) -> dict[str, Any]: destination = output / relative if source_path.is_dir(): copy_tree(source_path, destination) + elif relative in {"LICENSE", "NOTICE"} and not source_path.exists(): + copy_file(ROOT / relative, destination) else: copy_file(source_path, destination) write_json(output / "package.json", package) @@ -989,6 +995,15 @@ def assemble(arguments: argparse.Namespace) -> None: ) if facade["name"] != contract["name"] or facade["version"] != contract["version"]: raise RuntimeError(f"{tier} facade workspace identity is inconsistent") + document = stage_workspace_package( + "light-ocr-document", output / "light-ocr-document" + ) + if ( + document["name"] != DOCUMENT_PACKAGE + or document["version"] != DOCUMENT_VERSION + or document.get("dependencies", {}).get(FACADE_PACKAGE) != CORE_VERSION + ): + raise RuntimeError("Document preview workspace identity is inconsistent") stage_model_package("tiny", tiny_bundle, output / "model-ppocrv6-tiny") stage_model_package("medium", medium_bundle, output / "model-ppocrv6-medium") @@ -1077,6 +1092,7 @@ def package_directories(staging: Path) -> list[Path]: RUNTIME_PACKAGE, *(contract["name"] for contract in MODEL_PACKAGES.values()), *(contract["name"] for contract in FACADE_PACKAGES.values()), + DOCUMENT_PACKAGE, } if len(packages) != len(expected_names): raise RuntimeError( @@ -1256,13 +1272,28 @@ def ensure_unpublished(arguments: argparse.Namespace) -> None: f"release version {arguments.version} does not match source version " f"{CORE_VERSION}" ) - specification = f"{FACADE_PACKAGE}@{arguments.version}" - if npm_integrity(arguments.npm, specification) is not None: - raise RuntimeError( - f"{specification} is already published; promote the original release " - "artifact with the npm promote workflow instead of rebuilding it" - ) - print(json.dumps({"package": specification, "status": "unpublished"})) + specifications = [ + f"{FACADE_PACKAGE}@{arguments.version}", + f"{RUNTIME_PACKAGE}@{RUNTIME_VERSION}", + f"{DOCUMENT_PACKAGE}@{DOCUMENT_VERSION}", + *( + f"{contract['name']}@{contract['version']}" + for tier, contract in FACADE_PACKAGES.items() + if tier != "small" + ), + *( + f"{platform['package']}@{arguments.version}" + for platform in PLATFORMS.values() + ), + ] + for specification in specifications: + if npm_integrity(arguments.npm, specification) is not None: + raise RuntimeError( + f"{specification} is already published; use the original release " + "artifact or assign a new conservative patch version instead of " + "rebuilding immutable registry bytes" + ) + print(json.dumps({"package": specification, "status": "unpublished"})) def npm_dist_tag(npm: str, package: str, tag: str) -> str | None: @@ -1319,7 +1350,7 @@ def publish(arguments: argparse.Namespace) -> None: else: names = [ FACADE_PACKAGES[tier]["name"] for tier in ("small", "tiny", "medium") - ] + ] + [DOCUMENT_PACKAGE] pending: dict[str, str] = {} for name in names: record = records[name]