All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
- Lint toolchain pinned to oxlint 1.79.0.
oxlintand@oxlint/pluginsnow carry the same exact version rather than a caret range and a pin, which had already drifted apart (1.77.0 against 1.74.0). The JS plugin API the vendored anti-slop rules load through is alpha and explicitly not covered by semver, so the two have to move together.
- The web demo caches models in the browser. They are stored by URL in
IndexedDB after the first download and reused on later loads, so a reload no
longer re-fetches tens of megabytes. A "Clear cached models" button in the
configuration panel drops them. IndexedDB rather than the Cache API because
Hugging Face redirects model requests to its CDN, and
Cache.put()refuses a redirected response.
- Web demo: the sample images are a collapsible dock. They sit in the bottom-left of the canvas panel and fold to a single chip once an image is loaded, so they stay reachable without covering the image.
- Model downloads no longer send a referer. Some hosts blocklist the origin
of the page doing the embedding and answer with a status that carries no CORS
headers, which the browser then reports as "No 'Access-Control-Allow-Origin'
header is present" rather than as the block it is. A model download has no use
for a referer, so the web fetches ask for
no-referrer. This is what the demo needed a page header for; embedders now get it without knowing to set one. Node and Bun have no referer to send and are unaffected. - Web demo: switching sample images needed a page reload. The sample chips were inside the placeholder element that gets hidden once an image loads, so choosing one sample hid the row offering the others.
- Web demo: model load failures reported nothing useful. A cross-origin
failure surfaces as a bare
TypeError, so the demo now re-asks inno-corsmode to tell "the server answered and the browser withheld it" from "the host was never reached", and keeps the prefetch's diagnosis instead of discarding it. It also drops the per-fileHEADprobe, which was a second request and a second way to fail for a size eachGETalready reports, and retries a refused file against the GitHub original. - Web demo could not load models. Hugging Face returns
404for requests carrying a*.workers.devreferer, and a 404 response has no CORS headers, so the browser reported it as "No 'Access-Control-Allow-Origin' header is present" rather than as the 404 it was. The demo is hosted on workers.dev, so every model fetch failed after the 6.4.2 move to the mirror. The page now sendsReferrer-Policy: no-referrer, which nothing there needs. Only pages whose origin Hugging Face blocklists are affected;pages.dev,vercel.app,netlify.app, and localhost all serve normally, as do Node and Bun, which send no referer at all.
- Models now download from Hugging Face.
MODEL_BASE_URLandDICT_BASE_URLpoint athttps://huggingface.co/snowfluke/ppu-paddle-ocr-models/resolve/main, a mirror of the models repo that serves from a CDN. The GitHub copies are behind a Git LFS bandwidth budget which, once exhausted, cuts off downloads for every published version at once; the LFS batch API on that repo is already refused. Paths are identical on both hosts, so a custom URL built from either base keeps working, and the GitHub copies stay in place for versions that reference them. Output is unchanged: the tiny preset fetched from each host produces byte-identical OCR results and the model files match by sha256. - Lint: adopted the anti-slop
Oxlint rules, vendored under
tools/oxlint/anti-slop. Ten rules are enforced; five are turned off with the conflict named in.oxlintrc.json(ONNX tensorshapenaming, runtime environment probes,unknownat public boundaries, the option-tree dictionary contract, and conditional-spread optional fields). Type assertions in shipped code now carry aSAFETY:comment stating the invariant that makes them sound; test, bench, script, and example files are exempted. Anonymous object return types were replaced by named contracts (ResizeDimensions,DecodedText,CropSource,MergedLineCrop,AsyncQueue, and the serve envelopes), and astr()flag reader replaced twelveas string | undefinedcasts in the CLI. No behavior change: OCR output on the regression corpus is byte-identical. - Docs: the README now points manual model URLs at the Hugging Face
mirror (
https://huggingface.co/snowfluke/ppu-paddle-ocr-models/resolve/main), which serves models and dictionaries from one base with no Git LFS bandwidth budget behind it. Paths are identical on both hosts. The library's own defaults still resolve from GitHub.
- Debug mode changed OCR output. The detected-box overlay was stroked onto
the same canvas the recognition stage then read, so enabling
debugging.debugaltered the recognized text: box outlines fall in the gaps between words and swallowed the spaces. On the receipt sample the text went from 382 to 377 characters. The overlay is now drawn on a copy. Reported by @xirf in #101. - Debug dumps ignored an absolute
debugFolder.CanvasToolkit.saveImagejoins its path ontoprocess.cwd()unconditionally, so an absolute folder was created empty while the images were written to a path rebuilt under the working directory. Node now resolves the folder and writes the PNG itself, which also drops the toolkit's incrementing0.filename prefix and the doubled.pngon crop dumps. Reported by @xirf in #101. - A failed debug dump aborted detection. Writing debug images is wrapped so a read-only filesystem returns boxes instead of an empty result. Reported by @xirf in #101.
- Image cache keys collided on same-sized images.
ImageCache.generateKeyhashed only the first 1024 bytes plus the length, so two images sharing a length and an opening run of uniform pixels got one key and the second call returned the first one's text. Canvas input reaches the cache as raw RGBA, where both conditions are ordinary. The key now samples across the whole buffer, endpoints included. Reported by @xirf in #102. buildBatchOptionsomittedsettle. The batch and stream commands set it themselves, so behavior is unchanged, but the exported builder now returns what the commands actually run with. Reported by @xirf in #103.- Model cache collisions. The Node/Bun cache keyed entries on the file name
alone, so two resources sharing a name (a custom model and a preset, or the
same path on two hosts) served each other's bytes. Entries now sit under a
digest of the full URL. Existing caches are not migrated: the first run after
upgrading re-downloads, and the old flat files can be removed with
service.clearModelCache().
- Faster OpenCV-engine pipeline on Node/Bun (default engine): detection
postprocessing builds its single-channel contour input Mat directly from
the probability tensor instead of rendering it to a canvas and reading it
back through
grayscale(); recognition preprocessing reads the resized Mat's bytes directly instead of routing each crop throughtoCanvas()+getImageData. The CTC argmax loop also drops per-element nullish checks. Output is bit-identical on the regression samples (receipt accuracy unchanged at 99.74%/99.48%/94.26% across strategies); interleaved benchmarks show ~2-2.5% lower medianrecognize()latency on the OpenCV engine (detection postprocess -8%) and neutral oncanvas-native. - Docs: the README "CLI (global install)" and "Standalone Binaries" sections now lead with the command; the notes moved into collapsible sections. The CLI flag table is split into "Models and engine" and "Behavior and output".
- Docs: the README "Batch Recognition" section now opens with the basic
call and a "you want / use" lookup table, with each variant behind a short
labelled lead-in. The
"auto"concurrency default on CPU is documented as4instead of "a small default". - Docs: README "Main-Thread Usage" now documents
mainThreadYieldMsas a value table, and states the yield cost per batched inference rather than per detected line (it is one pause perrecBatchSizecrops). "Multithreaded WASM" and "React Native" lead with the requirement; the JSR exclusion rationale moved into a collapsible note. ThewasmPathsnote is no longer a blockquote and shows the self-host assignment. - Docs: README "Fine-Tuning on Your Data" splits the one-paragraph wall into what the starter kit covers, and points at Image Preprocessing first. Unwrapped the hard-wrapped PP-OCRv6 intro paragraph.
- CLI:
helplistedv6-smallas the default preset; the default isv6-tiny(DEFAULT_MODELin the model catalogue). - Docs: removed leftover rebase conflict remnants that duplicated the CLI
flag table and the
RecognitionOptionstable (README and theskill-ppu-paddle-ocrconfiguration reference), which broke their rendering on GitHub.
-
Standalone CLI binaries for Linux x64/arm64, macOS Apple Silicon, and Windows x64, built with
bun build --compileand attached to every GitHub release - no Node, Bun, or npm install required (models still download on first run). Each binary embeds ONNX Runtime's shared libraries and preloads them at startup so the native binding resolves inside the single-file executable. Distributed as.tar.gz/.ziparchives (~50 MB download, ~140-200 MB extracted), each with a-slimvariant that drops the OpenCV engine for ~28 MB less on disk (~5 MB less to download), pinned tocanvas-native(rejects--engine opencvwith a usage error). Binaries are smoke-tested on all four OS runners in CI, cosign-signed (Sigstore bundle attached per archive), and covered by GitHub build provenance attestations (gh attestation verify). Intel macOS and musl (Alpine) are not covered because onnxruntime ships no builds for them. -
recognition.mainThreadYieldMsoption pauses before each recognition inference to yield the event loop. On the web main thread the/webentry defaults it to10, so a page runningrecognize()without a Web Worker keeps painting and handling input between WASM inference blocks instead of freezing for the whole image; consecutiveawaits alone only queue microtasks, which the renderer cannot interleave with. Defaults to0(disabled) in workers, Node, Bun, and React Native, and an explicit caller value always wins. Also exposed as--main-thread-yield-mson the CLI. A Web Worker remains the recommended home for OCR; this closes the gap for script-tag/main-thread setups. -
Batched recognition inference (
recognition.recBatchSize, default6, CLI--rec-batch-size): crops are width-sorted and stacked into one[N, 3, 48, W]tensor per chunk - onesession.runper chunk instead of per crop. Each row decodes only its own share of the padded output sequence, and padding replicates the crop's edge pixels so convolution receptive fields never see a hard boundary. Benched ~35% faster with equal-or-better accuracy vs sequential on the reference receipt (per-line 99.48% -> 99.48%, per-box 99.48% -> 99.74%).1restores the previous sequential behavior; fixed-batch models are detected and clamped to1automatically. -
recognition.rotateVerticalCrops(defaulttrue, CLI--no-rotate-vertical-cropsto disable): crops with height/width >= 1.5 rotate 90 degrees counter-clockwise before recognition (upstream PaddleOCR's convention), so vertical text lines read correctly with no orientation model. -
recognition.spaceRecovery(defaultfalse, CLI--space-recovery): emits inter-word spaces the greedy CTC decode drops when the space class is a strong runner-up at a character timestep. -
README: a Document Correction section showing how to compose with ppu-doc-correction (page orientation, unwarping) instead of paying for orientation models inside the OCR path.
-
recognition.maxCropSourceSideLengthoption (default2000) caps the longest side of the canvas recognition crops are cut from, independent of and abovedetection.maxSideLength(which only resizes the detector's own input tensor, never the recognition crop source). Previously, recognition always cropped from the full-resolution source canvas regardless of size - a source far larger than any normal photo (e.g. a 4961x7016 full-page scan) paid seconds of decode plus dozens of full-res per-line crop ops per image. The default of2000keeps ordinary photos (up to ~2000px) untouched with today's crop fidelity, and measured 2-4x faster on far-oversized images with no measurable accuracy loss on the fixture set. This is a speed/accuracy trade-off, not a one-size-fits-all fix: lower the value for more speed on large sources at some accuracy cost, or raise it to always crop at native resolution regardless of input size. Box coordinates are scaled back to original-image space in the returned results either way. Exposed as--max-crop-source-side-lengthon the CLI and asMAX_CROP_SOURCE_SIDE_LENGTHonapps/serve, keeping the documented 1:1 option-to-flag mapping intact.
- Scaled recognition boxes now clamp to a 1px floor, so an aggressive
recognition.maxCropSourceSideLengthon a very large source cannot round a thin box to zero width/height and turn a degenerate crop into an empty result for the whole image.
- Removed
onnxruntime-react-nativefromdevDependencies(it remains an optional peer dependency, so consumers are unaffected). Itsreact-nativepeer draggedmetroandimage-size1.2.1 intobun.lock;image-sizecarries two unfixed High denial-of-service advisories (GHSA-5p2g-fcmc-qvqq, GHSA-w3rx-r6r6-pgpr) with no patched release, which failed the SCA gate. The mobile entry now type-checks against a local ambient declaration re-exportingonnxruntime-common, matching the real package's own typings.
isWebWorker()export onppu-paddle-ocr/webfor host apps that need to branch on the scope themselves- Recognition fine-tuning starter kit (
examples/fine-tune/): per-tier PP-OCRv6 training configs, a dataset preparation script that builds train/val/test splits from images with line-level ground truth, and a sample dataset generated from the bundled receipt.
- The web build runs inside a Web Worker (#84).
createCanvas()calleddocument.createElement("canvas")andisCanvas()evaluatedimage instanceof HTMLCanvasElement, and neither global exists in a worker scope, so both threw aReferenceError. Canvas creation now goes throughppu-ocv, which prefersOffscreenCanvas, and the canvas check is duck-typed. Manifest V3 extension service workers are covered by the same fix. No shims needed on the caller's side ort.env.wasm.wasmPathsgets its CDN default inside workers too. The default was gated ontypeof window !== "undefined", which is false in a worker, so CDN and unbundled worker setups 404ed on the WASM binaries- The default WASM CDN URL now tracks the loaded
onnxruntime-web. It was pinned to 1.26.0 while the package resolves 1.27.0, so anyone who did not setwasmPathshanded a 1.27 loader 1.26 binaries. The version is read fromort.env.versionsat call time and can no longer drift
- Default model is PP-OCRv6 tiny (was small): 2-4x faster, ~6 MB
download instead of ~30 MB, and 99.48% vs 97.39% on the receipt
benchmark. Tiny's dictionary covers ~6.9k characters; pass
model: V6_SMALL_MODELfor the full 50+ language coverage - Default recognition strategy is
per-line(wasper-box): line context reads short low-contrast labels more reliably and needs fewer inference calls. Applies to the library, CLI, serve, and playground - Default
minimumAreaThresholdis 20 (was 50), so single-digit detections survive the area filter
maxSideLength: "auto"(new default): the detection cap scales with the input,clamp(0.75 x longestSide, 960, 1920), replacing the fixed 640 cap. Pass a number to pin a fixed capminimumConfidencerecognition option (default 0.5): drops recognized items below the threshold (upstream PaddleOCR'sdrop_score); symbol-only items need an extra 0.3. Set 0 to disable- Decode-level text refinement: fullwidth forms become ASCII on non-CJK text, doubled spaces collapse, and gap-based space injection is measured in each crop's own CTC timestep quantum so it adapts to any model's position grid
- "Choosing a Model and Configuration" README section: a selection
matrix by model family and input characteristics, each recipe validated
against a committed example image in
assets/config-matrix/
canvas-nativeno longer drops weak detections theopencvengine keeps (missed text on the web build): its binarization threshold now matches the OpenCV foreground criterion
per-line/cross-linenow hand each box its own text instead of dumping the whole line into the first box.mergeLineCropstitched same-line crops flush against each other, so the recognizer returned the line as one unspaced token and the word-based redistribution gave everything to the first box and empty strings to the rest. Merged crops now get a white separator gap between boxes (a word boundary the model can see), and recognized text maps back to boxes by each character's CTC timestep position - the decoder reports where in the crop every character fired, so each character lands in the box it was read from. Falls back to a width-proportional split (with cuts snapped to nearby spaces) when positions are unavailable. Fixes both the all-text-in-first-box symptom and cross-line batch splits bleeding characters between lines (e.g....AlbumsRe/centl).- Spaces the model drops at wide gaps are restored. CTC recognition
under-emits spaces on columnar layouts (receipts, tab-aligned forms):
Total Item 1came back asTotal Item1even on a per-box crop. The decoder now inserts a space wherever the gap between two consecutive characters exceeds 2.5x the median glyph pitch, except between identical characters (CTC's mandatory blank inflates their gap, e.g.44). Receipt bench accuracy: per-box 96.61% -> 97.13%, per-line up to 97.13%, cross-line up to 98.17% (opencv engine).
- Playground: model preset selector and warm-up option. The Models
section now lists every catalogue preset (
v6-small...v3-mobile); custom URLs override the chosen preset per file. A new checkbox runs the hidden warm-up inference after Apply Configuration, so the first real recognize on a freshly applied model is not paying WASM/WebGPU compilation cost.
- README: added an Ecosystem section linking the sibling PPU libraries (ppu-ocv, ppu-pdf, ppu-doclayout, ppu-doc-correction, ppu-orientation-corrector, ppu-uniface, ppu-yolo-onnx-inference).
- Playground mobile experience and recovery. Small screens now use dedicated Configuration, Image, and Results views instead of one long stacked page. The configuration panel groups advanced settings, preserves edits made during asynchronous reloads, and reports incomplete model loads with a retry action.
- Cloudflare playground now ships the complete browser build dependency tree.
Shared modules imported by
lib/webare copied into the deployment, preventing local module MIME errors and unnecessary CDN fallback. per-line/cross-lineno longer crash on dense pages with thin detected regions (#72).mergeLineCropstretches every box on a line to the line's height; a degenerate box (an underline or table rule a few px tall) multiplied its width by that stretch and the merged canvas width could exceed the platform's maximum surface size, makingcreateCanvasthrowCreate skia surface failed. The per-box stretch is now clamped (max 4x) and the merged width is capped at 16384px, shrinking proportionally when exceeded.run()also awaits the strategy result inside its try/catch, so a strategy failure degrades to an empty result instead of rejectingrecognize().
detect()- detection-only inference. Runs the detection model without recognition and returns the bounding boxes ({ boxes: Box[] }). Available on all entry points (Node/Bun, web, mobile) and as the CLIdetectcommand and the serve app'sPOST /v1/detect. Per-callDetectOptionsextendsDetectionOptions, so every tuning field (maxSideLength,minimumAreaThreshold, paddings,mean,stdDeviation) can be overridden per call, plus:crop: truereturns each region PNG-encoded asArrayBuffer, index-aligned withboxes(not supported on React Native - the Skia canvas has no encoder).saveCropsTo: "some/folder"writes each crop ascrop_NNN.pnginto the folder (Node/Bun only; ignored on web/mobile).
- Install size back to ~45MB (was ~317MB since 5.7.1).
onnxruntime-nodeis no longer inoptionalDependencies, so a plainnpm install ppu-paddle-ocrstops pulling its ~258MB of all-platform native binaries. It stays an optional peer dependency: Node/Bun users install it explicitly alongside the package (npm install ppu-paddle-ocr onnxruntime-node); web/mobile/Deno installs no longer pay for it. The CLI now detects the missing backend and prints install instructions instead of a module resolution stack trace - zero-install runs becomenpx -p onnxruntime-node -p ppu-paddle-ocr ppu-paddle-ocr <args>.
- Bumped
ppu-ocvto^4.0.0(OpenCV 5.0 via@techstark/opencv-js5.0.0). No API changes;ImageProcessor.initRuntime()handles the new Promise-based runtime init internally. - Improve docs (README.md) readability
- Bumped dev dependencies (
oxfmt0.58,oxlint1.73,onnxruntime-*1.27,fast-check4.9,@napi-rs/canvas1.0.2,lint-staged17.0.8,tsx4.23). Refreshed GitHub Actions pins (docker/login-actionv4.4.0,github/codeql-actionv4.37.0). - Moved to TypeScript 7 (native).
typescript@7drops thetranspileDeclarationJS API the build used, soscripts/build.tsnow emits all declarations in one nativetsc -p tsconfig.build.jsonpass (output is byte-identical). The@typescript/native-preview(tsgo) dev dependency is gone -type-checkuses the same nativetsc --noEmit. - CI bun pin bumped from 1.2.23 to 1.3.14. bun 1.2.23 on Linux can hang
@techstark/opencv-js5's Promise-based runtime init when the node and web entries load in the same process; 1.3.14 handles it, and the 1.3.13 SIGILL-on-exit that originally forced the 1.2.23 pin no longer reproduces. - Fixed a test-suite bug that emptied all opencv-engine OCR when file order
changed.
tests/canvas-compatibility.test.tsstubbedImageProcessor.initRuntimewith a no-op but itsafterAllrestore omitted thevalueindefineProperty, which keeps the stub in place - on runners whose filesystem ordered that file first (CI's ext4), OpenCV never initialized and every later opencv OCR silently returned empty results. The restore now puts the realinitRuntimeback.
- Default models upgraded from PP-OCRv5 mobile (English) to PP-OCRv6 small (unified
multilingual). On first run after upgrading, the new v6 model files are downloaded and
cached; previously cached v5 files remain on disk and are not removed.
Pass
model: V5_EN_MOBILE_MODELto keep the previous behaviour without any other code changes. - Version bumped to 6.0.0 to signal the default-model generation change and align with the upstream PP-OCRv6 release series. Existing options and API surface are fully backwards compatible.
- Bumped
ppu-ocvto^3.3.0for its newcanvas-mobile(Skia) entry, and addedonnxruntime-react-nativeand@shopify/react-native-skiaas optional peer dependencies (for the new mobile entry below). - Default recognition strategy changed from
per-linetoper-box. On PP-OCRv6 small,per-boxis the most accurate on the receipt benchmark (96.61% vs 95.56% forper-line) while the three strategies are within ~1% on speed for sparse pages. Setrecognition: { strategy: "per-line" }(or"cross-line") to cut inference calls on dense, multi-word-per-line documents. - Improve OCR line grouping scalability by avoiding repeated average-height recomputation.
- React Native support via a new
ppu-paddle-ocr/mobileentry. Runs the same OCR pipeline on iOS and Android usingonnxruntime-react-native(native JSI) andppu-ocv/canvas-mobile(Skia-backed canvas), mirroring the web entry's platform-provider pattern. Always uses the canvas-native engine (no OpenCV on RN); CPU inference by default with opt-in NNAPI/CoreML. Install withnpm install ppu-paddle-ocr onnxruntime-react-native @shopify/react-native-skiaand import fromppu-paddle-ocr/mobile. Requires a dev client /expo prebuild(not Expo Go). A runnable Expo example lives in a separate repo,ppu-paddle-ocr-mobile-react-native-demo. Closes #17. - Model catalogue - 27 named preset constants covering PP-OCRv6 (
V6_SMALL_MODEL,V6_MEDIUM_MODEL,V6_TINY_MODEL), PP-OCRv5 (English, server, multilingual, INT8), PP-OCRv4, and PP-OCRv3, each bundling detection + recognition + dictionary URLs. Use them to switch models with autocomplete instead of hand-writing URLs, e.g.new PaddleOcrService({ model: V6_SMALL_MODEL }). Seesrc/model-catalogue.tsfor the full list. DEFAULT_MODEL- points to the current default (PP-OCRv6 small).DEFAULT_MODEL_URLSis retained as a deprecated alias.ModelUrlstype, plusMODEL_BASE_URL/DICT_BASE_URLconstants, for building custom model configurations.- CLI
--model <preset>flag for selecting a catalogue preset by name (e.g.v6-tiny,v5-thai-mobile); the granular--model-detection/-recognition/-dictflags override parts of it.models --jsonlists the available preset keys. Backed by the new exportedMODEL_PRESETSmap andModelPresettype. - All catalogue exports are available from
ppu-paddle-ocr,ppu-paddle-ocr/web, andppu-paddle-ocr/mobile.
- Model download timeout raised from 30 s to 300 s per attempt. The PP-OCRv6 small
models are ~30 MB combined (vs ~12 MB for PP-OCRv5 mobile), causing the previous
30-second
AbortSignal.timeoutto fire on slower connections before the body finished downloading. The new default matches a conservative 1 Mb/s floor across three attempts.
- Model downloads no longer hang on a stalled connection. Both the Node
(
fetchAndCacheResource) and Web (_loadResource) model fetches used a barefetch()with no timeout, so a stalled GitHub connection duringinitialize()would hang until the caller's timeout (and flaked CI). Both now go through a sharedfetchArrayBufferWithRetryhelper with a per-attempt abort deadline (30s) and bounded retries. - Restored the JSR score (regressed to 58% in 5.8.2). Exposing
coi-serviceworker.jsas a JSR module export forced a plain-JS file to be treated as a scored entrypoint - JSR can't derive types from it, so it flagged the whole public API as using "slow types" and as missing module docs. The file is now removed fromjsr.jsonexportsbut kept in the publish allowlist, so it still ships and is fetchable at its JSR file URL (and the npmppu-paddle-ocr/coi-serviceworker.jsexport is unchanged) - it is simply no longer scored as a documented module. - Documented every remaining exported symbol - the Node/Web service
constructors and the Web
PaddleOcrServicepublic methods (isInitialized,changeDetectionModel,changeRecognitionModel,changeTextDictionary,recognize) - bringingdeno doc --lintto zeromissing-jsdoc(100% documented-symbol coverage).
- JSR was missing the
coi-serviceworker.jsexport. The npm package exposedppu-paddle-ocr/coi-serviceworker.js, butjsr.jsondeclared neither the export nor the file in its publish allowlist, so the JSR build omitted it. Both are now added.
- Opt-in COOP/COEP service worker. The package now ships
coi-serviceworker.jsand exposes it via theppu-paddle-ocr/coi-serviceworker.jsexport. On static hosts that can't set headers (e.g. GitHub Pages), copy it to the served root and load it before anything else to unlock cross-origin isolation ->SharedArrayBuffer-> multithreaded WASM inference. Not registered automatically and not needed when WebGPU is used or headers are set server-side. See the README's "Multithreaded WASM" section. - Update outdated link to Cloudflare deployment
- Signed release artifacts. The publish workflow signs the published tarball
keyless with cosign (Sigstore, via OIDC) and attaches the
.sigstore.jsonbundle to the GitHub release, satisfying OpenSSF Scorecard's Signed-Releases check. Release tags are SSH-signed. - Reproducible-build verification. CI builds twice and fails unless
lib/is byte-identical;docs/REPRODUCIBLE_BUILD.mddocuments how to verify. - Fuzz testing (dynamic analysis).
tests/fuzz.test.tsusesfast-checkto feed random and malformed input to the OCR decode boundary on every CI run. - 300-line-of-code cap. A
max-linesoxlint rule (error,.tsonly) caps files at 300 lines of code; documented inCONTRIBUTING.md. - Web OCR test coverage. The
ppu-paddle-ocr/webpath runs under the test runner via an@napi-rs/canvaspolyfill harness (tests/web-canvas-polyfill.ts).
- The whole suite runs in one
bun test. Bumpedppu-ocvto^3.2.2(its structural canvas/Mat detection) and isolated the web suite's platform, so the node, web, CLI, andapps/servetests run in a single process - no split runner. Combined coverage ~94%, gated at 90% viabunfig.toml. Removed thescripts/test.ts/scripts/coverage.tstwo-pass runner. - Service layer split under the 300-LOC cap. The recognition, detection, and
processor services were split into focused internal modules
(
src/core/recognition/,src/core/detection/,src/processor/model-cache.ts). Public class APIs are unchanged. typeoverinterfaceis now a lint error; pre-commit formatting is scoped to staged files (no whole-repo reformat sweep).apps/servebumped to 0.1.3 withAPI_VERSIONsynced.
- Demo (
index.html) pins an exact package version. Theppu-paddle-ocr@5CDN range could resolve to a stale build; it now pins the released version. changeDetectionModel/changeRecognitionModelused a disposed session. Both the node and web services swapped the ONNX session at runtime without rebuilding the detector/recognitor, so the nextrecognize()failed with "Session already disposed". They now rebuild against the new session.
- Supply-chain hardening. All GitHub Actions are pinned to commit SHAs,
npm publishpasses--provenance, and an OpenSSF Scorecard workflow publishes a supply-chain health score. - Published package runs no install scripts.
scripts(includingprepare) anddevDependenciesare stripped from the published manifest, so an installed copy can execute no lifecycle code (hasInstallScript: false). - OpenSSF baseline + best practices. CodeQL on every push/PR (least-privilege
token), an osv-scanner SCA gate (CI and pre-release), a CycloneDX SBOM per
release, per-file SPDX headers, and the supporting docs:
GOVERNANCE.md,ROADMAP.md,docs/DESIGN.md,docs/THREAT_MODEL.md, the release-verification / dependency / remediation / VEX policy inSECURITY.md, and a DCO sign-off requirement inCONTRIBUTING.md. SECURITY.mddocuments the Socket "obfuscated code" alerts ononnxruntime-web/@protobufjs/floatas false positives on minified and generated upstream artifacts.- LICENSE now ships in the npm tarball (previously only the SPDX field traveled).
bunx ppu-paddle-ocr/npx ppu-paddle-ocrnow work out of the box.onnxruntime-nodeis an optional peer dependency, which package managers never auto-install, so a zero-install CLI run crashed withERR_MODULE_NOT_FOUND: onnxruntime-node. It is now also declared inoptionalDependencies, sobunx/npxpull it automatically. SDK consumers are unaffected (the optional peer is still honored); web-only installs can skip it with--no-optional.
- First-party CLI -
bunx ppu-paddle-ocr .../npx ppu-paddle-ocr .... Shipped as abinin the package (no extra install), it covers the whole library surface:recognize(single image),batchandstream(globs or lists, with bounded concurrency), plusdownload-models,clear-cache, andmodels. EveryPaddleOptions/RecognizeOptionsfield has a flag ---strategy,--engine,--flatten,--model-detection/-recognition/-dict, detection tuning (--max-side-length,--mean,--std, ...),--execution-providers,--concurrency, and output controls (--json,--pretty,-o,-q). Recognized text goes to stdout, progress/logs to stderr; exit codes are0success /1runtime error /2usage error. Uses the default v5 models unless overridden. Cross-runtime (Node and Bun); no new runtime dependencies (node:util.parseArgs).
apps/serve- production-grade REST API (Hono + Bun, dockerized). One warmedPaddleOcrServicebehind a bounded inference queue (429 backpressure, no OOM/VRAM blow-up), graceful shutdown, optional API-key auth, Prometheus/metrics, OpenAPI/docs. Endpoints: sync/batch/async/SSE OCR, task status/cancel,/v1/models,/health,/ready. Multi-stage CPU + CUDA Dockerfiles (models pre-baked, non-root), compose, and a release workflow that builds, slims (docker-slim), and pushes to ghcr.io. Standalone package - kept out of the library's install/publish path.
- Benchmarks now measure the shipped default models (v5).
bench/batch.bench.tsandbench/profile.tshardcoded the stale v4 recognition model frommodels/; they now omit explicit model paths so every benchmark exercises the library default, matchingbench/index.bench.ts. README benchmark numbers refreshed accordingly. - Tests now exercise the default models (v5) too.
index.test.ts(recognition behaviour + accuracy),engine-parity.test.ts, andbatch-recognize.test.tsno longer pin the stale v4 recognition model; the explicit file-path / ArrayBuffer / buffer-release tests inindex.test.tskeep loading a local file, since they verify the model-loading API itself.
batchRecognize()andbatchRecognizeStream()- runrecognize()over an array or (async) iterable of images with bounded concurrency, so peak memory stays bounded regardless of batch size. Results are index-aligned to the inputs; supports per-item error isolation (settle),AbortSignalcancellation, andonProgress. Concurrency defaults to"auto"-1when an accelerator execution provider (CUDA/WebGPU) is configured, a small CPU default otherwise. Inherited by both the Node and Web builds. See the new "Batch Recognition" section in the README.
- Replaced mitata with a zero-dependency benchmark harness (
bench/harness.ts). mitata crashed intermittently on this suite; the harness measures in-process withperformance.now, runs tasks round-robin across rounds so thermal/GC drift hits each equally, and reports the median plus optional peak RSS. Bothbench/index.bench.tsandbench/batch.bench.tsuse it. Removes themitatadevDependency. - Test files are now isolated in worker processes via
bun test --parallel=N(where N is the number of*.test.tsfiles undertests/andprivate-tests/). Sequentialbun teston Bun 1.3.13 segfaulted when multiple test files each loaded@techstark/opencv-jstogether with the newly upgraded@napi-rs/canvas@1.0.0- an Emscripten/embind multi-load issue that previously surfaced as a recoverable warning under@napi-rs/canvas@0.1.x. The workaround is also ~2.4x faster (11s vs 26s on the local suite). - Upstream fix landed. Bun 1.3.14 (likely via oven-sh/bun#30412) no longer crashes on the same suite without the workaround. Tracking issue: oven-sh/bun#30716. The
--parallel=Nflag is kept anyway for the speedup and to protect contributors still on 1.3.13. bun.lockis now committed. Previously gitignored; now part of the repo so CI and contributors install the exact set the maintainers test against. Has no effect on the published package (the publish workflow only ships./lib).
- Prototype pollution fix in
deepMerge(src/utils.ts). The recursive merge used to walk every own-enumerable key of the source object without filtering, so a crafted input containing__proto__,constructor, orprototypecould write through toObject.prototypeand affect unrelated objects in the process.deepMergenow skips those three keys explicitly. Users who pass untrusted JSON into any options object should upgrade.
- Added
.github/dependabot.ymlso npm dependencies and GitHub Actions are kept current automatically (weekly schedule). - Hardened CI: tightened
permissions:on the quality-check workflow. - Bumped CI actions to current majors:
actions/checkoutv4 -> v6,actions/setup-nodev4 -> v6,oven-sh/setup-bunv1 -> v2. - Bumped
oxfmt0.48.0 -> 0.49.0 (dev dependency, formatter).
- Browser bundlers no longer need to alias
ppu-ocv/canvas(#18). Core services previously importedppu-ocv/canvas(the Node variant) at module top level, which forced every browser consumer - including thewebsubpath - to alias orpnpm patchthe specifier. Canvas access is now routed throughPlatformProvider.canvas(prepareCanvas/createProcessor/getToolkit);NodePlatformProviderwires it toppu-ocv/canvas,WebPlatformProviderwires it toppu-ocv/canvas-web. Webpack / Vite / Next.js / esbuild consumers ofppu-paddle-ocr/webshould now work out of the box.
- New
CanvasOps<TCanvas>type onPlatformProviderfor platforms that want to plug in custom canvas backends. core/base-{detection,recognition,paddle-ocr}.service.tsno longer importppu-ocv/canvasat runtime (type-only imports remain).- Demo (
index.html) refreshed: full config surface (recognition strategy, cross-line factor, mean/std-dev, execution provider) is now editable from the sidebar; sticky "Apply Configuration" button with dirty-state pulse; loading overlay during inference; paper-and-ink theme.
- Safe execution provider fallback for Node.js: Session creation now gracefully handles failures from preferred providers (CUDA, DirectML, TensorRT) by falling back to CPU. Prevents initialization crashes on systems without GPU acceleration.
- Default to
.ortmodels: The library now defaults to using pre-optimized ONNX Runtime (.ort) models instead of standard ONNX files, providing ~5x faster cold start time. - Parallel model loading: Model file download and session creation now run concurrently during
initialize(), further reducing initialization latency.
- Added
src/core/session-factory.tswithcreateSession()that encapsulates EP selection and fallback logic, making it reusable across Node.js environments. - New tests in
tests/session-factory.test.tscovering EP fallback scenarios.
- WebGPU execution provider (web build).
PaddleOcrServiceimported fromppu-paddle-ocr/webnow probesnavigator.gpuduringinitialize()and prefers["webgpu", "wasm"]when available, falling back silently to["wasm"]otherwise. WebGPU session creation that errors out (e.g. a model uses an op WebGPU does not support) triggers a transparent retry on WebAssembly. Typical speedup on Chrome/Edge with a compatible GPU is 2-5x faster recognition with no code changes. isWebGpuAvailable()andgetDefaultWebExecutionProviders()exported fromppu-paddle-ocr/webfor conditional UI ("GPU-accelerated" indicators) and explicit provider selection.examples/quantize-onnx.py- helper script for producing INT8 dynamic quantized recognition models from the FP32 ONNX files in ppu-paddle-ocr-models. QuantizesMatMul/Gemmonly (Convis skipped becauseConvIntegeris not implemented inonnxruntime-node's CPU backend). Typically 20-50% faster recognition on x86-64 CPUs with VNNI and on WebAssembly, with no measurable accuracy loss on the receipt sample.
- Bumped internal
onnxruntime-webCDN URL and theort.env.wasm.wasmPathsdefault from 1.24.2 to 1.26.0 (so WebGPU is available out-of-the-box).
- README now documents the model-cache folder location per OS (macOS, Linux, Windows).
- New README section "WebGPU Acceleration" covering auto-detection, how to override the provider preference, and how to probe support from user code.
- New README section "INT8 Quantized Recognition Models (advanced)" with platform-specific guidance - explicitly calls out that INT8 is slower than FP32 on Apple Silicon, so users on macOS ARM64 should stick with FP32.
- Pre-commit hook now runs
bun run fmt:fixandbun run lint:fixacross the whole repo before delegating strict lint + type-check to lint-staged, and restages the fixer output viagit add -u. Commits land clean without a follow-up "fix: apply formatter" commit. package.jsonnow has a"prepare": "husky"script sobun installreliably activates husky on fresh clones (previously missing, which is why hooks silently did nothing).- GitHub issue templates (bug, accuracy, performance, install, feature, documentation) and a pull request template with What/Why/How sections.
- CI pinned to Bun 1.2.23 until the Bun 1.3.x test-runner SIGILL on exit is fixed upstream.
- Detection preprocessing: Replaced the OpenCV resize + separate padded-canvas step with a single
drawImagethat scales and places the image into the padded target in one call. Eliminates aMat <-> Canvasround trip for the OpenCV engine; neutral for canvas-native. - Detection normalization hot loop: Pre-computed
scale = 1/(255*std)andshift = mean/stdso each pixel costs one multiply + one subtract per channel instead of divide -> subtract -> multiply. - Recognition tensor creation:
createImageTensorFromCanvasnow fills channel 0 once from the grayscale input and usesFloat32Array.copyWithinto memcpy the block into channels 1 and 2, instead of writing each pixel three times. - CTC decoding: Inlined the per-timestep argmax and character-append helpers, and replaced the per-character confidence array + final
reducewith a running sum + count. Largest measurable gain incross-line(longer CTC output sequences).
Net result on the M1 receipt benchmark (vs. v5.2.0, clean machine): 1-3.5% faster across all six (strategy x engine) variants, with identical recognition accuracy on every variant.
- Recognition strategies (
recognition.strategyoption and per-callrecognize(..., { strategy })override): Choose how detected boxes are fed into the recognition model. Each strategy works by cropping detected regions from the canvas and stitching them side-by-side before running inference, so the number of recognition inferences can be reduced."per-box"- each detected box produces one separate inference (previous behavior, most accurate)."per-line"(default) - boxes on the same line are merged into a single crop and a single inference."cross-line"- short lines are bin-packed across batches to minimise total inference calls, improving throughput on images with many text regions.
RecognitionStrategytype,RecognitionOptions.strategy,RecognitionOptions.crossLineWidthFactor, andRecognizeOptions.strategyin the public API.PaddleOcrService.downloadModels()static method to pre-download and cache the default model files (useful for CI/CD and warm-up).- Multi-engine x multi-strategy benchmark suite under
bench/.
- Default recognition strategy is
"per-line": ~10% throughput improvement over"per-box"on typical receipts while keeping accuracy within 1 edit-distance. Users who need strict per-box behavior can pass{ strategy: "per-box" }per call or configure it at service creation. - Migrated linting toolchain from Prettier/ESLint to oxlint + oxfmt.
- Updated internal documentation and README to describe the recognition strategies, including the strategy diagram.
- Benchmark memory accounting and output formatting.
- Several
oxlintfindings acrosssrc/andexamples/(no behavior changes).
- Added a GitHub Actions CI workflow and wired npm + jsr publishing to GitHub releases.
- Added husky pre-commit + lint-staged hooks for commit-message validation and automatic formatting.
- Performance regression fix: Restored eager
ImageProcessor.initRuntime()call duringinitialize()when using the OpenCV engine. In v5.0.0 this call was removed when OpenCV was dropped; v5.1.0 restored the OpenCV code path but not the runtime initialization, causing the OpenCV WASM module to be lazily compiled on first use - resulting in 3-6x slower first inference and high variance in subsequent calls.
- Processing engine selection (
processing.engineoption): Choose between"opencv"(default) and"canvas-native"for the image preprocessing pipeline. ProcessingEnginetype,ProcessingOptionsinterface,DEFAULT_PROCESSING_ENGINEandDEFAULT_PROCESSING_OPTIONSexports.- Engine parity regression tests (
tests/engine-parity.test.ts) to detect divergence between the two engines.
- Default processing engine restored to OpenCV: v5.0.0 switched entirely to canvas-native processing, which caused regressions in bounding box accuracy for some images (boxes appearing ~8px narrower). The default is now
"opencv"again, matching v4 behavior. Users who prefer the lighter canvas-native engine can still opt in viaprocessing: { engine: "canvas-native" }. BaseDetectionServiceandBaseRecognitionServicenow accept anengineparameter to dynamically select the processing backend.- Node
DetectionService/RecognitionServiceconstructors accept an optionalengineparameter. - Web services always use
canvas-native(no OpenCV available in browser).
- Fixed bounding box width regression (issue #8) where canvas-native
findRegionsproduced narrower boxes compared to OpenCVfindContours.
- Removed deskew functionality: The
autoDeskewoption anddeskewImage()method have been removed from ppu-paddle-ocr - Deskew functionality has been moved to ppu-ocv library
- Users who need image deskewing should now use
DeskewServicefromppu-ocv - Canvas operation such as
resize,getContours,grayscaleare migrate fromopencvto native canvas operation.
See the Migration Guide in the README for detailed step-by-step instructions on upgrading from v4.x to v5.0.
DetectionOptions.autoDeskewoptionPaddleOcrService.deskewImage()method- Internal deskew service implementations (
BaseDeskewService,DeskewService,DeskewServiceWeb) - Deskew example file
- Deskew-related tests
- Better Separation of Concerns: Deskewing is an image preprocessing operation, while ppu-paddle-ocr focuses specifically on OCR
- Reduced Bundle Size: Users who don't need deskewing won't have to include that code in their bundles
- More Flexibility: ppu-ocv provides more advanced image processing capabilities beyond just deskewing, including grayscale conversion, thresholding, blurring, and more
- Updated
ppu-ocvto v3.0.0 (compatible with both v2 and v3)
- Updated migration guide examples to use ppu-ocv v3 API (
CanvasProcessorinstead ofImageProcessorfor canvas operations) - Added comprehensive "Models and Language Support" section clarifying:
- Default model is PP-OCRv5 mobile (English)
- Available model versions (v3, v4, v5) and types (mobile, server)
- Support for 40+ languages through ppu-paddle-ocr-models
- How to switch between models and languages
- Model output capabilities (text-only, not tables/formulas)
- Added migration guide with before/after code examples
- Enhanced usage examples with model switching instructions
See git history for changes prior to v5.0.0.