Repository navigation
ADFA-6629: Rewrite shipped PDFs losslessly so their Brotli shrinks them - #55
davidschachterADFA wants to merge 4 commits into
Conversation
Every shipped PDF is already wrapped in Brotli, but its streams are Flate inside, and Brotli cannot shrink Flate output. optimize_pdfs.py stores the non-image streams plain, takes the Flate wrapper off JPEGs and re-encodes Flate images with PNG predictors when that is smaller, so the outer Brotli does the compressing. Images stay compressed. It works on a database's application/pdf rows (in place, so the Bookshelf triggers never fire) or, with --file, on one .pdf or plain Brotli .pdf.br such as a Bookshelf plugin book. Each rewrite is refused unless the object graphs match key for key, every stream decodes to the same data, qpdf's syntax check is clean, and the page count, named destinations and outline are unchanged. Measured: the six d/ rows go from 1,187,754 to 821,335 stored bytes; packed, the database's .br shrinks by 373,806 bytes. The seven books go from 20,357,211 to 13,016,362 bytes. All 3,816 pages render the same in poppler at 72 dpi. Adds pikepdf (qpdf inside, a pip wheel) to requirements.txt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CMZQuXT3GT7nEct2w4tg3
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CMZQuXT3GT7nEct2w4tg3
Independent Claude review (head dfc064e)No data loss, and no Critical or High findings. Every real PDF tested comes out lossless, and the database mode is safe. The weak spots are test strength (most of the graph check's structural guards are unpinned), a few cases that wrongly refuse a whole PDF, and one docstring claim the code doesn't honour.
R55-01: 23 single-edit mutants ran against the 40 tests, and 14 survived.
R55-02: a stream inside a compared structure is compared as
R55-03: a 509×64 1-bit Flate image with its pad bits set is refused; with them zeroed, it is accepted. The fixture
R55-04:
R55-05: a PDF with
R55-06:
R55-07: with
R55-08: CI already has what the suite needs: pikepdf is in requirements.txt and the workflow installs the brotli CLI.
Checked and sound:
Open risk, not a finding: the plain PDFs grow up to 3.5× (Android Notes from 9.8 to 33.9 MB). This is the PR's open item D4, and the addons PR should wait for the phone test. |
- R55-01: tests for every structural guard of the graph check (added key, appended element, /Info, both identity checks, stream key set, indirect on one side, strings, type changes), ICCBased images, the outline's actions and levels, object streams, XMP left alone, and file mode's not-smaller case. The stream-key test now adds a key. - R55-02: canonical() compares a stream by its dictionary less the rewritable keys, plus its decoded data, so a Flate JBIG2Globals, an outline /JS stream or a stream in a named destination no longer refuses the whole PDF. - R55-03: an image is re-encoded only if Pillow gives back the same sample bytes; it zeroes set pad bits in 1-bit rows. - R55-04: qpdf decode warnings are counted for images too, and a Flate-wrapped JPEG is unwrapped only when its Flate data is whole. - R55-05: signed PDFs (SigFlags bit 1, or /Perms) are left alone. - R55-06: file mode keeps the target's mode; a new file gets 0666 less the umask. - R55-07: the report ends with a summary by reason. A run in which a check refused every PDF that policy did not skip exits 1 and writes nothing; in file mode a refusal writes nothing. - R55-08: the brotli-CLI test skips through pytest; the JBIG2 test skips where pikepdf has no jbig2dec. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CMZQuXT3GT7nEct2w4tg3
Adds scripts/test_optimize_pdfs.py to the pytest step that already runs against requirements.txt (which has pikepdf), and installs jbig2dec, through which pikepdf decodes the JBIG2 test's fixture. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018CMZQuXT3GT7nEct2w4tg3
Response to the independent review (head dfc064e, now 6916e49)Thanks, all eight are fixed. R55-01 was the real gap: the first round's proofs pinned value changes only. None of the fixes changes the output for the shipped PDFs (proof below).
Notes on four of the fixes:
Proof:
|
ADFA-6629
Adds
scripts/optimize_pdfs.py, a ship-time step that rewrites PDFs losslessly so the Brotli they ship in compresses them better. It handles the database's PDF rows, and (with--file) a Bookshelf plugin book. Download: −373,806 bytes ondocumentation.db.br, and −7,340,849 on the Bookshelf books (PR 2, addons, will ship the rewritten books).Why
Every shipped PDF is already inside Brotli: a Content row, or a
.pdf.brplugin asset. But inside the PDF most streams are Flate, and Brotli can't shrink Flate output. So the step stores those streams plain and lets the outer Brotli do the compressing.Classic optimization was measured and rejected:
--recompress-flatestrips PNG predictors from images (Java Notes grew by 60 KB), so it is never used.What the rewrite does
[/FlateDecode /DCTDecode]JPEGs are stored bare, keeping their DCT decode parameters. This happens only when the Flate data is whole: the checksum is right and nothing follows the end./ID, so a second run changes nothing.In the database, rows are UPDATEd in place via
replace_row. Deleting and re-inserting the base row would fireAddBook/DeleteBookand replace the curated Bookshelf entry. Continuation rows are re-chunked as needed.Checks
Each rewrite is refused (the PDF stays as it is, and the report names why) unless all of these hold:
/Rootand/Info, the two graphs match key for key; only a stream's/Filter,/DecodeParmsand/Lengthmay change.check_pdf_syntax()must find nothing. It decodes every stream, JPEGs and JBIG2 included. JBIG2 goes through jbig2dec; where that's missing, a PDF with a JBIG2 image is refused./Destsand the/Names /Deststree) and the outline, with its nested items, actions and levels, are unchanged. A stream inside any of them is compared by its data.A rewrite that isn't smaller once compressed is left alone.
Left alone by policy, which is not a failure:
/AcroFormSigFlags bit 1, or a/Permsentry), because a rewrite would break the signature while every check passed;Report and exit code: the report ends with a summary, e.g.
6 rewritten; left as they are: 9 not smaller.Refusals are counted by check, policy skips by reason. Exit 1 means nothing was written: a row could not be read, or a check refused every PDF that policy did not skip. In file mode, a check refusal writes nothing and exits 1. A policy skip or a not-smaller result copies the input. The target keeps its mode; a new file gets 0666 less the umask.Real data
Database: 2026-10-19 item-B ship-time build (
4c2005de). Both copies were packed withrebuild_database.py --pack Content,TooltipTexts --page-size 4096, then compressed withbrotli -q 11 -w 24. The baseline is the same database packed the same way..br(download)d/guides go from 1,187,754 to 821,335 stored bytes.integrity_checkandforeign_key_checkpass.Books (addons
6d14aa7, file mode): 20,357,211 → 13,016,362 bytes.Render check: every page of all 13 PDFs (3,816 pages) renders identically in poppler at 72 dpi, in colour, before and after. The page counts and
pdfinfo -destslists are unchanged.The review's fixes change no shipped output. Rerun at 6916e49:
qpdf's CLI
--checkreports "skipping over invalid kid" on the rewritten Beej (3) and JavaJavaJava (44). These are empty dictionaries that their/Destsname trees already hold in the originals, at the same places, and the counts are the same before and after (Beej 3 and 3, JavaJavaJava 44 and 44). The graph check proves the trees are unchanged.Tests
scripts/test_optimize_pdfs.pyhas 74 tests. They run under pytest and as a plain script, and now in CI (python-tests.yaml). That step also installs jbig2dec for the JBIG2 test, which skips through pytest where pikepdf can't decode JBIG2.test_svg_images_to_webp.pystill passes on the shared helpers. The fixtures are PDFs built with pikepdf in the tests; the one binary is a 116-byte JBIG2 sample made with jbig2enc, embedded as base64. CI's exact step was run locally (Python 3.11, pip pikepdf 10.17.0, jbig2dec): 326 passed.Mutants: 75, none surviving. Each was applied to a copy of
scripts/and checked against the tests it targets, each failing for the reason it is named for:Separately, the brotli-CLI skip shows as "skipped" under pytest with the fix, and as "passed" without it.
Notes
svg_images_to_webp.py, before the dictionary re-mint andrebuild_database.py --pack. That order lives on the ADFA-3305 stack branches, not on main, so main's CLAUDE.md only names the step among the live scripts.pikepdf>=10(a pip wheel with qpdf inside), added torequirements.txt. CI also installs jbig2dec.largeHeap512 MB), so the phone test will measure Android Notes' load time and heap. PR 2 waits for it. If a cap is needed, it goes inkeep_rewrite().🤖 Generated with Claude Code
https://claude.ai/code/session_018CMZQuXT3GT7nEct2w4tg3