Skip to content

pdfkit: share one XObject per pixel-identical bitmap - #26

Merged
tannevaled merged 1 commit into
mainfrom
bitmap-dedup
Sep 4, 2026
Merged

pdfkit: share one XObject per pixel-identical bitmap#26
tannevaled merged 1 commit into
mainfrom
bitmap-dedup

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Why

html2pdf's corpus renders react.dev into 12 pages and 9,095,777 bytes, most of it 166 image XObjects — a handful of icon SVGs, rasterised and stored as raw FlateDecode RGB samples once per placement. DrawImage/DrawPNG/DrawJPEG built a fresh XObject on every call even when the pixels were identical, so a page that reuses one icon paid for it every time: in file size, and in a redundant flate pass.

What

  • imageKey content-addresses an image by its uncompressed samples plus the parameters that give them meaning (filter, colour space, width, height, bits per component) and the alpha samples that would become the /SMask. The JPEG path keys on the raw DCTDecode bytes. Lengths go into the digest ahead of the payloads, so no shift of bytes between samples and alpha can forge a match.
  • Document.imageFor(key, build) looks the key up and calls build only on a first sighting — so a duplicate costs a SHA-256 pass and nothing else, and never pays for compression twice.
  • The cache is on the Document, so two pages drawing the same bitmap share one stream and one /Im<i> name. placeImage no longer registers; it records the resource on the page and paints it.

Determinism

Preserved by construction: the cache is consulted, never iterated. Object order and /Im<i> numbering still come from the images slice in first-sighting order, exactly as before. TestImageDedupStaysDeterministic writes one document twice, and two identically-built documents once each, and compares bytes.

Tests

Identical DrawImage collapses to one /Subtype /Image with both placements painting /Im0; distinct images do not collapse; same for DrawPNG (3 draws → 1 image + 1 soft mask) and DrawJPEG (a byte-identical copy, not the same slice, so identity cannot be doing the work); same RGB with different alpha stays two objects; cross-page sharing keeps one stream but a /XObject dict on each page.

go vet clean, gofmt clean on the touched files, coverage 100.0% total with every function at 100.0%, cross-compiles for wasm and windows.

🤖 Generated with Claude Code

html2pdf's corpus renders react.dev into 12 pages and 9,095,777 bytes, of
which the bulk is 166 image XObjects: a handful of icon SVGs, rasterised
and stored as raw FlateDecode RGB samples once per placement. Every
DrawImage/DrawPNG/DrawJPEG call built a fresh XObject even when the pixels
were identical, so a page that reuses one icon paid for it every time — in
file size and in a redundant flate pass.

Content-address the image instead. imageKey hashes the *uncompressed*
samples together with the parameters that give them meaning (filter,
colour space, width, height, bits per component) and the alpha samples
that would become the soft mask; the JPEG path keys on the raw DCTDecode
bytes. Document.imageFor looks the key up and only builds — and only
compresses — on a first sighting, so a duplicate costs a hash and nothing
else. The cache lives on the Document, so two pages drawing the same
bitmap share one stream and one /Im<i> name; placeImage no longer
registers, it only records the resource on the page and paints it.

Determinism is preserved by construction: the cache is consulted, never
iterated. Object order and /Im<i> numbering still come from the images
slice in first-sighting order, exactly as before, and the zero Options
still promise byte-identical output for identical input —
TestImageDedupStaysDeterministic writes one document twice and two
identically built documents once each, and compares bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 052e432 into main Sep 4, 2026
1 check passed
@tannevaled
tannevaled deleted the bitmap-dedup branch September 4, 2026 14:48
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