Skip to content

Rust steps 1: monochrome recompression, Compare pixels and snap geometry in Rust - #1014

Merged
DutchSailor merged 8 commits into
mainfrom
integratie/rust1
Nov 10, 2026
Merged

DutchSailor merged 8 commits into
mainfrom
integratie/rust1

Conversation

@DutchSailor

Copy link
Copy Markdown
Collaborator

First steps of moving CPU-heavy JavaScript work into Rust inside the existing Tauri app (not the Hayro kernel or the native rewrite). Each step keeps the JavaScript path as the fallback (web build, Android, older worker, errors) and has a parity test and a benchmark; numbers are in docs/rust-stappen.md. No version bump and no release.

Refs #902, Refs #465

What moved

Piece Where it runs now Parity JS → Rust (end to end) Force JS
Lossless monochrome recompression per image (#902): bilevel detection, 1-bit packing, Flate (zlib-rs), CCITT G4, smallest-stream choice new crate open-pdf-monochrome, Tauri command monochroom_beeld G4 byte-identical to the JS encoder (34 vectors, 40 A4 pages); Flate decodes identically; same filter choice on all benchmark images A4 scan 8-bit: 1847 → 32 ms; 20-page document: 2069 → 645 ms OPDS_RUST_MONOCHROME=0
Compare pixel pipeline (#465): tile render, background, ink masks, auto-alignment, dilated diff, clustering, scale correction PDFium worker request compare; only clusters cross IPC algorithms bit-identical to the JS modules on generated fixtures (incl. 526 floats); PDFium vs pdf.js rendering within 1 px edges, ≤0.002 mm alignment A0 pair: 2160 → 354 ms; A0 shifted: 2681 → 603 ms OPDS_RUST_COMPARE=0
Snap geometry extraction: path segments with form matrices, rotation, /UserUnit, dedup and the 48 px spatial index PDFium worker request snap_geometry, binary OSG1 through SHM points and edges equal within 1.5e-5 pt; intentional differences: clip paths and annotation appearances are not snap targets, n-only paths 200k segments: 8.1 s → 0.37 s, WebView heap 243 → 76 MB; 2M segments: 72 s → 4.9 s OPDS_RUST_SNAP=0

The worker announces new requests in its Ready line (caps: ["compare", "snap_geometry"]); an older sidecar never receives them, so the app stays on the JavaScript path until the worker is rebuilt.

Verification

  • cargo test -p pdfium-worker -p open-pdf-monochrome: 68 pass
  • cargo check -p open-pdf-studio -p pdfium-worker -p open-pdf-monochrome --lib --bins --tests (MSVC): pass
  • npx vite build, npm run build:hosted: exit 0
  • node scripts/draai-unit-tests.mjs: 8057 tests, 0 fail
  • npm run test:quality, Python library tests, README image check: pass

Not verified

  • No live check in the app: lossless compression of a scanned document, Compare on real drawings with auto-align, snapping on a large CAD drawing, each with and without the OPDS_RUST_*=0 flag. IPC cost in WebView2 is modelled, not measured.

🤖 Generated with Claude Code

The per-image work of the lossless monochrome mode (Flate inflate, packing
pure black/white eight-bit gray/RGB to one bit, Flate and CCITT G4 encoding,
choosing the smallest complete stream) now lives in the new crate
open-pdf-monochrome. The Tauri command monochroom_beeld takes the raw stream
bytes as a raw body plus the derived parameters in a header and returns an
ipc::Response. PDF parsing, eligibility rules, the document loop and the
replacement dictionaries stay in JS.

- G4 output is byte-identical to js/pdf/ccitt-g4.js (golden vectors in the
  crate tests, 40 full-page encodings in the benchmark script).
- Flate uses zlib-rs (already in Cargo.lock, Zlib licence): different bytes,
  same decoded samples; inflate is as strict as DecompressionStream.
- The size rule compares complete stream objects: JS passes both candidate
  dictionary sizes at /Length 0.
- JS falls back to its own encoder when not in Tauri, on Android, when the
  command is missing or fails (one warning), or with OPDS_RUST_MONOCHROME=0.
- scripts/bench-rust/09-monochrome.mjs checks parity and times JS vs Rust:
  a 300 dpi A4 page goes from 53 ms (JS, no yields) to ~17 ms (Rust plus an
  IPC model), a 20-page document from 900 ms to 645 ms. Notes in
  docs/rust-stappen.md.
)

New worker request `compare`: the worker renders both pages in tiles at the
compare resolution, estimates the paper colour, builds the ink masks, runs the
automatic alignment and the mask detection, and answers with the alignment,
statistics and the clusters (binary, through the shared memory), so no bitmap
crosses IPC. Masks are cached per worker (128 MB, cleared on trim).

The algorithms are line-by-line ports of ink-mask.js, mask-detect.js,
auto-align.js, fft.js and alignment.js, computing like V8 (Math.hypot and
Math.round rebuilt, fdlibm-style trig from the libm crate). Parity fixtures
generated from the JS modules (scripts/bench-rust/compare-golden.mjs) check
13 detections, 5 alignments, 4 ink masks and the background estimate bit for
bit. Column FFTs use a contiguous copy and independent steps run on a few
threads, both without changing the result.

The Ready line now lists `caps` so the app only sends `compare` to workers
that know it.
…fallback (#465)

compare_pixel_pair routes a page pair to a pool worker that advertises the
`compare` capability and returns the JSON header plus the packed clusters as
raw bytes. computePairChanges() tries it first on desktop (rust-compare.js)
and keeps the JS pipeline for the browser build, Android, in-memory sides,
files whose length on disk differs from the app's copy, older workers and any
failure (one console warning). OPDS_RUST_COMPARE=0 forces the JS pipeline.
…rison (#465)

scripts/bench-rust/09-compare-pixels.mjs profiles the JS pipeline per stage
on generated A3 and A0 drawing pairs with known edits, times the worker
request end to end including stdin/stdout and the shared-memory read, and
checks parity between pdf.js and PDFium rendering (--check). Results and the
tolerance are recorded in docs/rust-stappen.md.
# Conflicts:
#	docs/rust-stappen.md
Snap points, edges and the spatial index for PDF linework now come from
the PDFium worker on desktop instead of a walk over the PDF.js operator
list in JavaScript.

- pdfium-worker: new snap_geometry request. Walks the path objects of
  the already parsed page (form XObjects recursed with their matrices),
  flattens curves in four steps, applies the viewport matrix sent by the
  app, dedupes on the same 0.1 px grid and builds the same 48 px cell
  index. Compact binary result (OSG1) through the SHM region, fetched in
  chunks when it exceeds 64 MB. The Ready line lists caps so the app
  never sends the request to an older sidecar.
- app: extract_snap_geometry command and WorkerPool::snap_geometry with
  the same page affinity as rendering; OPDS_RUST_SNAP=0 forces the
  JavaScript route.
- js: pdf-snap-extractor uses the worker when the PDFium render path is
  available and the document has a file path; otherwise, or on failure,
  the PDF.js route as before (one warning, then JS for the session when
  the worker lacks the capability). Web and mobile builds are unchanged.
- Parity and benchmark script under scripts/bench-rust: equal within
  0.01 pt on lines, polylines, curves, rects, nested forms with /Matrix,
  CTM scaling, rotated pages, UserUnit; clip paths and annotation
  appearances are intentionally not snap geometry in the worker route.
  200k segments: about 8 s in JavaScript versus about 0.4 s end to end
  through the worker, a third of the WebView heap.
- docs/rust-stappen.md describes the step, the differences and the
  measurements.
# Conflicts:
#	docs/rust-stappen.md
#	open-pdf-studio/scripts/unit-testlijst.txt
#	open-pdf-studio/src-tauri/src/worker_pool/state.rs
#	pdfium-worker/src/main.rs
#	pdfium-worker/src/protocol.rs
@DutchSailor
DutchSailor merged commit dd81458 into main Nov 10, 2026
10 checks passed
@DutchSailor
DutchSailor deleted the integratie/rust1 branch November 10, 2026 04:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant