Skip to content

Add CCITT fax and JPEG-in-TIFF decoding via the Rust/WASM path#8

Merged
kleinicke merged 8 commits into
mainfrom
claude/determined-hopper-s9d2ka
Jun 17, 2026
Merged

Add CCITT fax and JPEG-in-TIFF decoding via the Rust/WASM path#8
kleinicke merged 8 commits into
mainfrom
claude/determined-hopper-s9d2ka

Conversation

@kleinicke

@kleinicke kleinicke commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

Adds decoding for TIFF compressions that previously failed to display, all routed through the Rust/WASM decoder rather than geotiff.js. The bundled tiff crate (0.10.3) already links the fax and zune-jpeg decoders, but several compressions never reached the screen.

CCITT T.3 compression was added as suggested in #2 by @130040167

Additionally add some tests for these test formats

Format Before Fix
JPEG-in-TIFF (7), incl. YCbCr tiff decoded it, but the committed .wasm was stale Rebuilt WASM; added defensive YCbCr → 3 channels (the crate returns interleaved RGB)
CCITT Group 4 / T.6 (4) tiff decodes it, but returns packed 1‑bit data the wrapper dropped (empty buffer) Routed through hayro-ccitt, expanded to 8‑bit
CCITT Group 3 / T.4 (3) + Modified Huffman (2) tiff errors Fax3 unsupported (compression 2 is the originally reported broken file) Decoded via hayro-ccitt, honoring FillOrder/T4Options (1D/2D, byte-align)
Uncompressed/LZW/PackBits/Deflate 1‑bit bilevel rendered blank (same empty-buffer bug) Expanded to 8‑bit

hayro-ccitt was chosen over the fax crate because the fax crate requires a leading EOL marker that TIFF Group‑3 streams omit (it failed on real libtiff G3 data). hayro-ccitt's DecodeSettings maps 1:1 to TIFF tags.

Why these weren't working

  • Compressions 2 and 3 are not supported by the tiff crate at all.
  • Compression 4 is supported by tiff but yields packed 1‑bit data; the wrapper's get_data_as_f32 had no 1‑bit arm and returned an empty buffer.
  • Compression 7 decoded fine, but the prebuilt .wasm predated the jpeg feature.

Implementation notes

  • CCITT output is expanded to 8‑bit grayscale and mapped through PhotometricInterpretation, so a fax image renders identically to the same image stored uncompressed.
  • Disabled the optional wasm-opt post-pass so the build no longer needs to download binaryen.
  • Replaced the macOS-only sed -i '' in build:wasm with a portable Node one-liner (it was silently failing on Linux/CI).

Testing

  • New npm run test:wasm loads the compiled WASM and asserts CCITT G3/G4 decode bit-identically to the uncompressed reference (0 differing pixels), the bilevel expansion, and 3‑channel JPEG output.
  • Added small TIFF fixtures under test-samples/ (a .gitignore exception keeps them tracked despite the global *.tif ignore).
  • Full suite (npm test) green; npm run lint clean.

Not covered

  • OldJPEG (compression 6) — deprecated pre‑TIFF6 scheme, not supported by tiff.
  • CCITT compression 2 is handled per spec (treated as Group 3 1D) but could not be verified end-to-end because Pillow won't emit raw compression‑2 files — happy to validate against a real sample.

claude added 8 commits June 16, 2026 00:58
The bundled tiff crate (0.10.3) already links the fax and zune-jpeg
decoders, but several TIFF compressions never reached the screen:

- CCITT Group 3 / T.4 (compression 3) and Modified Huffman (2) are not
  supported by the tiff crate at all.
- CCITT Group 4 / T.6 (compression 4) is decoded by the tiff crate, but
  it returns packed 1-bit data that the wrapper dropped (the 1-bit arm
  of get_data_as_f32 produced an empty buffer).
- JPEG-in-TIFF (compression 7) decoded fine but the stale prebuilt wasm
  predated the jpeg feature.

Changes:

- Route CCITT compressions 2/3/4 through the hayro-ccitt crate, which
  understands the TIFF encoding options (FillOrder, T4Options 1D/2D and
  byte-alignment). Output is expanded to 8-bit grayscale and mapped
  through PhotometricInterpretation so a fax image renders identically
  to the same image stored uncompressed.
- Expand uncompressed/LZW/PackBits/Deflate 1-bit bilevel images to 8-bit
  instead of returning an empty buffer.
- Report YCbCr color type as 3 channels (the tiff crate hands JPEG data
  back as interleaved RGB).
- Disable the optional wasm-opt post-pass so the build does not require
  downloading binaryen, and make the build:wasm path-rewrite step
  cross-platform (the macOS-only `sed -i ''` failed on Linux/CI).
- Add small CCITT/JPEG/bilevel TIFF fixtures and a WASM decoder
  regression test (npm run test:wasm) that checks the CCITT output is
  bit-identical to the uncompressed reference.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
Two more compression/photometric cases that previously fell back to
geotiff.js are now handled by the Rust decoder:

- Palette / RGBPalette images. The tiff crate refuses RGBPalette at
  colortype() and read_image(), so we detect PhotometricInterpretation
  3, rewrite the tag to BlackIsZero in a copy of the bytes (reusing the
  crate's compression/predictor/strip handling to decode the raw
  indices), then expand the indices through the ColorMap tag into
  interleaved 8-bit RGB. Output matches Pillow's palette->RGB conversion
  exactly.
- ZSTD (compression 50000) via the tiff crate's `zstd` feature. This
  pulls in the C zstd library, which grows the wasm binary from ~407 KB
  to ~708 KB.

Extends the WASM decoder test with palette and ZSTD cases. Existing
formats (LZW, Deflate, uint16, float32) re-verified against the rebuilt
binary; full suite green.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
24-bit grayscale is a post-decode reinterpretation (combine R/G/B into a
single value), applied in renderTiff/ImageRenderer and the stats block,
both of which run on the decoded rasters regardless of which decoder
produced them. The two `!use24BitMode` guards that forced these images
onto the geotiff.js path were a conservative legacy gate, not a decode
requirement, so removing them lets 24-bit depth images use the faster
off-thread WASM decoder like any other RGB TIFF.

Note: best confirmed by opening a real 24-bit depth TIFF in the
extension, since the webview render path can't be exercised headlessly.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
The Playwright workflow failed before any test ran:

- Both jobs start with `npm ci`, which requires a committed lockfile,
  but package-lock.json was gitignored. Commit it (and stop ignoring it)
  so `npm ci` has something to install from.
- The test-cursor job ran `npm run compile`, which rebuilds the WASM via
  wasm-pack/cargo — tooling the workflow never installs. Switch it to
  `npm run compile:quick`, which reuses the committed wasm binary and
  needs no Rust toolchain (matching the other job).

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
The "Run Extension" launch ran `npm: compile`, which chains `build:wasm`
(wasm-pack/cargo). On a machine without the Rust toolchain that step
fails, and because the script is `&&`-chained, esbuild never runs and
out/extension.js is never produced — so the extension host fails to
activate with "Cannot find module .../out/extension.js".

The wasm binary is committed, so F5 only needs esbuild. Point the
preLaunchTask at a new `npm: compile:quick` task, which builds with
esbuild alone (reusing media/wasm/tiff-wasm.wasm) in ~1-2s and needs no
Rust toolchain. Run `npm run build:wasm` manually when changing the
decoder.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
Loading a binary PBM (P4) failed with "Insufficient data for binary
PBM". The header reader (readToken) reads until the next whitespace, but
raster bytes are arbitrary and frequently not whitespace, so when the
single whitespace separating the header from the data was absent (or a
data byte simply wasn't whitespace) it swallowed the raster into the
numeric height field, leaving the read offset at EOF.

- Parse width/height/maxval with a digit-bounded reader that stops at the
  first non-digit, so it can never consume raster bytes.
- Skip the single header-terminating whitespace in the P4 branch (the
  P5/P6 branch already did this); this also fixes a latent off-by-one
  where valid binary PBM files read the separator as the first data byte.

Adds test/ppm-decode-test.js exercising the real _parsePpm across the
ASCII/binary PBM/PGM/PPM sample files, with the binary PBM checkerboard
as a regression guard.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
These format processors previously had no automated coverage (the same
gap that let the binary PBM bug through). Add small fixtures and a
smoke-test that drives the real parser entry points the decode worker
uses:

- NumPy .npy (float32, uint16, uint8 RGB) and .npz via
  NpyProcessor._parseNpy / _parseNpz
- PFM grayscale (Pf) and color (PF) via PfmProcessor._parsePfm
- PNG 16-bit grayscale and 8-bit RGB via UPNG.decode (the worker's PNG
  path)

Wired in as `npm run test:formats`. EXR/HDR are left out for now because
they can't be generated without OpenEXR/imageio here; RAW is out of
scope.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
The Playwright "test" job failed on two stale assertions that never
matched the current package.json (they were only reachable once CI's
npm ci was fixed):

- displayName is "Scientific Image Visualizer", not the old
  "Float and TIFF Visualizer".
- filterByMask was removed as a command, so drop it from the required
  command list.

Also remove the test-cursor job: it ran `playwright test
cursor-test.spec.ts`, which matches nothing (playwright.config.ts pins
testMatch to extension-core.spec.ts) and whose tests are all skipped.
Cursor is no longer targeted.

https://claude.ai/code/session_01KfaJ5sxn4ZXBHEebgcQK3e
@kleinicke kleinicke merged commit 2f2e984 into main Jun 17, 2026
1 check passed
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.

2 participants