Repository navigation
Description
Linearized reads and checks a linearized file. Nothing in the fleet writes one. This is the last row of BentoPDF's secure group that needs no decoder, no OCR engine and no new format reader — the capability sweep counts it as one of sixteen missing, and the other fifteen each need something the fleet does not have.
⛔ The oracle we already have is WEAKER than the property
linearized.go says so itself, and the sentence is the whole reason to open this before writing code:
⛔ It does NOT validate the hint tables. Their format is F.4 and checking them is a separate, much larger job; a file can pass this and still have hint tables that send a reader to the wrong offset.
It also checks only one of Table F.1's claims against the file — /L against len(b). /O, /E, /T and /H are checked for presence and type, never against what is at those offsets.
So a writer that emitted an ordinary PDF with a correct /L and four invented integers would pass Linearized(). A gate built on it alone would be green for the wrong reason, and the green would be indistinguishable from the real thing. That is the failure this issue exists to prevent, not a risk to note in passing.
Two judges, and they must disagree before they agree
- Inside:
Linearized(), extended — see below. It is the judgement a writer needs during development, on the bytes it has just produced. - Outside:
qpdf --checkandqpdf --show-linearization(qpdf 12.4.2 is the reference implementation of this format in practice; it validates the hint tables and reports each violation by name). Same role poppler plays in the render tests: a foreign judge the test prints by.
⛔ The control that makes this honest: an ordinary, non-linearized file produced by the same writer must be REFUSED by both judges. A test suite in which every input passes measures nothing. Before the writer exists, the first commit should be the negative: feed today's writer output to qpdf --check and record that it says the file is not linearized.
What has to be built, in order
Annex F.3.1 lays a linearized file out in eleven parts. The writer already has what the layout needs — Reserve() fixes an object number before anything is written, and Put(ref, o) places it — so this does not need a second writer, it needs a second Finish.
- Partition: first-page objects against the rest. Needs the page tree, which is document knowledge, so the entry point takes the first page's object set rather than guessing it.
- Layout, the eleven parts in F.3.1's order, with the first-page cross-reference table at the FRONT (part 2) and the main one at the end.
- Hint tables (F.4): the page offset hint table (F.4.2) and the shared object hint table (F.4.3) are both mandatory. This is the hard half and most of the work.
- The fixpoint:
/L,/H,/E,/Tare offsets known only after layout. Two passes, with placeholders of FIXED WIDTH in the first, or the second pass moves what the first pass measured.
Extending the checker, which is a change of its own
Linearized()'s contract today is "the front of the file says what Annex F requires and what it says matches the file". Checking /O, /E, /T against the file, and parsing the hint tables, is a larger job and changes what a passing result means. It belongs in its own pull request, before the writer, so that the writer is developed against a judge that was not written to let it through.
What has NOT been read yet
ISO 32000-1:2008 Annex F itself, beyond the passages linearized.go quotes, and qpdf's own account of what --show-linearization validates. Both go before the design is called settled; this issue records the shape and the trap, not a finished plan.
Scope
Reader writes the file. The user-facing verb (pdfops linearize, a ribbon press) is a separate issue in go-pdfkit/ops and go-pdfkit/app, and should not be opened until this lands — a ribbon button for a capability that does not exist is the defect this fleet has filed before.
Prior art: priorart "linearize a PDF: write a linearized (web-optimized) file, Annex F" at 2026-11-12 12:02 CET reported prior art; checked: priorart --repo go-pdfkit/reader on linearization returned 25 tracker entries; read the titles: #37 and #38 are the CHECKER (read and check a file's linearization, and its README), the rest match on the generic word 'write' (the serialiser, string encoding, sparse xref). Nothing writes a linearized file. Also ran priorart --repo go-pdfkit/ops: 71 entries, all matching on 'write', none about linearization.
Activity
tannevaled commentedon Nov 12, 2026
⛔ The foreign judge this issue leans on is not installed in CI, which I should record here before the writer is built on the assumption that it is.
linearized_test.goopens by saying the positives are qpdf's output and not fixtures written to agree with this reading of Annex F, and that every test skips when qpdf is absent "because a skipped test that looks like a pass is how a checker comes to be believed without ever having been checked"..github/workflows/ci.ymlinstallsqemu-userand nothing else, so on CI every one of those tests skips, including the three added in #41. OnlyTestLinearizedReadsTheCommittedFixtureand the committed-fixture cases actually run there — which is precisely why that test was written, per its own comment.So the state is honest but weaker than it reads: qpdf agreement is measured on a workstation, by whoever runs it, and nothing in CI would notice if it stopped holding.
That matters more for the writer than for the checker. #40 says the writer must be judged by two judges, one of them
qpdf --check; if the second judge is absent from CI, the writer is in practice gated by the inside judge alone — the exact arrangement this issue was opened to avoid.⛔ And the one apt step there is the pattern that caused
go-pdfkit/ops#90:That one-liner cannot tell a mirror outage from a wrong package name, which is how an unreachable third-party mirror held a tagged release hostage for two hours on 2026-11-11.
go-fleettools/fleettoolscmd/aptneedexists for it and is in review as#252.Both are the same one-line change, once that lands:
⛔ Worth saying plainly: adding qpdf to CI will make tests run that have never run there, and some may not pass first time. That is the point of adding it, and it should land before the writer rather than after, or the writer's first red will be indistinguishable from the judge's arrival.
linearize: reach every branch, and delete the two that no file could
linearize: the /H bound overflowed on 32-bit, and the check is fuzzed…
tannevaled commentedon Nov 12, 2026
Bibliography step for the writer, measured rather than assumed — #41 covers the checker, this is what the second judge actually gives us.
⛔⛔
qpdf --checkEXITS 0 ON A FILE IT SAYS IS NOT LINEARIZEDMeasured on qpdf 12.4.2, with a non-linearized copy of the committed fixture (
qpdf in.pdf out.pdf, no--linearize):Both commands report the verdict in their text and exit 0 either way. A test harness that shells out and reads
$?would call every file linearized, including one with no linearization at all — the foreign judge would be present, green, and measuring nothing. Same shape as theprwaitrule already written in CLAUDE.md: bind the verdict to the SHA, and readconclusion, never the exit status.So the harness for the writer must match the sentence (
File is linearized/is not linearized), and must assert the negative control — an ordinary file produced by the same writer is reported as not linearized — or the judge is unfalsifiable.--show-linearizationis a STRUCTURAL oracle, not a yes/noThis is the part that changes the writer's plan. On the committed fixture it prints the whole hint-table content and the part assignment:
Two consequences:
The writer can be judged field by field, not pass/fail. Run
--show-linearizationon qpdf's linearization of a document and on ours, and compare the two readings. A disagreement names the field —min_page_length,nbits_delta_content_offset— instead of saying "not linearized". That is a much sharper oracle than Write a linearized file, and extend the checker first so the writer is not its own judge #40 assumed when it was opened.The part assignment is printed, so the layout can be checked before a single hint byte is written.
part 6 (first page): 7-9is the F.3.1 partition this fleet has to reproduce, and qpdf will compute it for any input. The first milestone of the writer is therefore "our part assignment equals qpdf's for the same document", which needs no hint tables at all and fails loudly if the object partition is wrong.What this says about the hint tables
Every field above is bit-packed at a width derived from the data (
nbits_delta_*beside eachmin_*). So the work is not writing a struct: it is computing, per document, the minimum of each quantity and the number of bits needed for the largest delta from it. That is where a writer will be wrong in ways/Land/Ocannot show, and it is why #41's checker — which deliberately stops before F.4 — is not sufficient on its own.Still unread
ISO 32000-1:2008 Annex F itself, beyond the passages
linearized.goquotes. The above is qpdf's behaviour, which is the implementation everybody uses but is not the specification; where the two disagree the spec wins and the disagreement is worth its own note.tannevaled commentedon Nov 12, 2026
Measured the first milestone from the comment above — "our F.3.1 partition equals qpdf's for the same document" — with a throwaway probe against both committed fixtures, before writing any of it for real. Two findings, and the second is a constraint I would otherwise have built the wrong thing around.
⛔
/Parentmakes naive reachability useless: everything reads as sharedFirst attempt: for each page, walk its object graph; an object reached by one page is private to it, by several is shared.
Measured on
testdata/linearized.pdf: 6 of 6 objects shared, both private sets empty. A page's dictionary carries/Parent, which reaches the/Pagesnode, whose/Kidsreach every other page — so every page reaches every object, and the partition collapses.Not following
/Parentgives, for that fixture:/Pagesnode)Exact agreement — once part 6 is the union of "private to page 1" and "shared with page 1". Object 9 is used by both pages, and qpdf puts it in part 6 rather than part 8, because the first page must be complete at the front of the file; part 8 is for objects shared among later pages only. That is one sentence of Annex F that a reasonable reading of "shared" gets backwards.
⛔⛔ With object streams the unit of placement is the CONTAINER, not the object
The same rule disagrees on
testdata/linearized-objstm.pdf:Resolving the disputed numbers:
The probe names the logical object (12). qpdf names object 10, the object stream that contains it. A compressed object has no byte offset of its own — it lives inside an
ObjStm— so it cannot be placed in a part at all. What gets placed is the container, and it must go in the earliest part any of its members requires.So the writer's partition cannot be computed over logical objects. It has to be computed over placement units: a top-level object is its own unit, and an object stream is one unit carrying all its members. This also means a linearizing writer that wants a small first page may have to re-pack object streams — qpdf's
--object-streams=generateproduced a layout where part 9 is empty and everything rides in one ObjStm, which is fine for a 2-page file and would not be for a 200-page one.Where that leaves the milestone
It stands, with the rule stated correctly:
/Parent;Confirmed exactly against qpdf on the traditional-cross-reference fixture. The object-stream fixture is the one that found rule 4, which is the argument for having committed both.
Probe deleted; nothing of it is in the branch.
tannevaled commentedon Nov 12, 2026
Third measurement, on the part that will be wrong invisibly: the page offset hint table's header. Throwaway probe, deleted; the branch is untouched.
⛔ I have still not read Annex F's Table F.3. What follows is a hypothesis tested against values qpdf had already printed, which is why it is worth anything — the numbers were not mine to choose.
The header layout, established by measurement
Hint stream of
testdata/linearized.pdf(object 6,/H [ 550 127 ],<< /Filter /FlateDecode /Length 50 /S 43 >>), decoded to 71 bytes, beginning:Read as alternating big-endian 32-bit value / 16-bit bit-width, then four 16-bit fields:
min_nobjectsfirst_page_offsetnbits_delta_nobjectsmin_page_lengthnbits_delta_page_lengthmin_content_offsetnbits_delta_content_offsetmin_content_lengthnbits_delta_content_lengthnbits_nshared_objectsnbits_shared_identifiernbits_shared_numeratorshared_denominatorTwelve of thirteen agree exactly, and the header is 36 bytes. The bit-packed per-page entries start there;
/Sin the stream dictionary is 43, which is where the shared-object section begins — so the page section occupies bytes 36–42 and the shared section 43–70. That makes/Sa byte offset into the decoded stream, not an object number.⛔⛔ The one disagreement is the trap: offsets exclude the hint stream itself
Stored
first_page_offsetis 550. qpdf reports 677. The difference is 127 — exactly the hint stream's own length from/H [ 550 127 ]. And 677 is where object 7, the first page, actually sits.So the offsets written into the page offset hint table are computed as if the primary hint stream were not in the file, and a reader adds its length back. That has to be true of the format: the hint stream's length is not known until the offsets it contains have been computed, so the numbers cannot be absolute without a fixpoint.
For the writer this is the difference between working and silently wrong: every offset in the table would be off by the hint stream's length, and nothing in
/L,/O,/Eor/Twould show it — the file would passCheckLinearizedfrom #41 completely, and a reader asking for the first page would land 127 bytes into the wrong object. It is the exact failure mode #40 was opened to guard against, and it is invisible to every check that exists today.What this adds to the plan
qpdf --show-linearizationdecodes these fields, so the writer can be compared field by field — and thefirst_page_offsetrow is the one to check first, because it is the one where the stored and reported values legitimately differ.Still unread: Annex F itself. Where it and this measurement disagree, the specification wins and the disagreement deserves its own note.
linearize: the checks review found missing, and the ones qpdf disagre…
linearize: read the line ending instead of guessing it, and correct a…
linearize: refuse a value this build cannot address, instead of trunc…
linearize: the refusal was written once and tested in one place only
linearize: check the parameters against the document, not only agains…
README: the 32-bit refusal, and a count that three rounds of tests ha…
tannevaled commentedon Nov 13, 2026
/Eis the one parameter the checker still does not gate, and it is the one a writer is most likely to get wrong — so leaving it is precisely the "writer is its own judge" problem this issue is about. I measured what it actually equals. Writing it down rather than implementing it now, because it is a change the size of #41 and not a rider on one.What
/Eequals, measuredqpdf --show-linearization testdata/linearized.pdf:And the file's front cross-reference section — the one its last
startxrefpoints at, at byte 216:The front section lists objects 4–9: by construction, every object that precedes
/E. Its highest-offset entry is object 9 at 871, and871 + n + eolAfter(b, 871+n) == 902 == /E.So the rule is:
That is structural, not a coincidence of one file. An earlier reading of it ("obj 9 at 871 gives 902") was true but rested on a single fixture; what makes it a rule is why that section contains those objects.
Both shapes
testdata/linearized-objstm.pdfis the same structure through an XRef stream: object 5 at byte 216,/Index [ 4 9 ],/W [ 1 2 1 ],/Prev 1327, and/E 1131. So the check must read one section in both shapes, and must take the maximum over type-1 entries only — an object inside an object stream has no file offset of its own, but its container does, so the maximum is unaffected.Why it is not a small change
readXrefSection(xref.go:110) reads one section, but as a*Documentmethod that merges into the document's table;Openhas already walked/Prevby then. Reading the front section alone without disturbing that needs a seam.eolAfter/spansalready encode the end-of-object subtlety (linearize: check the parameters against the document, not only against themselves #41), and this must reuse them rather than re-derive the rule.spans-subsumption trap from linearize: check the parameters against the document, not only against themselves #41 round 3 applies directly here, since an/Ebound could easily be subsumed by the existing "inside the file" check.What the checker does today
Only
0 < /E <= len(b), documented in the README as explicitly NOT a gate, so a writer that miscomputes/Epasses here and failsqpdf. That wording stays honest until the above lands.Refs #41.
tannevaled commentedon Nov 13, 2026
I prototyped the rule from my previous comment and measured it on both shapes — it holds — then found the reason not to ship it yet. Recording both, because the second part is the kind of thing that becomes a latent bug.
The rule verified, both shapes
Reading the front section in isolation (a fresh
DocumentwithreadXrefSectioncalled once —setEntrykeeps the first entry seen, and in a linearized filestartxrefpoints at the front section, so its entries are exactly that section's):The object-stream shape exercises the part that could have gone wrong: type-2 entries have no offset of their own, their container does, so skipping them leaves the maximum unaffected.
⛔ Why it is not shippable yet: the hint stream's placement
Annex F permits the primary hint stream on either side of the first page. If it follows the first page then
/E < hintOffset, the hint stream is still listed in the front section, and a naive "highest offset" would exceed/Eand refuse a valid file. So the rule needs the/Hobjects excluded:⛔⛔ And neither committed fixture can tell whether that exclusion works. qpdf places the hint stream before the first page in both — 550 against a maximum of 871, 630 against 953 — so removing the exclusion entirely leaves both green. It is a branch no test can distinguish: present, apparently exercised by the coverage gate (the
continuedoes run), and proving nothing. That is the same vacuity #41 round 3 spent four tests closing, and I would be re-introducing it deliberately.Shipping the exclusion untested, or dropping it and false-refusing another producer's files, are both bad trades.
What it needs
A fixture whose primary hint stream follows the first page. qpdf does not appear to emit one, so this wants either a hand-built file or another producer — which is worth having on its own terms: both current fixtures come from one tool, so they agree about everything that tool decides, including this.
Until then the README's statement stands and is honest:
/Eis bounded to0 < /E <= len(b)and is explicitly NOT a gate.The seam itself is undramatic and costs nothing to redo:
Documentis constructible in-package,readXrefSection(xref.go:110) reads exactly one section, andeolAfter/spansalready encode the end-of-object rule.Refs #41.
tannevaled commentedon Nov 13, 2026
/Eis gated in #43. Recording the next gap while the evidence is in hand, because adding the Ghostscript fixture handed us a ready-made negative case for it.The hint tables' CONTENTS are still unchecked
The checker verifies that
/Hpoints at a real stream and that the stream's extent matches what/Hdeclares. It does not decode the tables or compare what they say with the file.qpdf --show-linearizationdoes, and on the new fixture it disagrees with Ghostscript five times:65536for every shared object is1 << 16, which looks like a field written at a fixed width rather than computed — three identical wrong values is not three independent mistakes.⛔ Our checker accepts this file (measured: deep check ACCEPTED), and so it should today — nothing it claims to check is wrong. But it means
testdata/linearized-gs.pdfis simultaneously:/Hexclusion testable (linearize: /E is the end of the first page, and the file already says where that is #43), andThat is unusually convenient and worth not losing: the hard part of such a check is normally finding a real file that is wrong in a known way, and this one is wrong in five.
⚠ One caution for whoever takes it: a file qpdf warns about is not automatically a file that violates Annex F — qpdf's computation could be the stricter reading. The warnings are a lead, and the spec is the judge. Worth settling before a gate is written on them, not after.
Where this leaves #40
The checker now establishes, against the document:
/L,/N,/O,/H's extent,/T(both meanings), and/E. What a writer could still get wrong without being told: the hint tables' contents, and/P, which is a number nothing cross-checks.Refs #43.
7 remaining items
linearize: the /S test was weaker than the /S check, and -1 was a pos…
linearize: the shared object hint table is required, and nothing look…
tannevaled commentedon Nov 14, 2026
First of the ten properties from the Annex F reading has landed (#46), and one limit it leaves open is recorded here because the README now points at this issue for it.
Landed
CheckLinearizednow requires the primary hint stream's dictionary to carry/S, as a direct integer that is not negative.⭐ The question it stands or falls on is settled, and by the spec rather than by qpdf's behaviour — §F.4.2:
The first page always has at least its page object, so the table is never empty and no conforming writer may omit
/S. Table F.2's flat "(Required)" is not a default that a document without shared objects escapes. Had that gone the other way this check would have refused conforming files, which is the worst thing a checker can do.Not stricter than the reference either:
qpdf --checkrefuses a/S-less file too, withERROR: Integer operation attempted on invalid object— the symptom of reading a missing/Sas an integer rather than a statement about what is absent.⚠ Open: the upper bound on
/S/S 99on the committed fixture is accepted here and reported by qpdf as/S (shared object) offset is out of bounds.The reason it is not closed in #46 is worth stating rather than leaving as an omission:
/Sis an offset into the decoded stream data. Bounding it means decoding the hint stream, which is the first step of reading the tables themselves — so it belongs with §F.4 and not smuggled into a change about the dictionary. The same applies to "the page offset table shall be the first table and shall start at offset 0": both become checkable the moment the stream is decoded, and neither is checkable before.So §F.4 now has a concrete first deliverable that is not the whole table format: decode the primary hint stream and bound every position its dictionary names.
Where the ten stand
/S/Sonly/Prev⛔ One note on the last row, because it does not belong with the others. It comes from a comment inside Example 11, not from normative text:
"should not", in a comment, in an example — which is weaker than a normative should, let alone a shall. So it is the one row here that must not become a refusal; at most it is something to report. Worth saying before somebody reads this table and implements all eleven alike.
⛔ And a second caveat on the whole table: I derived these from §F.3.1 and §F.3.6. Each still needs its own citation checked at the point of implementation, the way #46's did — the
/Srow only became safe to enforce once §F.4.2 settled that the shared object table is never empty, and that sentence is nowhere near the row it decides.Still unread
§F.4's bit-level layouts. The decode step above does not require them; reading the table contents does.
tannevaled commentedon Nov 14, 2026
Correcting my own previous comment before anybody acts on it. I wrote that §F.4 "now has a concrete first deliverable that is not the whole table format: decode the primary hint stream and bound every position its dictionary names." That is still the right next step, but it is not the small one it sounds like, and the reason is security rather than effort.
⛔ Bounding
/Smeans decoding attacker-controlled bytes in the cheap path/Sis an offset into the decoded stream data, so the bound needsDecodeStream. The hint stream's bytes come straight out of the file, which means an attacker chooses them.Decoding is capped —
filter.go:14,maxDecodedSize = 1 << 30, enforced byreadAllCappedthrough anio.LimitReader, so a decompression bomb is refused rather than fatal. Measured by reading, not assumed.⚠ But 1 GiB is the cap, and a hint stream is hundreds of bytes to a few kilobytes. A few hundred bytes of Flate in a 1 KB file could make
CheckLinearizedallocate up to a gigabyte before anything objected — in a function whose entire selling point is that it is the cheap judgement a writer can run on every file it produces. That is a denial-of-service shape, not a correctness one, and it is exactly the sort of thing that gets added without being noticed because the cap already exists and "a bomb is refused" is true.What that implies for the work, rather than against it
The bound is still worth having; it needs a size limit derived from the file, not the generic 1 GiB one. The hint tables index this document's objects, so their decoded size is a function of the page count and the object count — both known by then. A limit of that shape refuses a hostile stream long before the generic cap and cannot false-refuse a real one.
Deriving it properly needs §F.4's layouts, which is the same reading the table contents need. So the honest ordering is:
/Nand the object count ;Not: decode first and bound later.
⭐ And it is worth saying plainly that the reviewer who found the
/Srange gap called it "cheap to add, or document as an open limit". The second half was the right call, and I would not have seen why until I went to add it — the cap that makes decoding safe is also what makes it expensive, and those are easy to confuse.The README's statement of the limit stands unchanged; this only corrects what closing it costs.
linearize: /S cannot point inside the page offset table's own header
linearize: /S cannot point inside the page offset table's own header (#…
linearize: bound /S above where it costs nothing — an unfiltered hint…
linearize: bound /S above where it costs nothing — an unfiltered hint…
linearize: bound /S above where it costs nothing — an unfiltered hint…
linearize: bound /S above where it costs nothing — an unfiltered hint…
linearize: bound /S above where it costs nothing — an unfiltered hint…
linearize: bound /S above where it costs nothing — an unfiltered hint…
linearize: bound /S above where it costs nothing — an unfiltered hint…
README: /S has a floor and a conditional ceiling, and the judge now a…
README: /S has a floor and a conditional ceiling, and the judge now a…