Skip to content

reader: read and check a file's linearization (ISO 32000-1 Annex F) - #37

Merged
tannevaled merged 2 commits into
mainfrom
linearization-annex-f
Oct 31, 2026
Merged

tannevaled merged 2 commits into
mainfrom
linearization-annex-f

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

reader: read and check a file's linearization (ISO 32000-1 Annex F)

Linearized PDF -- Adobe's "Fast Web View" -- is ordinary PDF rearranged so a
reader with a byte-range transport can show the first page before the rest has
arrived. Annex F describes it in eleven parts; this reads the parameter
dictionary at the front (F.3.3, Table F.1) and checks it against the file.

BIBLIOGRAPHY, opened before any code: ISO 32000-1:2008 Annex F, downloaded to
~/Library/Application Support/go-pdfkit/spec/ so the next session has it.
F.3.1 (object groups and numbering), F.3.2-F.3.6 (the parts), Table F.1 (the
dictionary), Table F.2 (the hint tables). Every rule this enforces is quoted
beside the code that enforces it.

⛔ THIS READS AND CHECKS; IT DOES NOT LINEARIZE. The checker comes first
because a writer cannot be trusted without one: "qpdf says it is linearized" is
a judgement from outside, and what a writer needs while being built is the same
judgement from inside, on the bytes it just produced.

⛔ AND IT IS NOT A yes/no. Table F.1 says of the file length that "a mismatch
indicates that the file is not linearized and shall be treated as ordinary
PDF". A file can carry a well-formed dictionary and not be linearized, because
something was appended -- which is the ordinary fate of these files, since
F.3.6 says any incremental update invalidates the hint tables. So there are
three errors and they mean different things: ErrNotLinearized (never was),
ErrLinearizationMalformed (breaks a rule of Table F.1), ErrLinearizationStale
(was, and the file changed underneath it).

WHAT IS CHECKED: the dictionary is the FIRST object of the body (F.3.3 -- not
"a dictionary somewhere near the front", because searching would find one left
by an earlier write and call the file linearized when what a reader looks at
first is something else); it lies entirely within the first 1024 bytes, the
rule that makes the format worth having; every value is DIRECT, so no
cross-reference table is needed to read it; the required entries are present
with the right types; and /L equals the real file length.

NOT checked: the hint tables themselves (F.4). That is a much larger job and a
file can pass this and still send a reader to the wrong offset. The
documentation says so rather than leaving it to be assumed.

VERIFICATION -- the positives are made by QPDF, not by me. A checker tested
only on files its own author wrote agrees with its author's reading of Annex F,
which is the one thing it must not do. qpdf is the linearizer BentoPDF itself
uses.

  • 1, 2 and 7-page documents linearized by qpdf: accepted, with /N matched
    against the document's own page count and /O matched against the object
    number our reader gives the first page.
  • the un-linearized originals: refused. Without that control every assertion
    above would pass for a checker that said yes to everything.
  • a linearized file with bytes appended: ErrLinearizationStale.
  • sixteen malformed dictionaries, each CORRECT except for the one rule named,
    so a failure says what it says.
  • a second population: five real library scans from the pdfscans corpus,
    linearized by qpdf and judged by qpdf --check beside us. 5 agree, 0
    disagree, including a 68-page 13 MB document. The 7 skipped are encrypted.

⛔ A FINDING, from a branch the tests had left uncovered: Annex F's own
Example 2 writes /Linearized 1.0 while qpdf writes /Linearized 1. Both are
"a number" per Table F.1 and both must be accepted. My tests used only the
integer, and chasing the uncovered real branch is what surfaced the
disagreement between the specification's example and the implementation
everyone uses.

⛔ And one unreachable branch REMOVED rather than covered: a second presence
check on /Linearized, after firstBodyDict already refuses the file without it.
The distinction it would have duplicated is a real one -- absence means "not
linearized", a wrong type means "malformed" -- so the value is passed in
instead of looked up twice.

100.0% of statements, 0 uncovered blocks of 1697.

Co-Authored-By: Claude Opus 5 noreply@anthropic.com

tannevaled and others added 2 commits October 31, 2026 23:28
Linearized PDF -- Adobe's "Fast Web View" -- is ordinary PDF rearranged so a
reader with a byte-range transport can show the first page before the rest has
arrived. Annex F describes it in eleven parts; this reads the parameter
dictionary at the front (F.3.3, Table F.1) and checks it against the file.

BIBLIOGRAPHY, opened before any code: ISO 32000-1:2008 Annex F, downloaded to
~/Library/Application Support/go-pdfkit/spec/ so the next session has it.
F.3.1 (object groups and numbering), F.3.2-F.3.6 (the parts), Table F.1 (the
dictionary), Table F.2 (the hint tables). Every rule this enforces is quoted
beside the code that enforces it.

⛔ THIS READS AND CHECKS; IT DOES NOT LINEARIZE. The checker comes first
because a writer cannot be trusted without one: "qpdf says it is linearized" is
a judgement from outside, and what a writer needs while being built is the same
judgement from inside, on the bytes it just produced.

⛔ AND IT IS NOT A yes/no. Table F.1 says of the file length that "a mismatch
indicates that the file is not linearized and shall be treated as ordinary
PDF". A file can carry a well-formed dictionary and not be linearized, because
something was appended -- which is the ordinary fate of these files, since
F.3.6 says any incremental update invalidates the hint tables. So there are
three errors and they mean different things: ErrNotLinearized (never was),
ErrLinearizationMalformed (breaks a rule of Table F.1), ErrLinearizationStale
(was, and the file changed underneath it).

WHAT IS CHECKED: the dictionary is the FIRST object of the body (F.3.3 -- not
"a dictionary somewhere near the front", because searching would find one left
by an earlier write and call the file linearized when what a reader looks at
first is something else); it lies entirely within the first 1024 bytes, the
rule that makes the format worth having; every value is DIRECT, so no
cross-reference table is needed to read it; the required entries are present
with the right types; and /L equals the real file length.

NOT checked: the hint tables themselves (F.4). That is a much larger job and a
file can pass this and still send a reader to the wrong offset. The
documentation says so rather than leaving it to be assumed.

VERIFICATION -- the positives are made by QPDF, not by me. A checker tested
only on files its own author wrote agrees with its author's reading of Annex F,
which is the one thing it must not do. qpdf is the linearizer BentoPDF itself
uses.

  - 1, 2 and 7-page documents linearized by qpdf: accepted, with /N matched
    against the document's own page count and /O matched against the object
    number our reader gives the first page.
  - the un-linearized originals: refused. Without that control every assertion
    above would pass for a checker that said yes to everything.
  - a linearized file with bytes appended: ErrLinearizationStale.
  - sixteen malformed dictionaries, each CORRECT except for the one rule named,
    so a failure says what it says.
  - a second population: five real library scans from the pdfscans corpus,
    linearized by qpdf and judged by `qpdf --check` beside us. 5 agree, 0
    disagree, including a 68-page 13 MB document. The 7 skipped are encrypted.

⛔ A FINDING, from a branch the tests had left uncovered: Annex F's own
Example 2 writes `/Linearized 1.0` while qpdf writes `/Linearized 1`. Both are
"a number" per Table F.1 and both must be accepted. My tests used only the
integer, and chasing the uncovered real branch is what surfaced the
disagreement between the specification's example and the implementation
everyone uses.

⛔ And one unreachable branch REMOVED rather than covered: a second presence
check on /Linearized, after firstBodyDict already refuses the file without it.
The distinction it would have duplicated is a real one -- absence means "not
linearized", a wrong type means "malformed" -- so the value is passed in
instead of looked up twice.

100.0% of statements, 0 uncovered blocks of 1697.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The coverage gate failed at Linearized 96.0% while reading 100.0% here, and
the cause is the thing the test file's own header warns about: every positive
was built by RUNNING qpdf, the runner has no qpdf, so they all skipped. A
skipped test that looks like a pass.

testdata/linearized.pdf is a two-page document this package wrote and qpdf
linearized, 1 367 bytes. Still an independent implementation's output -- now a
deterministic one that needs nothing installed. The staleness test reads it
too, since that path had no other cover.

⛔ .gitignore ignores *.pdf repo-wide and every test here builds its documents
in CODE, which is the better habit and stays the default. The exception is
narrow and says why: a linearized file is precisely what this package cannot
produce yet. When it can, the fixture should be generated and the exception
removed.

Verified by running only the qpdf-free tests: Linearized back to 100.0%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 2f24bce into main Oct 31, 2026
9 checks passed
@tannevaled
tannevaled deleted the linearization-annex-f branch October 31, 2026 22:34
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