Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ jobs:
- name: Install verified Node development files
shell: bash
run: |
npm ci --ignore-scripts --no-audit --no-fund
npm install --ignore-scripts --no-audit --no-fund
node_version="$(node -p process.versions.node)"
node_dev="$PWD/.cache/node-gyp/$node_version"
npx --yes node-gyp@11.4.2 install "$node_version" --devdir "$PWD/.cache/node-gyp"
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,34 @@ This file records user-visible changes to `light-ocr`. Published artifact detail

## [Unreleased]

## [0.5.0] - 2026-07-24

### Added

- Added `recognizeDocument()` async generator API to `@arcships/light-ocr` for unified document OCR. Accepts file paths, `Buffer`, or arrays thereof; auto-detects PDF (magic bytes or `.pdf` extension) and delegates to per-page image OCR otherwise. Each yielded page result carries `source.kind`, `appliedTransforms.pdf`, page dimensions, OCR lines, and `timingUs`.
- Added `light-ocr document` CLI subcommand with flags: `--source <path>`, `--page-range start-end`, `--dpi <n>`, `--format json|text`, `--max-pages <n>`, and `--abort-on-limit`. Processes PDF and multi-page image sources from the command line.
- Added `hasPdfSupport()` runtime probe to detect `pdfium-native` availability without throwing. Both the CLI and API gracefully degrade when PDF rendering is unavailable.
- Added `light-ocr doctor --json` system diagnostics command for hardware and runtime information collection (no user content collected).
- Added bounded PDF resource limits: `maxPages` (default 100), `maxPagePixels` (16 MiB), `maxTotalPixels` (100 MiB), and `maxFileBytes` (100 MiB). Violations surface as `resource_limit_exceeded` `OcrError` before rendering begins.
- Added `AbortSignal` passthrough to `recognizeDocument()`, `processPdf()`, and `processImages()`, checked between each page to allow cooperative cancellation of long-running document jobs.
- Added CPU performance baseline benchmark script (`tools/cpu-benchmark.mjs`).

### Changed

- `@arcships/light-ocr` now lists `pdfium-native` as an optional dependency. When the native PDFium binary is not installed, `hasPdfSupport()` returns `false` and `recognizeDocument()` handles PDF sources by throwing a clear `unsupported_capability` error instead of a module-not-found crash.
- The `light-ocr` CLI now includes `document` and `doctor` alongside `recognize`, `detect`, and `info` in its subcommand surface.
- Updated pdfium-native API calls to match v0.6.1 (`loadDocument`, `getPage`, `page.width/height`, `render({scale})`).
- Added `page.close()` to prevent native memory leaks in multi-page PDFs.
- Exported `loadNative` from runtime for doctor command integration.

### Fixed

- Fixed PDF processing broken by pdfium-native API mismatch.

### Notes

- This is the N3 document entry release (roadmap §N3). The feature adds PDF and multi-page image document processing as a new entry point for the `light-ocr` Small package. PDF rendering is optional; image-only document workflows require no additional native dependency.

## [0.4.0] - 2026-07-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.4.0
0.5.0
156 changes: 155 additions & 1 deletion bindings/node/test/cli.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.4.0');
assert.equal(info.core, '0.5.0');
assert.equal(info.tier, 'small');
assert.equal(info.maturity, 'stable');
assert.equal(info.model, 'ppocrv6-small-native-20260719.1');
Expand Down Expand Up @@ -408,3 +408,157 @@ test('schema snapshot: detect envelope has detections with id, score, box', () =
assert.ok(det.box, 'missing detection.box');
assert.equal(det.box.length, 4, 'box must have 4 points');
});

// --- doctor subcommand tests ---
const { runDoctor } = require('../../../packages/light-ocr/src/cli.cjs');

test('doctor: outputs valid JSON with required top-level fields', async () => {
const { code, stdout, stderr } = await runCli(['doctor']);
assert.equal(code, EXIT.success);
assert.equal(stderr, '');
const result = JSON.parse(stdout);
assert.equal(result.schemaVersion, 1);
assert.ok(result.tool, 'missing tool');
assert.ok(result.model, 'missing model');
assert.ok(result.system, 'missing system');
assert.ok(result.native, 'missing native');
assert.ok(result.modules, 'missing modules');
});

test('doctor: --json flag accepted and produces valid JSON', async () => {
const { code, stdout } = await runCli(['doctor', '--json']);
assert.equal(code, EXIT.success);
const result = JSON.parse(stdout);
assert.equal(result.schemaVersion, 1);
});

test('doctor: tool section contains version info', async () => {
const { stdout } = await runCli(['doctor']);
const result = JSON.parse(stdout);
assert.equal(result.tool.command, 'light-ocr');
assert.ok(result.tool.version, 'missing tool.version');
assert.ok(result.tool.coreVersion, 'missing tool.coreVersion');
});

test('doctor: system section has Node, platform, CPU, memory', async () => {
const { stdout } = await runCli(['doctor']);
const result = JSON.parse(stdout);
assert.ok(result.system.node.startsWith('v'), 'node version should start with v');
assert.ok(result.system.platform, 'missing platform');
assert.ok(result.system.arch, 'missing arch');
assert.ok(result.system.cpuModel, 'missing cpuModel');
assert.ok(result.system.cpuCores > 0, 'cpuCores should be positive');
assert.ok(result.system.totalMemoryGB > 0, 'totalMemoryGB should be positive');
});

test('doctor: model section mirrors modelProfile', async () => {
const { stdout } = await runCli(['doctor']);
const result = JSON.parse(stdout);
assert.equal(result.model.tier, 'small');
assert.equal(result.model.maturity, 'stable');
assert.ok(result.model.model, 'missing model name');
});

test('doctor: modules section reports runtime and model availability', async () => {
const { stdout } = await runCli(['doctor']);
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');
});

test('doctor: native section has status field', async () => {
const { stdout } = await runCli(['doctor']);
const result = JSON.parse(stdout);
assert.ok(result.native.status, 'missing native.status');
// In test env without native build, status is 'unavailable' or 'error'
assert.ok(['ok', 'unavailable', 'error'].includes(result.native.status),
`unexpected native.status: ${result.native.status}`);
});

test('doctor: rejects file path argument exit 65', async () => {
const { code, stderr } = await runCli(['doctor', 'image.png']);
assert.equal(code, EXIT.invalid_argument);
assert.match(stderr, /does not accept arguments/);
});

test('doctor: rejects --format exit 65', async () => {
const { code, stderr } = await runCli(['doctor', '--format', 'json']);
assert.equal(code, EXIT.invalid_argument);
assert.match(stderr, /does not accept --format/);
});

test('doctor: rejects --model-info exit 65', async () => {
const { code, stderr } = await runCli(['doctor', '--model-info']);
assert.equal(code, EXIT.invalid_argument);
assert.match(stderr, /does not accept --model-info/);
});

test('doctor: help prints usage and --json flag', async () => {
const { code, stdout } = await runCli(['doctor', '--help']);
assert.equal(code, EXIT.success);
assert.match(stdout, /doctor/);
assert.match(stdout, /--json/);
});

test('doctor: top-level help includes doctor subcommand', async () => {
const { stdout } = await runCli(['--help']);
assert.match(stdout, /doctor/);
});

test('doctor: hostHash is 16-char hex and privacy-safe', 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)');
});

// --- 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 <n>/);
assert.match(stdout, /--max-pages <n>/);
assert.match(stdout, /--quiet/);
assert.match(stdout, /--provider/);
});

test('help: top-level help includes document subcommand', 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');
});
84 changes: 84 additions & 0 deletions bindings/node/test/document-integration.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'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',
);
});
4 changes: 2 additions & 2 deletions docs/implementation-status.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# C++ Core 与 Node-API 实施状态

更新时间:2026-07-23<br>
结论:npm `0.4.0` 已发布,N2 完成。S3 PDF 可行性 Spike 完成,决策选择 `pdfium-native`(N-API binding)作为 PDF 渲染方案(D108)。N3 文档入口能力已合并到 `@arcships/light-ocr` 包,`light-ocr document` 子命令可处理 PDF 和多页图片
更新时间:2026-07-24<br>
结论:npm `0.5.0` 准备发布,N3 文档入口完成。S3 PDF 可行性 Spike 完成,决策选择 `pdfium-native`(N-API binding)作为 PDF 渲染方案(D108)。`light-ocr document` 子命令可处理 PDF 和多页图片,`light-ocr doctor --json` 可收集系统信息

状态含义:

Expand Down
Loading
Loading