From e1ac656533cb5850c958ec0e4788a70972a4525c Mon Sep 17 00:00:00 2001 From: luojiyin Date: Tue, 14 Jul 2026 17:57:45 +0800 Subject: [PATCH 1/4] feat: add encoded JPEG and PNG input --- CMakeLists.txt | 5 ++ README.md | 9 +- README.zh-CN.md | 9 +- bindings/node/CMakeLists.txt | 6 +- bindings/node/README.md | 10 ++- bindings/node/js/index.cjs | 10 ++- bindings/node/js/index.d.ts | 2 + bindings/node/src/addon.cpp | 131 ++++++++++++++++++++++++---- bindings/node/src/encoded_image.cpp | 114 ++++++++++++++++++++++++ bindings/node/src/encoded_image.hpp | 24 +++++ bindings/node/test/adapter.test.cjs | 77 ++++++++++++++++ cmake/Dependencies.cmake | 13 ++- docs/build-and-release.md | 1 + docs/decisions.md | 4 +- docs/napi-design.md | 13 ++- models/deps.lock.json | 15 ++++ tests/fuzz/encoded_image_fuzz.cpp | 19 ++++ tools/generate_release_metadata.py | 1 + tools/npm/smoke.ts | 2 + 19 files changed, 432 insertions(+), 33 deletions(-) create mode 100644 bindings/node/src/encoded_image.cpp create mode 100644 bindings/node/src/encoded_image.hpp create mode 100644 tests/fuzz/encoded_image_fuzz.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b49737c..ad56a49 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -380,6 +380,11 @@ if(LIGHT_OCR_BUILD_FUZZERS) endfunction() light_ocr_add_fuzzer(light_ocr_fuzz_image tests/fuzz/image_fuzz.cpp) + light_ocr_add_fuzzer(light_ocr_fuzz_encoded_image + tests/fuzz/encoded_image_fuzz.cpp + bindings/node/src/encoded_image.cpp) + target_link_libraries(light_ocr_fuzz_encoded_image PRIVATE light_ocr::stb) + target_include_directories(light_ocr_fuzz_encoded_image PRIVATE bindings/node/src) light_ocr_add_fuzzer(light_ocr_fuzz_bundle tests/fuzz/bundle_fuzz.cpp) light_ocr_add_fuzzer(light_ocr_fuzz_geometry tests/fuzz/geometry_fuzz.cpp) light_ocr_add_fuzzer(light_ocr_fuzz_lifecycle tests/fuzz/lifecycle_fuzz.cpp diff --git a/README.md b/README.md index c731e09..a5b21fa 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ It is made for products where OCR should feel like a local capability: quick to | **On-premise and edge software** | Run a consistent OCR model in kiosks, terminals, appliances, or controlled networks where a cloud dependency is undesirable. | | **Native and Node.js services** | Embed OCR directly instead of deploying and supervising a separate Python process or OCR daemon. | -The current model is best suited to general text detection and recognition in CJK/Latin mixed content. PDF rendering, encoded-image decoding, document layout analysis, tables, formulas, and translation remain the host application's responsibility. +The current model is best suited to general text detection and recognition in CJK/Latin mixed content. The Node.js adapter can decode in-memory JPEG and PNG inputs; the native core still accepts decoded pixels only. PDF rendering, other image formats, document layout analysis, tables, formulas, and translation remain the host application's responsibility. ## Why this project exists Cloud OCR is convenient, but it introduces uploads, network availability, recurring cost, and a new privacy boundary. Operating-system OCR APIs avoid the network, but their behavior and availability vary by platform. PaddleOCR offers excellent models, while its usual Python deployment is not always a natural fit for desktop software, native products, or a Node.js application. -`light-ocr` closes that gap with one reusable native core built around official PP-OCRv6 Small models. Applications keep control of image decoding, scheduling, storage, and user experience; the library focuses on turning pixels into structured OCR results. +`light-ocr` closes that gap with one reusable native core built around official PP-OCRv6 Small models. Applications keep control of scheduling, storage, and user experience; the library focuses on turning images into structured OCR results while preserving a raw-pixel native boundary. ## Why use light-ocr @@ -105,6 +105,7 @@ The package installs the matching native runtime and the pinned PP-OCRv6 Small m ```ts import { createEngine } from "@arcships/light-ocr"; +import { readFile } from "node:fs/promises"; const engine = await createEngine(); const result = await engine.recognize({ @@ -114,8 +115,12 @@ const result = await engine.recognize({ stride, pixelFormat: "rgba8", }); +const encodedResult = await engine.recognizeEncoded( + await readFile("image.jpg"), +); console.log(result.lines); +console.log(encodedResult.lines); await engine.close(); ``` diff --git a/README.zh-CN.md b/README.zh-CN.md index e005725..4af13c9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,13 +28,13 @@ | **本地部署与边缘软件** | 在自助终端、设备、边缘节点或受控网络中运行一致的 OCR 模型,摆脱云服务依赖。 | | **原生与 Node.js 服务** | 把 OCR 直接嵌入应用,不再单独部署和维护 Python 进程或 OCR daemon。 | -当前模型主要面向常规文字检测和 CJK/拉丁字符混排识别。PDF 渲染、编码图片解码、文档版面分析、表格、公式和翻译仍由宿主应用负责。 +当前模型主要面向常规文字检测和 CJK/拉丁字符混排识别。Node.js 适配器可以解码内存中的 JPEG/PNG;原生 Core 仍只接受解码后的像素。PDF 渲染、其他图片格式、文档版面分析、表格、公式和翻译仍由宿主应用负责。 ## 为什么要做 light-ocr 云 OCR 使用方便,但也带来了图片上传、网络可用性、持续成本和新的隐私边界。操作系统 OCR API 不依赖网络,但各个平台的能力与行为并不一致。PaddleOCR 提供了优秀的模型,不过常见的 Python 部署方式并不总适合桌面软件、原生产品和 Node.js 应用。 -`light-ocr` 希望补上这块空白:围绕官方 PP-OCRv6 Small 模型,提供一套可复用的原生核心。应用继续掌控图片解码、任务调度、数据存储和用户体验;light-ocr 专注于把像素稳定地转换为结构化 OCR 结果。 +`light-ocr` 希望补上这块空白:围绕官方 PP-OCRv6 Small 模型,提供一套可复用的原生核心。应用继续掌控任务调度、数据存储和用户体验;light-ocr 在保留原生 raw-pixel 边界的同时,把图片稳定地转换为结构化 OCR 结果。 ## light-ocr 的优势 @@ -105,6 +105,7 @@ npm install @arcships/light-ocr ```ts import { createEngine } from "@arcships/light-ocr"; +import { readFile } from "node:fs/promises"; const engine = await createEngine(); const result = await engine.recognize({ @@ -114,8 +115,12 @@ const result = await engine.recognize({ stride, pixelFormat: "rgba8", }); +const encodedResult = await engine.recognizeEncoded( + await readFile("image.jpg"), +); console.log(result.lines); +console.log(encodedResult.lines); await engine.close(); ``` diff --git a/bindings/node/CMakeLists.txt b/bindings/node/CMakeLists.txt index 00c518e..2d8cbc8 100644 --- a/bindings/node/CMakeLists.txt +++ b/bindings/node/CMakeLists.txt @@ -7,13 +7,17 @@ endif() add_library(light_ocr_node MODULE src/addon.cpp src/bundle_loader.cpp + src/encoded_image.cpp ) target_include_directories(light_ocr_node PRIVATE "${LIGHT_OCR_NODE_INCLUDE_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}/src" ) -target_link_libraries(light_ocr_node PRIVATE light_ocr::core Threads::Threads) +target_link_libraries(light_ocr_node PRIVATE + light_ocr::core + light_ocr::stb + Threads::Threads) target_compile_definitions(light_ocr_node PRIVATE NAPI_VERSION=8) set_target_properties(light_ocr_node PROPERTIES PREFIX "" diff --git a/bindings/node/README.md b/bindings/node/README.md index 745ea1c..2b7b02d 100644 --- a/bindings/node/README.md +++ b/bindings/node/README.md @@ -15,13 +15,14 @@ npm install @arcships/light-ocr - 原始 Node-API C API,编译为 `NAPI_VERSION=8`,不依赖 `node-addon-api`。 - `createEngine()`、`recognize()`、`close()` 全部返回 Promise。 - 每个 engine 一条专用 C++ worker thread 和一个有界 FIFO;推理不占 JavaScript 线程或 libuv 共享线程池。 -- 输入只接受 `Uint8Array` raw pixels:`gray8`、`rgb8`、`bgr8`、`rgba8`。 +- `recognize()` 接受 `Uint8Array` raw pixels:`gray8`、`rgb8`、`bgr8`、`rgba8`。 +- `recognizeEncoded()` 接受内存中的 JPEG/PNG `Uint8Array`;格式自动检测,解码在 engine worker 中执行。 - `recognize()` 返回前同步复制本次调用实际需要的像素范围;调用返回后可以立即修改或复用原 Buffer。 - 支持 `AbortSignal` 协作式取消:queued 请求会从队列移除;running 请求立即拒绝 public Promise,但 Core 会安全运行到返回并丢弃结果。 - native addon 只接收现有绝对 bundle 目录。当前源码开发调用显式传 `bundlePath`;发布后的 facade 默认使用随 npm 安装的 model package 路径。 - 产品 engine 默认报告 `detectionStrategy: 'bounded'`、`detectionMaxSide: 960` 和 `defaultRecognitionBatchSize: 1`。`detection: {strategy: 'upstreamExact'}` 只用于显式上游对照;单次 `recognize({detectionMaxSide})` 只能继续降低 bounded engine 的 side。 -v1 不支持 encoded image、zero-copy/transfer、运行中 inference 硬中断、Electron 或 Bun。详细契约见 [Node-API 设计](../../docs/napi-design.md)。 +不支持 WebP、GIF、PDF、EXIF orientation 自动旋转、zero-copy/transfer、运行中 inference 硬中断、Electron 或 Bun。详细契约见 [Node-API 设计](../../docs/napi-design.md)。 ## 本地构建 @@ -96,7 +97,12 @@ async function main() { signal: controller.signal, }, ); + const encodedResult = await engine.recognizeEncoded( + await require('node:fs/promises').readFile('/absolute/path/to/image.jpg'), + { signal: controller.signal }, + ); console.log(result.lines); + console.log(encodedResult.lines); } catch (error) { if (error instanceof OcrError) console.error(error.code, error.message, error.detail); else throw error; // 包括调用方提供的 AbortSignal.reason diff --git a/bindings/node/js/index.cjs b/bindings/node/js/index.cjs index 93a1906..41d01f0 100644 --- a/bindings/node/js/index.cjs +++ b/bindings/node/js/index.cjs @@ -117,6 +117,14 @@ class OcrEngineImpl { } recognize(image, options = {}) { + return this.#recognize('recognize', image, options); + } + + recognizeEncoded(data, options = {}) { + return this.#recognize('recognizeEncoded', data, options); + } + + #recognize(nativeMethod, image, options) { let signal; let nativeOptions; try { @@ -136,7 +144,7 @@ class OcrEngineImpl { let operation; try { - operation = this.#native.recognize(image, nativeOptions); + operation = this.#native[nativeMethod](image, nativeOptions); } catch (error) { return Promise.reject(normalizeNativeError(error)); } diff --git a/bindings/node/js/index.d.ts b/bindings/node/js/index.d.ts index 5efac54..8534f85 100644 --- a/bindings/node/js/index.d.ts +++ b/bindings/node/js/index.d.ts @@ -94,6 +94,7 @@ export interface Diagnostics { } export interface TimingUs { readonly total: number; + readonly decode: number; readonly inputValidation: number; readonly detectionPreprocess: number; readonly detectionInference: number; @@ -170,6 +171,7 @@ export class OcrError extends Error { export interface OcrEngine { readonly info: EngineInfo; recognize(image: RawImage, options?: RecognizeOptions): Promise; + recognizeEncoded(data: Uint8Array, options?: RecognizeOptions): Promise; close(): Promise; } diff --git a/bindings/node/src/addon.cpp b/bindings/node/src/addon.cpp index 4ab9038..e87d450 100644 --- a/bindings/node/src/addon.cpp +++ b/bindings/node/src/addon.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,7 @@ #include #include "bundle_loader.hpp" +#include "encoded_image.hpp" #include "light_ocr/core.hpp" namespace light_ocr::node { @@ -557,6 +559,7 @@ struct ImageSnapshot { std::uint32_t height = 0; std::size_t stride = 0; PixelFormat pixel_format = PixelFormat::bgr8; + bool encoded = false; }; ImageSnapshot copy_image(const ParsedImage& parsed) { @@ -572,6 +575,56 @@ ImageSnapshot copy_image(const ParsedImage& parsed) { return snapshot; } +struct ParsedEncodedImage { + const std::uint8_t* data = nullptr; + std::size_t size = 0; +}; + +ParsedEncodedImage parse_encoded_image(napi_env env, napi_value value) { + bool typed = false; + check(env, napi_is_typedarray(env, value, &typed), "check encoded image typed array"); + if (!typed) { + throw AddonFailure("invalid_image", "encoded image must be a Uint8Array"); + } + napi_typedarray_type type = napi_int8_array; + std::size_t length = 0; + void* data = nullptr; + napi_value backing = nullptr; + std::size_t byte_offset = 0; + check(env, + napi_get_typedarray_info(env, value, &type, &length, &data, &backing, &byte_offset), + "read encoded image typed array"); + (void)byte_offset; + if (type != napi_uint8_array) { + throw AddonFailure("invalid_image", "encoded image must be a Uint8Array"); + } + bool array_buffer = false; + check(env, napi_is_arraybuffer(env, backing, &array_buffer), + "check encoded image ArrayBuffer"); + if (!array_buffer) { + throw AddonFailure("invalid_image", + "SharedArrayBuffer-backed encoded images are unsupported"); + } + bool detached = false; + check(env, napi_is_detached_arraybuffer(env, backing, &detached), + "check detached encoded image ArrayBuffer"); + if (detached) { + throw AddonFailure("invalid_image", "encoded image ArrayBuffer is detached"); + } + if (length == 0 || data == nullptr) { + throw AddonFailure("invalid_image", "encoded image is empty"); + } + return ParsedEncodedImage{static_cast(data), length}; +} + +ImageSnapshot copy_encoded_image(const ParsedEncodedImage& parsed) { + ImageSnapshot snapshot; + snapshot.encoded = true; + snapshot.bytes.resize(parsed.size); + std::memcpy(snapshot.bytes.data(), parsed.data, parsed.size); + return snapshot; +} + struct EnvContext; struct EngineState; @@ -585,6 +638,7 @@ struct Request { RequestStatus status = RequestStatus::queued; bool discard_result = false; bool operation_live = false; + std::uint64_t decode_us = 0; }; enum class CompletionKind { create, recognize, maintenance, close, reap }; @@ -805,14 +859,27 @@ void EngineState::run() { } const auto snapshot_size = static_cast(request->image.bytes.size()); - ImageView view; - view.data = request->image.bytes.data(); - view.size = request->image.bytes.size(); - view.width = request->image.width; - view.height = request->image.height; - view.stride = request->image.stride; - view.pixel_format = request->image.pixel_format; - auto result = core->recognize(view, request->options); + auto result = [&]() -> Result { + if (request->image.encoded) { + const auto decode_begin = std::chrono::steady_clock::now(); + auto decoded_result = decode_encoded_image(request->image.bytes, info.limits); + request->decode_us = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - decode_begin) + .count()); + if (!decoded_result) { + return Result::failure(decoded_result.error()); + } + auto decoded = std::move(decoded_result).value(); + const ImageView view{decoded.bytes.data(), decoded.bytes.size(), decoded.width, + decoded.height, decoded.stride, decoded.pixel_format}; + return core->recognize(view, request->options); + } + const ImageView view{request->image.bytes.data(), request->image.bytes.size(), + request->image.width, request->image.height, + request->image.stride, request->image.pixel_format}; + return core->recognize(view, request->options); + }(); bool discard = false; { @@ -937,7 +1004,8 @@ napi_value create_line(napi_env env, const OcrLine& line) { return object; } -napi_value create_timing(napi_env env, const Timing& timing) { +napi_value create_timing(napi_env env, const Timing& timing, + std::uint64_t decode_us) { napi_value object = nullptr; check(env, napi_create_object(env, &object), "create timing"); const auto set = [&](const char* name, std::uint64_t value) { @@ -946,7 +1014,11 @@ napi_value create_timing(napi_env env, const Timing& timing) { } set_named(env, object, name, double_value(env, static_cast(value))); }; - set("total", timing.total_us); + if (decode_us > std::numeric_limits::max() - timing.total_us) { + throw NapiFailure("total timing overflows"); + } + set("total", timing.total_us + decode_us); + set("decode", decode_us); set("inputValidation", timing.input_validation_us); set("detectionPreprocess", timing.detection_preprocess_us); set("detectionInference", timing.detection_inference_us); @@ -1051,7 +1123,8 @@ napi_value create_diagnostics(napi_env env, const Diagnostics& diagnostics) { return object; } -napi_value create_result(napi_env env, const OcrResult& result) { +napi_value create_result(napi_env env, const OcrResult& result, + std::uint64_t decode_us) { napi_value object = nullptr; check(env, napi_create_object(env, &object), "create OCR result"); napi_value lines = nullptr; @@ -1065,7 +1138,7 @@ napi_value create_result(napi_env env, const OcrResult& result) { set_named(env, object, "imageWidth", uint32_value(env, result.image_width)); set_named(env, object, "imageHeight", uint32_value(env, result.image_height)); set_named(env, object, "modelBundleId", string_value(env, result.model_bundle_id)); - set_named(env, object, "timingUs", create_timing(env, result.timing)); + set_named(env, object, "timingUs", create_timing(env, result.timing, decode_us)); if (result.diagnostics) { set_named(env, object, "diagnostics", create_diagnostics(env, *result.diagnostics)); } @@ -1170,7 +1243,8 @@ std::shared_ptr unwrap_engine(napi_env env, napi_value value) { return *static_cast*>(data); } -napi_value native_recognize(napi_env env, napi_callback_info callback_info) { +napi_value native_recognize_impl(napi_env env, napi_callback_info callback_info, + bool encoded) { std::shared_ptr engine; std::uint64_t snapshot_size = 0; bool reservation_live = false; @@ -1215,8 +1289,15 @@ napi_value native_recognize(napi_env env, napi_callback_info callback_info) { } auto options = parse_recognize_options( env, argument_count >= 2 ? arguments[1] : nullptr, info); - const auto parsed_image = parse_image(env, arguments[0], info); - snapshot_size = static_cast(parsed_image.required_bytes); + std::optional parsed_image; + std::optional parsed_encoded_image; + if (encoded) { + parsed_encoded_image = parse_encoded_image(env, arguments[0]); + snapshot_size = static_cast(parsed_encoded_image->size); + } else { + parsed_image = parse_image(env, arguments[0], info); + snapshot_size = static_cast(parsed_image->required_bytes); + } if (snapshot_size > engine->create_options.max_pending_input_bytes) { throw AddonFailure("resource_limit_exceeded", "image snapshot exceeds maxPendingInputBytes"); @@ -1237,7 +1318,8 @@ napi_value native_recognize(napi_env env, napi_callback_info callback_info) { reservation_live = true; } - auto snapshot = copy_image(parsed_image); + auto snapshot = encoded ? copy_encoded_image(*parsed_encoded_image) + : copy_image(*parsed_image); auto request = std::make_shared(); request->id = engine->context->next_request_id.fetch_add(1); if (request->id == 0) { @@ -1298,6 +1380,15 @@ napi_value native_recognize(napi_env env, napi_callback_info callback_info) { return nullptr; } +napi_value native_recognize(napi_env env, napi_callback_info callback_info) { + return native_recognize_impl(env, callback_info, false); +} + +napi_value native_recognize_encoded(napi_env env, + napi_callback_info callback_info) { + return native_recognize_impl(env, callback_info, true); +} + napi_value native_cancel(napi_env env, napi_callback_info callback_info) { try { napi_value argument = nullptr; @@ -1422,8 +1513,10 @@ void finalize_engine(napi_env, void* data, void*) { napi_value create_native_engine(napi_env env, const std::shared_ptr& engine) { napi_value object = nullptr; check(env, napi_create_object(env, &object), "create native engine"); - const std::array properties{{ + const std::array properties{{ {"recognize", nullptr, native_recognize, nullptr, nullptr, nullptr, napi_default, nullptr}, + {"recognizeEncoded", nullptr, native_recognize_encoded, nullptr, nullptr, nullptr, + napi_default, nullptr}, {"cancel", nullptr, native_cancel, nullptr, nullptr, nullptr, napi_default, nullptr}, {"close", nullptr, native_close, nullptr, nullptr, nullptr, napi_default, nullptr}, }}; @@ -1500,7 +1593,9 @@ void call_js(napi_env env, napi_value, void*, void* data) { error.message, error.detail)), "reject recognition"); } else { - check(env, napi_resolve_deferred(env, deferred, create_result(env, *completion->result)), + check(env, napi_resolve_deferred( + env, deferred, + create_result(env, *completion->result, request->decode_us)), "resolve recognition"); } } diff --git a/bindings/node/src/encoded_image.cpp b/bindings/node/src/encoded_image.cpp new file mode 100644 index 0000000..4f3b015 --- /dev/null +++ b/bindings/node/src/encoded_image.cpp @@ -0,0 +1,114 @@ +#include "encoded_image.hpp" + +#include +#include +#include +#include +#include +#include + +#define STBI_ONLY_JPEG +#define STBI_ONLY_PNG +#define STBI_NO_STDIO +#define STBI_FAILURE_USERMSG +#define STB_IMAGE_IMPLEMENTATION +#include + +namespace light_ocr::node { +namespace { + +Result failure(ErrorCode code, std::string message, + std::string detail = {}) { + return Result::failure( + Error{code, std::move(message), std::move(detail)}); +} + +std::string decoder_detail() { + const char* reason = stbi_failure_reason(); + return reason == nullptr ? std::string{} : std::string(reason); +} + +} // namespace + +Result decode_encoded_image( + const std::vector& encoded, + const ResourceLimits& limits) noexcept { + try { + if (encoded.empty()) { + return failure(ErrorCode::invalid_image, "Encoded image is empty"); + } + if (encoded.size() > static_cast(INT_MAX)) { + return failure(ErrorCode::resource_limit_exceeded, + "Encoded image exceeds decoder limits"); + } + + int width = 0; + int height = 0; + int source_channels = 0; + const auto* data = encoded.data(); + const int size = static_cast(encoded.size()); + if (stbi_info_from_memory(data, size, &width, &height, &source_channels) == 0 || + width <= 0 || height <= 0) { + return failure(ErrorCode::invalid_image, + "Input is not a supported JPEG or PNG image", decoder_detail()); + } + + const auto decoded_width = static_cast(width); + const auto decoded_height = static_cast(height); + if (decoded_width > limits.max_width || decoded_height > limits.max_height || + decoded_width > std::numeric_limits::max() / decoded_height || + decoded_width * decoded_height > limits.max_pixels) { + return failure(ErrorCode::resource_limit_exceeded, + "Decoded image dimensions exceed engine limits"); + } + constexpr std::uint64_t kOutputChannels = 3; + const std::uint64_t pixels = decoded_width * decoded_height; + if (pixels > std::numeric_limits::max() / kOutputChannels) { + return failure(ErrorCode::resource_limit_exceeded, + "Decoded image byte size overflows"); + } + const std::uint64_t decoded_bytes = pixels * kOutputChannels; + // The decoded RGB pixels coexist first with stb's output and later with + // Core's BGR conversion, so reserve for two full decoded buffers. + if (decoded_bytes > limits.max_temporary_bytes / 2 || + decoded_bytes > std::numeric_limits::max()) { + return failure(ErrorCode::resource_limit_exceeded, + "Decoded image exceeds the temporary memory budget"); + } + + using StbiPixels = std::unique_ptr; + StbiPixels pixels_data( + stbi_load_from_memory(data, size, &width, &height, &source_channels, + static_cast(kOutputChannels)), + stbi_image_free); + if (!pixels_data) { + return failure(ErrorCode::invalid_image, "Failed to decode JPEG or PNG image", + decoder_detail()); + } + if (static_cast(width) != decoded_width || + static_cast(height) != decoded_height) { + return failure(ErrorCode::invalid_image, + "Encoded image dimensions changed during decoding"); + } + + DecodedImage result; + result.bytes.assign(pixels_data.get(), + pixels_data.get() + static_cast(decoded_bytes)); + result.width = static_cast(decoded_width); + result.height = static_cast(decoded_height); + result.stride = static_cast(decoded_width * kOutputChannels); + result.pixel_format = PixelFormat::rgb8; + return Result::success(std::move(result)); + } catch (const std::bad_alloc&) { + return failure(ErrorCode::resource_limit_exceeded, + "Encoded image decoding ran out of memory"); + } catch (const std::exception& exception) { + return failure(ErrorCode::internal_error, + "Unexpected encoded image decoding failure", exception.what()); + } catch (...) { + return failure(ErrorCode::internal_error, + "Unknown encoded image decoding failure"); + } +} + +} // namespace light_ocr::node diff --git a/bindings/node/src/encoded_image.hpp b/bindings/node/src/encoded_image.hpp new file mode 100644 index 0000000..4d1c6f5 --- /dev/null +++ b/bindings/node/src/encoded_image.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +#include "light_ocr/error.hpp" +#include "light_ocr/types.hpp" + +namespace light_ocr::node { + +struct DecodedImage { + std::vector bytes; + std::uint32_t width = 0; + std::uint32_t height = 0; + std::size_t stride = 0; + PixelFormat pixel_format = PixelFormat::rgb8; +}; + +Result decode_encoded_image( + const std::vector& encoded, + const ResourceLimits& limits) noexcept; + +} // namespace light_ocr::node diff --git a/bindings/node/test/adapter.test.cjs b/bindings/node/test/adapter.test.cjs index 03f5bc0..9ddb639 100644 --- a/bindings/node/test/adapter.test.cjs +++ b/bindings/node/test/adapter.test.cjs @@ -15,6 +15,15 @@ const bundlePath = path.resolve( path.join(repositoryRoot, 'models/generated/ppocrv6-small-onnx-20260714.2'), ); +const encodedBlankPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAFUlEQVR4nGP8//8/AwMDEwMDA4ICADkbAwP+wj6MAAAAAElFTkSuQmCC', + 'base64', +); +const encodedBlankJpeg = Buffer.from( + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAADAAIDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD3+iiigD//2Q==', + 'base64', +); + function loadFixture(id) { const directory = path.join(repositoryRoot, 'corpus/fixtures', id); const metadata = JSON.parse(fs.readFileSync(path.join(directory, 'fixture.json'), 'utf8')); @@ -72,6 +81,7 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', assert.equal(result.diagnostics.detectionPasses.length, 1); assert.equal(result.timingUs.detectionMerge, 0); assert.deepEqual(result.diagnostics.recognitionBatchShapes.map((shape) => shape.batchSize), [1]); + assert.equal(result.timingUs.decode, 0); const closeA = engine.close(); const closeB = engine.close(); @@ -83,6 +93,73 @@ test('loads PP-OCRv6, snapshots pixels, maps results, and closes idempotently', ); }); +test('decodes JPEG and PNG snapshots on the engine worker', async () => { + const engine = await createEngine({ bundlePath }); + const png = Buffer.from(encodedBlankPng); + const pngRecognition = engine.recognizeEncoded(png); + png.fill(0); + const pngResult = await pngRecognition; + assert.equal(pngResult.imageWidth, 2); + assert.equal(pngResult.imageHeight, 3); + assert.deepEqual(pngResult.lines, []); + assert.ok(Number.isSafeInteger(pngResult.timingUs.decode)); + + const jpegResult = await engine.recognizeEncoded(encodedBlankJpeg); + assert.equal(jpegResult.imageWidth, 2); + assert.equal(jpegResult.imageHeight, 3); + assert.deepEqual(jpegResult.lines, []); + await engine.close(); +}); + +test('rejects malformed and unsupported encoded images safely', async () => { + const engine = await createEngine({ bundlePath }); + await assert.rejects( + engine.recognizeEncoded(Buffer.from('not an image')), + (error) => error instanceof OcrError && error.code === 'invalid_image', + ); + await assert.rejects( + engine.recognizeEncoded(new Uint8Array()), + (error) => error instanceof OcrError && error.code === 'invalid_image', + ); + await assert.rejects( + engine.recognizeEncoded(new Uint16Array([1, 2, 3])), + (error) => error instanceof OcrError && error.code === 'invalid_image', + ); + const oversizedPng = Buffer.from(encodedBlankPng); + oversizedPng.writeUInt32BE(engine.info.limits.maxWidth + 1, 16); + await assert.rejects( + engine.recognizeEncoded(oversizedPng), + (error) => error instanceof OcrError && error.code === 'resource_limit_exceeded', + ); + if (typeof SharedArrayBuffer === 'function') { + await assert.rejects( + engine.recognizeEncoded(new Uint8Array(new SharedArrayBuffer(16))), + (error) => error instanceof OcrError && error.code === 'invalid_image', + ); + } + await engine.close(); + + const limits = engine.info.limits; + const memoryLimited = await createEngine({ + bundlePath, + reducedLimits: { + maxWidth: limits.maxWidth, + maxHeight: limits.maxHeight, + maxPixels: limits.maxPixels, + maxDetectionSide: limits.maxDetectionSide, + maxDetectionCandidates: limits.maxDetectionCandidates, + maxRecognitionBatchSize: limits.maxRecognitionBatchSize, + maxRecognitionWidth: limits.maxRecognitionWidth, + maxTemporaryBytes: 17, + }, + }); + await assert.rejects( + memoryLimited.recognizeEncoded(encodedBlankPng), + (error) => error instanceof OcrError && error.code === 'resource_limit_exceeded', + ); + await memoryLimited.close(); +}); + test('validates input and reports adapter errors as OcrError', async () => { await assert.rejects( createEngine({ model: 'ppocrv6-small', bundlePath }), diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index f339a2f..aeda171 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -45,6 +45,13 @@ function(light_ocr_configure_dependencies) URL_HASH SHA256=2be14496a1609fa8602d9d3672c83ee95d5ef44a08b765a60e65b93a68882ff6 DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + light_ocr_archive_url(_stb_url stb-31c1ad374564.tar.gz + https://codeload.github.com/nothings/stb/tar.gz/31c1ad37456438565541f4919958214b6e762fb4) + FetchContent_Declare(stb + URL "${_stb_url}" + URL_HASH SHA256=e4e3bba9c572a4a4148373a914d88ea0f0d11de8cc2c66739926e7eca0223319 + DOWNLOAD_EXTRACT_TIMESTAMP TRUE) + light_ocr_archive_url(_opencv_url opencv-4.10.0.tar.gz https://codeload.github.com/opencv/opencv/tar.gz/refs/tags/4.10.0) set(BUILD_LIST core,imgproc CACHE STRING "" FORCE) @@ -116,7 +123,11 @@ function(light_ocr_configure_dependencies) URL_HASH SHA256=d571e63a2329baacb713f441e65ad75284de354db6e1ac435fe4bebbb417986a DOWNLOAD_EXTRACT_TIMESTAMP TRUE) - FetchContent_MakeAvailable(nlohmann_json clipper opencv onnxruntime_package) + FetchContent_MakeAvailable(nlohmann_json clipper stb opencv onnxruntime_package) + + add_library(light_ocr_stb INTERFACE) + add_library(light_ocr::stb ALIAS light_ocr_stb) + target_include_directories(light_ocr_stb SYSTEM INTERFACE "${stb_SOURCE_DIR}") add_library(light_ocr_clipper STATIC "${clipper_SOURCE_DIR}/src/clipper.cpp") add_library(light_ocr::clipper ALIAS light_ocr_clipper) diff --git a/docs/build-and-release.md b/docs/build-and-release.md index cbac14a..d8d5dad 100644 --- a/docs/build-and-release.md +++ b/docs/build-and-release.md @@ -47,6 +47,7 @@ macOS 的 `CMAKE_OSX_DEPLOYMENT_TARGET` 默认固定为 `13.3`。当前没有对 | ONNX Runtime | 1.22.0 | CPU Execution Provider | | OpenCV | 4.10.0 | 仅 `core`、`imgproc`;静态构建 | | Clipper | 6.4.2,来自 pyclipper 1.3.0.post6 | 与 PaddleOCR 的 pyclipper 整数 offset 行为一致 | +| stb | commit `31c1ad374564` | Node adapter 的内存 JPEG/PNG 解码;关闭 stdio 和其他格式 | | nlohmann/json | 3.11.3 | 有界 bundle JSON 解析 | OpenCV 同时带入锁中声明的 zlib 1.3.1 与 Carotene 0.0.1。项目自己的 SHA-256 实现用于 bundle 完整性。 diff --git a/docs/decisions.md b/docs/decisions.md index bdeb126..55723d6 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -70,9 +70,9 @@ Consequence: Validation packages must be built for each Tier 1 target; consumers ### D008 — Accept decoded pixels only Status: Accepted -Decision: The public boundary accepts validated `GRAY8`, `RGB8`, `BGR8`, or `RGBA8` memory views. Encoded images and documents are decoded by the caller. +Decision: The C++ Core boundary accepts validated `GRAY8`, `RGB8`, `BGR8`, or `RGBA8` memory views. Host adapters may offer bounded decoding before crossing that boundary; the Node adapter supports in-memory JPEG/PNG. Documents and other formats are decoded by the caller. Reason: Decoding greatly expands format, security, dependency, and platform scope without improving the OCR algorithms. -Consequence: Fixtures cross the C++ boundary as raw pixels, and parity compares identical decoded bytes. +Consequence: Fixtures cross the C++ boundary as raw pixels, parity compares identical decoded bytes, and adapter decoders require their own dependency, security-limit, and format tests. ### D009 — Support four Tier 1 targets diff --git a/docs/napi-design.md b/docs/napi-design.md index cc217b6..6df79b1 100644 --- a/docs/napi-design.md +++ b/docs/napi-design.md @@ -39,7 +39,7 @@ Decision:[decisions.md](decisions.md) D101、D105 ### 2.2 v1 非目标 -- PNG、JPEG、WebP、PDF 等 encoded input 解码。 +- WebP、GIF、PDF 等输入解码;JPEG/PNG 由受限的内存 decoder 支持。 - install/postinstall 或运行时网络下载、默认目录扫描或模型自动更新。 - 无模型瘦包、按语言拆分模型、tiny/medium/orientation 模型。 - 对运行中的 ONNX Runtime inference 做硬中断或强制超时终止。 @@ -171,6 +171,7 @@ export interface Diagnostics { export interface TimingUs { readonly total: number; + readonly decode: number; readonly inputValidation: number; readonly detectionPreprocess: number; readonly detectionInference: number; @@ -264,6 +265,7 @@ export interface OcrEngine { /** Deep-frozen snapshot created after native engine initialization. */ readonly info: EngineInfo; recognize(image: RawImage, options?: RecognizeOptions): Promise; + recognizeEncoded(data: Uint8Array, options?: RecognizeOptions): Promise; /** Idempotent: stop admission, drain accepted work, release native state. */ close(): Promise; } @@ -271,7 +273,7 @@ export interface OcrEngine { export function createEngine(options?: CreateEngineOptions): Promise; ``` -`Buffer` 是 `Uint8Array` 的子类,因此可以直接作为 `RawImage.data`。v1 不接受 `DataView`、其他 TypedArray 或以 `SharedArrayBuffer` 为 backing store 的 `Uint8Array`。 +`Buffer` 是 `Uint8Array` 的子类,因此可以直接作为 `RawImage.data` 或 `recognizeEncoded()` 输入。不接受 `DataView`、其他 TypedArray 或以 `SharedArrayBuffer` 为 backing store 的 `Uint8Array`。 `OcrEngine` 没有 public constructor,只能由成功的 `createEngine` 创建。未传 `model`/`bundlePath` 时默认使用内置 `ppocrv6-small`;二者同时出现是 `invalid_argument`。`reducedLimits` 一旦提供就必须包含全部八个字段;适配器把 Core 固定的 `maxConcurrentCalls=1` 补入 native options。所有配置对象拒绝未知 own property,避免拼写错误被静默忽略。预期的参数、package、I/O、Core 和队列错误都通过 Promise rejection 返回 `OcrError`;取消按 `AbortSignal.reason` 拒绝,默认 `AbortController.abort()` 因而得到标准 `AbortError`。只有非法 receiver、Node-API 无法创建 Promise 或不可恢复的运行时故障可能同步抛出。 @@ -311,6 +313,7 @@ JavaScript 使用 camelCase,C++ 使用 snake_case;除命名外不改变值 | `Quad.points[4]` | `box: [Point, Point, Point, Point]` | 保留顺序和浮点坐标 | | `OcrLine::confidence` | `confidence` | 不重标定、不四舍五入 | | `Timing::*_us` | `timingUs.*` | 保持微秒单位 | +| adapter decode duration | `timingUs.decode` | raw 为零;encoded 时计入 `total` | | `optional` | `diagnostics?` | 未请求时属性缺失 | | `ErrorCode` | `OcrError.code` | Core 字符串逐字保持 | | `Error::detail` | `OcrError.detail` | 空字符串映射为属性缺失 | @@ -357,6 +360,8 @@ POSIX 实现使用 directory-relative、no-follow 的打开方式;Windows 使 6. 分配 native vector,并复制恰好 `requiredBytes`;不复制 view 末尾无关字节。 7. 将只含 native 数据的请求入队。 +`recognizeEncoded()` 复用相同 options、admission、snapshot、AbortSignal 和 completion 语义,但 JavaScript 线程只校验并复制 encoded bytes。worker 使用关闭 stdio 且只启用 JPEG/PNG 的 `stb_image` 自动检测格式,先读取 dimensions 并按 effective `maxWidth`、`maxHeight`、`maxPixels` 和 `maxTemporaryBytes` 拒绝超限输入,再解码为 RGB8 后进入不变的 Core raw-pixel API。临时内存检查为 decoder 输出与 RGB→BGR 转换的双缓冲保留空间。EXIF orientation 不自动应用。`timingUs.decode` 记录 worker 解码时间,并计入 `timingUs.total`;raw input 的 `decode` 固定为零。 + native admission 返回一个不导出的 `{ requestId, promise }`。JS facade 在 public Promise settled 前监听一次 signal;abort 时调用 private native cancel,并立即按 `signal.reason` reject public Promise。native promise 始终安装 fulfillment/rejection handler,所以取消后晚到的内部 completion 不会形成 unhandled rejection。listener 在 success、error 或 abort 任一路径只移除一次。 必须先预留、后复制。队列或字节预算不足时直接以 `queue_full` 拒绝,不能先复制大图再发现背压。分配或复制失败会释放预留并拒绝 Promise。 @@ -376,7 +381,7 @@ native admission 返回一个不导出的 `{ requestId, promise }`。JS facade 每个 engine 同时满足两个 admission 条件: - `running + queued recognize <= queueCapacity`;默认 4,合法范围 1..64。 -- 所有未完成请求的 pixel snapshot 总和 `<= maxPendingInputBytes`;默认 256 MiB,硬上限 1 GiB。 +- 所有未完成请求的 raw-pixel 或 encoded-byte snapshot 总和 `<= maxPendingInputBytes`;默认 256 MiB,硬上限 1 GiB。encoded 解码结果只在串行 worker 当前请求中存活,并额外受 `maxTemporaryBytes` 约束。 单个 snapshot 大于该 engine 的 `maxPendingInputBytes` 返回 `resource_limit_exceeded`;预算本身足够、但当前被其他请求占用时返回 `queue_full`。Core 的 `maxTemporaryBytes` 继续独立限制推理过程临时内存。 @@ -676,7 +681,7 @@ Node-API 解决 Node/V8 ABI 兼容,不消除 OS、architecture、libc、C++ ru | --- | --- | --- | | 独立模型镜像/公开下载页 | 延期 | npm model package 已满足默认安装;仅在需要非 npm 分发时重开 | | 无模型或多模型 package | 延期 | 有真实体积/语言/服务端部署需求并定义兼容策略 | -| encoded image | 延期 | 独立 decoder 依赖、安全限制和格式矩阵获批 | +| WebP/GIF/PDF 解码 | 延期 | 独立格式语义、安全限制和测试矩阵获批;当前仅支持 JPEG/PNG | | zero-copy/transfer | 延期 | 能证明 mutation、detachment、Worker 和 teardown 安全 | | running inference 硬中断 | 延期 | Core 或隔离层提供经过验证的安全 interruption | | Electron/Bun | 延期 | 独立 runtime/version/prebuild/lifecycle matrix 全绿 | diff --git a/models/deps.lock.json b/models/deps.lock.json index 8aa247e..d15cc72 100644 --- a/models/deps.lock.json +++ b/models/deps.lock.json @@ -41,6 +41,21 @@ "buildOptions": {"sources": ["src/clipper.cpp", "src/clipper.hpp"], "integerCoordinates": true}, "patches": [] }, + { + "name": "stb", + "version": "31c1ad37456438565541f4919958214b6e762fb4", + "filename": "stb-31c1ad374564.tar.gz", + "source": "https://codeload.github.com/nothings/stb/tar.gz/31c1ad37456438565541f4919958214b6e762fb4", + "bytes": 1516082, + "sha256": "e4e3bba9c572a4a4148373a914d88ea0f0d11de8cc2c66739926e7eca0223319", + "license": "MIT OR Unlicense", + "buildOptions": { + "components": ["stb_image"], + "formats": ["jpeg", "png"], + "stdio": false + }, + "patches": [] + }, { "name": "nlohmann-json", "version": "3.11.3", diff --git a/tests/fuzz/encoded_image_fuzz.cpp b/tests/fuzz/encoded_image_fuzz.cpp new file mode 100644 index 0000000..d57a856 --- /dev/null +++ b/tests/fuzz/encoded_image_fuzz.cpp @@ -0,0 +1,19 @@ +#include +#include +#include + +#include "encoded_image.hpp" +#include "light_ocr/types.hpp" + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, + std::size_t size) { + std::vector encoded; + if (size != 0) encoded.assign(data, data + size); + light_ocr::ResourceLimits limits; + limits.max_width = 4096; + limits.max_height = 4096; + limits.max_pixels = 16ull * 1024 * 1024; + limits.max_temporary_bytes = 128ull * 1024 * 1024; + (void)light_ocr::node::decode_encoded_image(encoded, limits); + return 0; +} diff --git a/tools/generate_release_metadata.py b/tools/generate_release_metadata.py index 10def46..7d6cb7d 100644 --- a/tools/generate_release_metadata.py +++ b/tools/generate_release_metadata.py @@ -135,6 +135,7 @@ def copy_licenses(build: Path, output: Path) -> list[dict[str, str]]: (source_dir(build, "opencv") / "COPYRIGHT", "opencv-COPYRIGHT.txt", "opencv"), (source_dir(build, "opencv") / "3rdparty" / "zlib" / "LICENSE", "opencv-zlib.txt", "zlib"), (source_dir(build, "clipper") / "LICENSE", "clipper-BSL-1.0.txt", "clipper"), + (source_dir(build, "stb") / "LICENSE", "stb-MIT-or-Unlicense.txt", "stb"), (source_dir(build, "nlohmann_json") / "LICENSE.MIT", "nlohmann-json-MIT.txt", "nlohmann-json"), ] bundle = ROOT / "models" / "generated" / "ppocrv6-small-onnx-20260714.2" diff --git a/tools/npm/smoke.ts b/tools/npm/smoke.ts index bd29a1b..48095e7 100644 --- a/tools/npm/smoke.ts +++ b/tools/npm/smoke.ts @@ -1,6 +1,7 @@ import { createEngine, type RawImage } from '@arcships/light-ocr'; declare const image: RawImage; +declare const encodedImage: Uint8Array; async function recognize(): Promise { const engine = await createEngine({ @@ -11,6 +12,7 @@ async function recognize(): Promise { const result = await engine.recognize(image, { includeDiagnostics: true, }); + await engine.recognizeEncoded(encodedImage); return result.lines.map((line) => line.text); } finally { await engine.close(); From 7691a768bddeec4a01d3a3ee6a5400bf3981d89a Mon Sep 17 00:00:00 2001 From: luojiyin Date: Tue, 14 Jul 2026 18:25:41 +0800 Subject: [PATCH 2/4] fix: enforce encoded decoder resource limits --- bindings/node/src/encoded_image.cpp | 194 +++++++++++++++++++++++++++- bindings/node/test/adapter.test.cjs | 25 +++- cmake/Dependencies.cmake | 11 +- docs/napi-design.md | 6 +- tools/generate_release_metadata.py | 17 ++- 5 files changed, 242 insertions(+), 11 deletions(-) diff --git a/bindings/node/src/encoded_image.cpp b/bindings/node/src/encoded_image.cpp index 4f3b015..6bf4454 100644 --- a/bindings/node/src/encoded_image.cpp +++ b/bindings/node/src/encoded_image.cpp @@ -1,16 +1,165 @@ #include "encoded_image.hpp" +#include #include +#include #include #include #include #include #include +namespace { + +enum class DecodeAllocationFailure { + none, + budget_exceeded, + size_overflow, + system_allocation_failed, + accounting_error, +}; + +struct DecodeBudget { + std::size_t limit = 0; + std::size_t current = 0; + std::size_t peak = 0; + DecodeAllocationFailure failure = DecodeAllocationFailure::none; +}; + +struct alignas(std::max_align_t) AllocationHeader { + std::size_t size = 0; +}; + +thread_local DecodeBudget* active_decode_budget = nullptr; + +class DecodeBudgetScope { + public: + explicit DecodeBudgetScope(DecodeBudget* budget) + : previous_(active_decode_budget) { + active_decode_budget = budget; + } + + ~DecodeBudgetScope() { active_decode_budget = previous_; } + + DecodeBudgetScope(const DecodeBudgetScope&) = delete; + DecodeBudgetScope& operator=(const DecodeBudgetScope&) = delete; + + private: + DecodeBudget* previous_ = nullptr; +}; + +bool allocation_size(std::size_t requested, std::size_t* total) { + if (requested > std::numeric_limits::max() - + sizeof(AllocationHeader)) { + return false; + } + *total = requested + sizeof(AllocationHeader); + return true; +} + +void mark_allocation_failure(DecodeAllocationFailure failure) { + if (active_decode_budget != nullptr && + active_decode_budget->failure == DecodeAllocationFailure::none) { + active_decode_budget->failure = failure; + } +} + +void* stbi_budget_malloc(std::size_t requested) { + std::size_t total = 0; + if (!allocation_size(requested, &total)) { + mark_allocation_failure(DecodeAllocationFailure::size_overflow); + return nullptr; + } + if (active_decode_budget != nullptr) { + if (active_decode_budget->current > active_decode_budget->limit || + total > active_decode_budget->limit - active_decode_budget->current) { + mark_allocation_failure(DecodeAllocationFailure::budget_exceeded); + return nullptr; + } + } + auto* header = static_cast(std::malloc(total)); + if (header == nullptr) { + mark_allocation_failure(DecodeAllocationFailure::system_allocation_failed); + return nullptr; + } + header->size = total; + if (active_decode_budget != nullptr) { + active_decode_budget->current += total; + active_decode_budget->peak = + std::max(active_decode_budget->peak, active_decode_budget->current); + } + return header + 1; +} + +void stbi_budget_free(void* pointer) { + if (pointer == nullptr) return; + auto* header = static_cast(pointer) - 1; + if (active_decode_budget != nullptr) { + if (header->size > active_decode_budget->current) { + mark_allocation_failure(DecodeAllocationFailure::accounting_error); + active_decode_budget->current = 0; + } else { + active_decode_budget->current -= header->size; + } + } + std::free(header); +} + +void* stbi_budget_realloc(void* pointer, std::size_t old_size, + std::size_t requested) { + (void)old_size; + if (pointer == nullptr) return stbi_budget_malloc(requested); + if (requested == 0) { + stbi_budget_free(pointer); + return nullptr; + } + auto* old_header = static_cast(pointer) - 1; + const std::size_t old_total = old_header->size; + std::size_t new_total = 0; + if (!allocation_size(requested, &new_total)) { + mark_allocation_failure(DecodeAllocationFailure::size_overflow); + return nullptr; + } + if (active_decode_budget != nullptr) { + if (old_total > active_decode_budget->current) { + mark_allocation_failure(DecodeAllocationFailure::accounting_error); + return nullptr; + } + if (new_total > old_total && + (active_decode_budget->current > active_decode_budget->limit || + new_total - old_total > active_decode_budget->limit - + active_decode_budget->current)) { + mark_allocation_failure(DecodeAllocationFailure::budget_exceeded); + return nullptr; + } + } + auto* new_header = + static_cast(std::realloc(old_header, new_total)); + if (new_header == nullptr) { + mark_allocation_failure(DecodeAllocationFailure::system_allocation_failed); + return nullptr; + } + new_header->size = new_total; + if (active_decode_budget != nullptr) { + active_decode_budget->current = + active_decode_budget->current - old_total + new_total; + active_decode_budget->peak = + std::max(active_decode_budget->peak, active_decode_budget->current); + } + return new_header + 1; +} + +} // namespace + +#define STB_IMAGE_STATIC #define STBI_ONLY_JPEG #define STBI_ONLY_PNG #define STBI_NO_STDIO #define STBI_FAILURE_USERMSG +#define STBI_MALLOC(size) stbi_budget_malloc(size) +#define STBI_REALLOC_SIZED(pointer, old_size, new_size) \ + stbi_budget_realloc(pointer, old_size, new_size) +#define STBI_FREE(pointer) stbi_budget_free(pointer) #define STB_IMAGE_IMPLEMENTATION #include @@ -28,6 +177,27 @@ std::string decoder_detail() { return reason == nullptr ? std::string{} : std::string(reason); } +Result allocation_failure(const DecodeBudget& budget) { + switch (budget.failure) { + case DecodeAllocationFailure::budget_exceeded: + return failure(ErrorCode::resource_limit_exceeded, + "Encoded image decoder exceeded its memory budget"); + case DecodeAllocationFailure::size_overflow: + return failure(ErrorCode::resource_limit_exceeded, + "Encoded image decoder allocation size overflowed"); + case DecodeAllocationFailure::system_allocation_failed: + return failure(ErrorCode::resource_limit_exceeded, + "Encoded image decoder ran out of memory"); + case DecodeAllocationFailure::accounting_error: + return failure(ErrorCode::internal_error, + "Encoded image decoder memory accounting failed"); + case DecodeAllocationFailure::none: + break; + } + return failure(ErrorCode::resource_limit_exceeded, + "Encoded image decoder exceeded its memory budget"); +} + } // namespace Result decode_encoded_image( @@ -47,8 +217,16 @@ Result decode_encoded_image( int source_channels = 0; const auto* data = encoded.data(); const int size = static_cast(encoded.size()); + const auto budget_limit = static_cast(std::min( + limits.max_temporary_bytes, + std::numeric_limits::max())); + DecodeBudget budget{budget_limit}; + DecodeBudgetScope budget_scope(&budget); if (stbi_info_from_memory(data, size, &width, &height, &source_channels) == 0 || width <= 0 || height <= 0) { + if (budget.failure != DecodeAllocationFailure::none) { + return allocation_failure(budget); + } return failure(ErrorCode::invalid_image, "Input is not a supported JPEG or PNG image", decoder_detail()); } @@ -82,6 +260,9 @@ Result decode_encoded_image( static_cast(kOutputChannels)), stbi_image_free); if (!pixels_data) { + if (budget.failure != DecodeAllocationFailure::none) { + return allocation_failure(budget); + } return failure(ErrorCode::invalid_image, "Failed to decode JPEG or PNG image", decoder_detail()); } @@ -92,8 +273,19 @@ Result decode_encoded_image( } DecodedImage result; + const auto decoded_size = static_cast(decoded_bytes); + if (budget.current > budget.limit || + decoded_size > budget.limit - budget.current) { + budget.failure = DecodeAllocationFailure::budget_exceeded; + return allocation_failure(budget); + } result.bytes.assign(pixels_data.get(), - pixels_data.get() + static_cast(decoded_bytes)); + pixels_data.get() + decoded_size); + pixels_data.reset(); + if (budget.current != 0) { + return failure(ErrorCode::internal_error, + "Encoded image decoder retained temporary allocations"); + } result.width = static_cast(decoded_width); result.height = static_cast(decoded_height); result.stride = static_cast(decoded_width * kOutputChannels); diff --git a/bindings/node/test/adapter.test.cjs b/bindings/node/test/adapter.test.cjs index 9ddb639..86c4fa1 100644 --- a/bindings/node/test/adapter.test.cjs +++ b/bindings/node/test/adapter.test.cjs @@ -111,6 +111,23 @@ test('decodes JPEG and PNG snapshots on the engine worker', async () => { await engine.close(); }); +test('decodes encoded images concurrently across independent engines', async () => { + const engines = await Promise.all([ + createEngine({ bundlePath }), + createEngine({ bundlePath }), + ]); + try { + const [pngResult, jpegResult] = await Promise.all([ + engines[0].recognizeEncoded(encodedBlankPng), + engines[1].recognizeEncoded(encodedBlankJpeg), + ]); + assert.deepEqual([pngResult.imageWidth, pngResult.imageHeight], [2, 3]); + assert.deepEqual([jpegResult.imageWidth, jpegResult.imageHeight], [2, 3]); + } finally { + await Promise.all(engines.map((engine) => engine.close())); + } +}); + test('rejects malformed and unsupported encoded images safely', async () => { const engine = await createEngine({ bundlePath }); await assert.rejects( @@ -150,7 +167,9 @@ test('rejects malformed and unsupported encoded images safely', async () => { maxDetectionCandidates: limits.maxDetectionCandidates, maxRecognitionBatchSize: limits.maxRecognitionBatchSize, maxRecognitionWidth: limits.maxRecognitionWidth, - maxTemporaryBytes: 17, + // 18 decoded RGB bytes fit the old output-only check (18 <= 64 / 2), + // but stb's decoder allocations do not fit this request-level budget. + maxTemporaryBytes: 64, }, }); await assert.rejects( @@ -324,6 +343,10 @@ test('enforces bounded admission and restores capacity after queued cancellation oneSlot.recognize(loadFixture('generated-blank')), (error) => error instanceof OcrError && error.code === 'queue_full', ); + await assert.rejects( + oneSlot.recognizeEncoded(Buffer.from('not an image')), + (error) => error instanceof OcrError && error.code === 'queue_full', + ); await first; await oneSlot.close(); diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index aeda171..eadc7bb 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -123,11 +123,14 @@ function(light_ocr_configure_dependencies) URL_HASH SHA256=d571e63a2329baacb713f441e65ad75284de354db6e1ac435fe4bebbb417986a DOWNLOAD_EXTRACT_TIMESTAMP TRUE) - FetchContent_MakeAvailable(nlohmann_json clipper stb opencv onnxruntime_package) + FetchContent_MakeAvailable(nlohmann_json clipper opencv onnxruntime_package) - add_library(light_ocr_stb INTERFACE) - add_library(light_ocr::stb ALIAS light_ocr_stb) - target_include_directories(light_ocr_stb SYSTEM INTERFACE "${stb_SOURCE_DIR}") + if(LIGHT_OCR_BUILD_NODE OR LIGHT_OCR_BUILD_FUZZERS) + FetchContent_MakeAvailable(stb) + add_library(light_ocr_stb INTERFACE) + add_library(light_ocr::stb ALIAS light_ocr_stb) + target_include_directories(light_ocr_stb SYSTEM INTERFACE "${stb_SOURCE_DIR}") + endif() add_library(light_ocr_clipper STATIC "${clipper_SOURCE_DIR}/src/clipper.cpp") add_library(light_ocr::clipper ALIAS light_ocr_clipper) diff --git a/docs/napi-design.md b/docs/napi-design.md index 6df79b1..33199a1 100644 --- a/docs/napi-design.md +++ b/docs/napi-design.md @@ -360,13 +360,15 @@ POSIX 实现使用 directory-relative、no-follow 的打开方式;Windows 使 6. 分配 native vector,并复制恰好 `requiredBytes`;不复制 view 末尾无关字节。 7. 将只含 native 数据的请求入队。 -`recognizeEncoded()` 复用相同 options、admission、snapshot、AbortSignal 和 completion 语义,但 JavaScript 线程只校验并复制 encoded bytes。worker 使用关闭 stdio 且只启用 JPEG/PNG 的 `stb_image` 自动检测格式,先读取 dimensions 并按 effective `maxWidth`、`maxHeight`、`maxPixels` 和 `maxTemporaryBytes` 拒绝超限输入,再解码为 RGB8 后进入不变的 Core raw-pixel API。临时内存检查为 decoder 输出与 RGB→BGR 转换的双缓冲保留空间。EXIF orientation 不自动应用。`timingUs.decode` 记录 worker 解码时间,并计入 `timingUs.total`;raw input 的 `decode` 固定为零。 +`recognizeEncoded()` 复用相同 options、admission、snapshot、AbortSignal 和 completion 语义,但 JavaScript 线程只校验 encoded view/backing store、非空输入并复制 bytes,不同步解析图片内容。worker 使用关闭 stdio 且只启用 JPEG/PNG 的 `stb_image` 自动检测格式,先读取 dimensions 并按 effective `maxWidth`、`maxHeight`、`maxPixels` 和 `maxTemporaryBytes` 拒绝超限输入,再解码为 RGB8 后进入不变的 Core raw-pixel API。请求级 allocator 统计并限制 stb 的 `malloc/realloc/free`,同时在复制 RGB 输出前计入重叠存活的 decoder buffer 和 native vector;allocator 拒绝或系统分配失败均映射为 `resource_limit_exceeded`。EXIF orientation 不自动应用。`timingUs.decode` 记录 worker 解码时间,并计入 `timingUs.total`;raw input 的 `decode` 固定为零。 native admission 返回一个不导出的 `{ requestId, promise }`。JS facade 在 public Promise settled 前监听一次 signal;abort 时调用 private native cancel,并立即按 `signal.reason` reject public Promise。native promise 始终安装 fulfillment/rejection handler,所以取消后晚到的内部 completion 不会形成 unhandled rejection。listener 在 success、error 或 abort 任一路径只移除一次。 必须先预留、后复制。队列或字节预算不足时直接以 `queue_full` 拒绝,不能先复制大图再发现背压。分配或复制失败会释放预留并拒绝 Promise。 -错误优先级固定为:environment/engine state,JavaScript 结构与类型,capability/recognition options,image metadata 与 limits,adapter admission,最后是 worker 中的 Core/runtime error。因而 malformed image 不会被当成 `queue_full`,unsupported orientation 也不会为了排队而复制 pixels。 +raw input 的错误优先级固定为:environment/engine state,JavaScript 结构与类型,capability/recognition options,image metadata 与 limits,adapter admission,最后是 worker 中的 Core/runtime error。因而 raw input 的 malformed metadata 不会被当成 `queue_full`,unsupported orientation 也不会为了排队而复制 pixels。 + +encoded input 在 admission 前只验证 JavaScript 类型、backing store、detached/empty 状态和 byte budget;格式、dimensions、pixels 及 decoder 内存限制在 worker 中验证。因此 engine 已满时,malformed 或 dimension 超限的 encoded payload 可以先返回 `queue_full`。这是避免在 JavaScript 线程同步执行重复图片解析的明确 API 契约。 普通 `ArrayBuffer` 在同步 native 调用期间不能由同一 JavaScript Agent 并发执行修改;完成快照后不再访问 V8 backing store。拒绝 `SharedArrayBuffer` 是为了避免另一 Agent 在复制期间写入导致不一致快照或 native data race。这里不依赖较新、experimental 的 `node_api_is_sharedarraybuffer`,因此仍只使用 Node-API v8 symbols。 diff --git a/tools/generate_release_metadata.py b/tools/generate_release_metadata.py index 7d6cb7d..1341bf1 100644 --- a/tools/generate_release_metadata.py +++ b/tools/generate_release_metadata.py @@ -125,7 +125,9 @@ def source_dir(build: Path, name: str) -> Path: raise RuntimeError(f"dependency source directory not found: {name}-src") -def copy_licenses(build: Path, output: Path) -> list[dict[str, str]]: +def copy_licenses( + build: Path, output: Path, include_stb: bool +) -> list[dict[str, str]]: license_dir = output / "licenses" license_dir.mkdir(parents=True, exist_ok=True) sources = [ @@ -135,9 +137,12 @@ def copy_licenses(build: Path, output: Path) -> list[dict[str, str]]: (source_dir(build, "opencv") / "COPYRIGHT", "opencv-COPYRIGHT.txt", "opencv"), (source_dir(build, "opencv") / "3rdparty" / "zlib" / "LICENSE", "opencv-zlib.txt", "zlib"), (source_dir(build, "clipper") / "LICENSE", "clipper-BSL-1.0.txt", "clipper"), - (source_dir(build, "stb") / "LICENSE", "stb-MIT-or-Unlicense.txt", "stb"), (source_dir(build, "nlohmann_json") / "LICENSE.MIT", "nlohmann-json-MIT.txt", "nlohmann-json"), ] + if include_stb: + sources.append( + (source_dir(build, "stb") / "LICENSE", "stb-MIT-or-Unlicense.txt", "stb") + ) bundle = ROOT / "models" / "generated" / "ppocrv6-small-onnx-20260714.2" sources.extend([ (bundle / "LICENSES" / "PaddleOCR-Apache-2.0.txt", "PP-OCRv6-Apache-2.0.txt", "PP-OCRv6-models"), @@ -189,6 +194,10 @@ def main() -> int: output = arguments.output_dir.resolve() output.mkdir(parents=True, exist_ok=True) cache = cache_values(build / "CMakeCache.txt") + include_stb = ( + cache.get("LIGHT_OCR_BUILD_NODE") == "ON" + or cache.get("LIGHT_OCR_BUILD_FUZZERS") == "ON" + ) dependency_lock_path = ROOT / "models" / "deps.lock.json" bundle_lock_path = ROOT / "models" / "bundles.lock.json" dependency_lock = json.loads(dependency_lock_path.read_text("utf-8")) @@ -268,7 +277,7 @@ def main() -> int: json.dumps(manifest, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8" ) - inventory = copy_licenses(build, output) + inventory = copy_licenses(build, output, include_stb) (output / "license-inventory.json").write_text( json.dumps({"schemaVersion": "1.0", "files": inventory}, sort_keys=True, separators=(",", ":")) + "\n", encoding="utf-8", @@ -285,6 +294,8 @@ def main() -> int: }] relationships: list[dict[str, str]] = [] for record in dependency_lock["dependencies"]: + if record["name"] == "stb" and not include_stb: + continue identifier = "SPDXRef-Package-" + record["name"].replace("_", "-") packages.append(spdx_package(identifier, record)) relationships.append({"spdxElementId": "SPDXRef-Package-light-ocr-core", "relationshipType": "DEPENDS_ON", "relatedSpdxElement": identifier}) From 183bf43cd0714fb885fd73db593cfe7a6c1af8f8 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Tue, 14 Jul 2026 18:41:31 +0800 Subject: [PATCH 3/4] docs: update model bundle references --- docs/npm-packaging.md | 6 +++--- docs/releases/npm-0.1.0.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/npm-packaging.md b/docs/npm-packaging.md index af829fa..84450fc 100644 --- a/docs/npm-packaging.md +++ b/docs/npm-packaging.md @@ -42,7 +42,7 @@ const engine = await createEngine(); | 包 | 类型 | 内容 | 安装关系 | | --- | --- | --- | --- | | `@arcships/light-ocr` | facade | CJS、ESM、TypeScript types、平台与模型解析器 | 用户直接安装 | -| `@arcships/light-ocr-model-ppocrv6-small` | model | 完整 `ppocrv6-small-onnx-20260714.1` bundle、模型 license、可解析的 manifest subpath | facade 的普通 dependency | +| `@arcships/light-ocr-model-ppocrv6-small` | model | 完整 `ppocrv6-small-onnx-20260714.2` bundle、模型 license、可解析的 manifest subpath | facade 的普通 dependency | | `@arcships/light-ocr-darwin-arm64` | native | arm64 `.node`、ONNX Runtime dylib、licenses、SBOM、hashes | facade 的 optional dependency | | `@arcships/light-ocr-darwin-x64` | native | x64 `.node`、ONNX Runtime dylib、licenses、SBOM、hashes | facade 的 optional dependency | | `@arcships/light-ocr-win32-x64` | native | x64 `.node`、`onnxruntime.dll`、licenses、SBOM、hashes | facade 的 optional dependency | @@ -209,7 +209,7 @@ export function createEngine(options?: CreateEngineOptions): Promise; 模型内容身份与 npm 版本分离: - npm version 表示 package release set,例如 `0.1.0`。 -- bundle ID 表示 OCR 模型与配置身份,例如 `ppocrv6-small-onnx-20260714.1`。 +- bundle ID 表示 OCR 模型与配置身份,例如 `ppocrv6-small-onnx-20260714.2`。 - 任何模型 bytes、normalized config、dictionary 或 manifest 变化都创建新 bundle ID,并发布新的完整 release set。 - 只修改 README 不需要创建新 bundle ID,但仍需要新的 npm version。 @@ -221,7 +221,7 @@ export function createEngine(options?: CreateEngineOptions): Promise; ```text bindings/node/js + facade manifest template -models/generated/ppocrv6-small-onnx-20260714.1 +models/generated/ppocrv6-small-onnx-20260714.2 reports/release/ native artifacts ↓ dist/npm/ diff --git a/docs/releases/npm-0.1.0.md b/docs/releases/npm-0.1.0.md index de29b3d..c0fea2d 100644 --- a/docs/releases/npm-0.1.0.md +++ b/docs/releases/npm-0.1.0.md @@ -59,4 +59,4 @@ sha512-pC9UcqoCbS7q8tMR4Zfn3omonWksPLNgwMTQrXMQdNb4leAMaZ6IXapShR7LeocPQbOkWD8C4 sha512-vJghMn0FlBJdXvHd5q9RdpXdrYhAhA2p3Dto5rds8oCkqQGyt6mdC/4DNeMtvY/BDGIPB39GEScei+hEf/pkxw== ``` -模型 package 中 bundle payload 的身份仍由 `ppocrv6-small-onnx-20260714.1`、manifest、`SHA256SUMS` 与 Core directory loader 共同验证;npm tarball integrity 不能替代安装后模型内容校验。 +模型 package 中 bundle payload 的身份仍由 `ppocrv6-small-onnx-20260714.2`、manifest、`SHA256SUMS` 与 Core directory loader 共同验证;npm tarball integrity 不能替代安装后模型内容校验。 From aa36dab87cedbf50947a2d6083ec6fd6de924cc1 Mon Sep 17 00:00:00 2001 From: luojiyin Date: Tue, 14 Jul 2026 20:05:23 +0800 Subject: [PATCH 4/4] test: compare encoded and raw color input --- bindings/node/test/adapter.test.cjs | 69 +++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/bindings/node/test/adapter.test.cjs b/bindings/node/test/adapter.test.cjs index 86c4fa1..59b3bfa 100644 --- a/bindings/node/test/adapter.test.cjs +++ b/bindings/node/test/adapter.test.cjs @@ -6,6 +6,7 @@ const path = require('node:path'); const test = require('node:test'); const { pathToFileURL } = require('node:url'); const { Worker } = require('node:worker_threads'); +const zlib = require('node:zlib'); const { createEngine, OcrError } = require('../js/index.cjs'); @@ -36,6 +37,55 @@ function loadFixture(id) { }; } +function crc32(data) { + let crc = 0xffffffff; + for (const byte of data) { + crc ^= byte; + for (let bit = 0; bit < 8; ++bit) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type, data) { + const typeBytes = Buffer.from(type, 'ascii'); + const chunk = Buffer.alloc(12 + data.length); + chunk.writeUInt32BE(data.length, 0); + typeBytes.copy(chunk, 4); + data.copy(chunk, 8); + chunk.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 8 + data.length); + return chunk; +} + +function encodeBgrFixtureAsPng(image) { + assert.equal(image.pixelFormat, 'bgr8'); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(image.width, 0); + ihdr.writeUInt32BE(image.height, 4); + ihdr.set([8, 2, 0, 0, 0], 8); + + const scanlines = Buffer.alloc(image.height * (1 + image.width * 3)); + for (let y = 0; y < image.height; ++y) { + const row = y * (1 + image.width * 3); + scanlines[row] = 0; + for (let x = 0; x < image.width; ++x) { + const source = y * image.stride + x * 3; + const destination = row + 1 + x * 3; + scanlines[destination] = image.data[source + 2]; + scanlines[destination + 1] = image.data[source + 1]; + scanlines[destination + 2] = image.data[source]; + } + } + + return Buffer.concat([ + Buffer.from('89504e470d0a1a0a', 'hex'), + pngChunk('IHDR', ihdr), + pngChunk('IDAT', zlib.deflateSync(scanlines)), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + function delay(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); } @@ -111,6 +161,25 @@ test('decodes JPEG and PNG snapshots on the engine worker', async () => { await engine.close(); }); +test('matches raw recognition for a non-blank color PNG', async () => { + const engine = await createEngine({ bundlePath }); + try { + const image = loadFixture('paddleocr-garden-sign'); + const png = encodeBgrFixtureAsPng(image); + const options = { includeDiagnostics: true }; + const rawResult = await engine.recognize(image, options); + const encodedResult = await engine.recognizeEncoded(png, options); + + const stableResult = ({ timingUs, ...result }) => result; + assert.deepEqual(stableResult(encodedResult), stableResult(rawResult)); + assert.deepEqual(rawResult.lines.map((line) => line.text), ['绿洲仕格维花园公寓']); + assert.equal(rawResult.timingUs.decode, 0); + assert.ok(encodedResult.timingUs.decode > 0); + } finally { + await engine.close(); + } +}); + test('decodes encoded images concurrently across independent engines', async () => { const engines = await Promise.all([ createEngine({ bundlePath }),