Skip to content

Keep what a document says about itself, and its form - #11

Merged
tannevaled merged 1 commit into
mainfrom
keep-the-catalogue
Aug 27, 2026
Merged

Keep what a document says about itself, and its form#11
tannevaled merged 1 commit into
mainfrom
keep-the-catalogue

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

A document is more than its pages. Its catalogue says what language it is in, whether its structure has been marked up for a screen reader, how a viewer should open it — and, if it has one, where its form is.

Every verb here rebuilds the document around the pages it kept, and the catalogue was rebuilt as two entries: the page tree and the word Catalog.

before: MarkInfo Metadata Type ViewerPreferences Perms AcroForm Names Pages StructTreeRoot Extensions Lang
after:  Pages Type

Rotating a tax return kept all 199 of its widget annotations on the pages and threw away the field list that gives them meaning. That is not a form with something missing — it is half a form, and worse than none: every box is still there to be seen and not one of them can be filled in.

What now happens

The field list is rebuilt around the widgets that survived. A field merged into its own widget — how nearly every field with one place on the page is written — is already in the output, so it is pointed at rather than copied again; a field with widgets of its own is written afresh with its children pointing back at it, however deep the tree goes.

Measured over the 2 268-form corpus

files with a form 1 339
form survives a rotate 1 338 (was 0)
with every field 1 335
language kept 556 of 556
marked-up flag kept 738 of 738
panics 0

Every difference is accounted for, which is the part that matters:

  • The fields that go are exactly those whose widgets are on no page at all — unreachable in the original too, since a widget outside every page's /Annots is never drawn and cannot be clicked. us-uscourts/ao187.pdf has 179 of those beside its 156 real ones, and 156 + 179 = 335, which is what it had.
  • The one form that disappears entirely is qpdf's own remove-annotations output — a file that was already a half-form when we got it, all three of its fields pointing at widgets nobody had left on a page. We clean it up rather than propagate it.

What is deliberately not carried

Pages from several files keep their pages and nothing above them: two files have two catalogues and two forms whose fields may be named the same, and there is no honest way to choose or to merge.

catalogue.go names each dropped entry with its reason. The largest is /StructTreeRoot, the marked-up structure a screen reader follows: its elements name the page each belongs to and the numbered marks inside that page's content, and its parent tree is indexed by a number the page carries. Carrying it means rebuilding all three, and a structure tree pointing at the wrong pages is worse than none — a reader would read the document aloud in the wrong order rather than fall back on the text. That is the one worth doing next.

Found by the robustness campaign. 100% statement coverage in both the package and the command, go vet and gofmt clean, nine cross-compile targets.

A document is more than its pages. Its catalogue says what language it is in,
whether its structure has been marked up for a screen reader, how a viewer
should open it — and, if it has one, where its form is.

Every verb here rebuilds the document around the pages it kept, and the
catalogue was rebuilt as two entries: the page tree and the word Catalog.
Everything else went. Rotating a tax return kept all 199 of its widget
annotations on the pages and threw away the field list that gives them
meaning — not a form with something missing but half a form, which is worse
than none, since every widget is still there to be seen and none of them can
be filled in. The language and the marked-up flag went with it, which for a
government form is not merely untidy.

The field list is now rebuilt around the widgets that survived. A field merged
into its own widget — how nearly every field with one place on the page is
written — is already in the output, so it is pointed at rather than copied
again; a field with widgets of its own is written afresh with its children
pointing back at it, however deep the tree goes.

Measured over the corpus of 2 268 real forms: 1 338 of 1 339 forms survive a
rotate, 1 335 of them with every field, no panics. Every difference is
accounted for:

  - The fields that go are exactly those whose widgets are on no page at all,
    which could never have been seen or filled in the original either. One US
    courts form has 179 of them beside its 156 real ones, and 156 plus 179 is
    what it had.
  - The one form that disappears entirely is qpdf's own remove-annotations
    output — a file that was already a half-form when we got it, with all three
    of its fields pointing at widgets nobody had left on a page.

Language kept 556 of 556, marked-up flag 738 of 738.

Pages from several files keep their pages and nothing above them: two files
have two catalogues and two forms whose fields may be named the same, and there
is no honest way to choose between them or to merge them.

What is still not carried is named in catalogue.go with the reason for each.
The largest is /StructTreeRoot, which names the pages and the numbered marks
inside their content and needs a rebuild of its own — and which a screen reader
would follow into the wrong order if it were carried across wrong.

Found by the robustness campaign.
@tannevaled
tannevaled merged commit a43bafc into main Aug 27, 2026
2 checks passed
tannevaled added a commit that referenced this pull request Aug 27, 2026
A tagged document says which run of marks on which page is a heading, a
paragraph, a table cell, the label of a form field. That is the reading order a
screen reader follows, and for a government form it is often what the law
requires: RGAA in France, Section 508 in the United States, EN 301 549 in the
EU. #11 rebuilt the catalogue around the pages that survived and deliberately
left /StructTreeRoot behind, because carrying it wrong sends a reader through
the document in the wrong order rather than letting it fall back on the text.
It is now rebuilt.

Of 1 633 real forms from eighteen issuers, 1 021 carry a structure tree and
1 012 of those carry the number tree that indexes it; 6 869 of their 7 114
pages say under what number their marks are filed. The tree holds 853 108
elements, 470 269 marks named by number, 735 named by reference and 104 521
references to annotations.

WHERE THE KEYS LIVE. A mark is filed under a key the object holding it carries,
and it is not always the page. Of the 1 021 tagged forms, 991 file every mark
under a page; 14 file some under a page and some under a form XObject drawn on
it; and 3 — the UK lasting-power-of-attorney forms — carry no key on any page at
all and file everything under the XObjects, whose stream dictionaries say
/StructParents 0, 1, 2 and 3 while every page of the file says nothing. 6 869
keys sit on pages and 150 on drawn streams. That is read off the files, not off
a specification, and it settles two things: a mark inside a drawn stream cannot
be filed under the page's number, and the numbers handed to pages must start
above every key a drawn stream already carries — because a copied stream keeps
its key, a stream being written before the structure above it is rebuilt.

WHAT IS CARRIED. The tree of elements, each element's page, the marks inside
those pages, marked-content references, references to annotations, /RoleMap,
/ClassMap, /IDTree, and the number tree, rebuilt in both directions. The
numbers are handed out afresh: a page that kept the number it had in a file it
is no longer part of is a page a reader would look up and be told, with every
confidence, about somebody else's. An element whose every page has gone is
removed and its parent pruned; one with some pages gone keeps the children that
are left; an empty table cell is kept, because the shape of a table is part of
what it says, and the corpus has 30 345 of them. A page written twice carries
the structure once, on the first copy, since an element names one page.

MEASURED. Rotating all 1 633: 1 021 trees in, 1 014 out. The seven that lose
theirs are the seven whose /StructTreeRoot has no /K in the file we were given —
a root with a role map and nothing under it. 991 of the 1 021 come out identical
down to the last element, mark, reference and annotation reference. The thirty
that differ are all of them:

  - 23 files drop 142 references to annotations that are on no page of the
    source either — the same 142 the OBJR survey found; 104 379 of 104 521
    point at an annotation that is on a page. Ten of the 23 lose 236 elements
    with them, the elements whose only child was such a reference.
  - 4 files drop 109 marks inside a stream that no surviving page draws. 109 of
    the corpus's 215 such marks named a stream no page of the source drew
    either; the other 106 are carried, with the stream, under the key the
    stream carries. Two UK power-of-attorney forms go from 158 elements to 91
    this way and cerfa_12496 from 16 to 10.
  - 3 files drop 33 marks that have no page anywhere above them.

Nothing else moves: /RoleMap 984 in and 977 out, /ClassMap 356 and 349,
/ParentTree 1 012 and 1 004 — in each case the missing ones are the seven empty
trees, plus one file whose tree holds no mark and no annotation, so there is
nothing to file. /IDTree goes from 173 to 153: the 20 files that lose it have no
element carrying an /ID at all, so their identifier tree named nothing.

READING ORDER, NOT JUST PRESENCE. The check walks both trees in step. At every
level the children left must be the children that were there, in the order they
were there, with nothing added and nothing moved; elements are told apart by
what they contain, and the pairing backtracks, because two sibling table cells
of the same kind are indistinguishable until their contents are compared and a
checker that commits to the first plausible pairing reports a defect of its own.
Then, on the output alone: every element's /Pg names a page that exists (0
failures), every mark the tree names is a mark that page's content stream really
draws, and the number tree agrees with the tree in both directions — every mark
the tree claims is filed under the page's key, and every non-null place in the
page's array is a mark the tree claims (0 failures), and the same for the
annotations (0 failures).

The output names 1 387 marks that the page does not draw. The source names the
same 1 387, in the same 17 files, mark for mark: not one wrong pointer is
introduced. On the number tree we come out ahead of the source — 4 stream marks
are misfiled in the file we were given and 1 in ours, the one that remains being
two form XObjects that share a key and both claim mark 0, which a number tree
can only answer once.

The same check over five other verbs. Keeping the odd pages: 1 009 trees out of
1 021 — the five extra losses are files whose entire marked content is on even
pages, so keeping the odd ones leaves nothing to describe — 210 191 marks and
49 302 annotation references pruned with their pages, 358 261 elements removed,
0 unexplained. Writing a page twice: identical to the rotate, and the second
copy carries no /StructParents. Flatten and remove-annotations: all 104 521
annotation references go and 179 435 elements with them, and not one dangling
reference is left. Watermarking: identical to the rotate, so a stamp does not
disturb the marks underneath it.

Merging two files carries no tree, as the catalogue and the form already did:
two files have two /RoleMap and /ClassMap dictionaries in which the same name
may stand for two different things, and there is no honest way to choose. The
6 897 pages that said where their structure was filed now say nothing, rather
than pointing into a tree that is not there.

TWO DEFECTS THIS FOUND. A page written twice used to leave anything pointing at
its annotations pointing at the last copy rather than the first, which for a
structure tree means describing page one and sending a reader to a widget on
page two; the annotations a rebuild keeps now answer with the first copy, the
same choice the destination map already made for the page itself. And an element
whose own page was dropped but which survived through a child on another page
was left with no /Pg, and so silently inherited the page of whatever it sat
under — a page it is not on, stated as confidently as the right one would have
been. It now names the page its remaining content is actually on.

NOT CARRIED, with the reason in catalogue.go: a mark inside a stream no
surviving page draws, an element's /Ref, and the structure of pages from more
than one file.

#11's figures do not move. Over the whole corpus of 2 268: 1 338 of 1 340 forms
survive a rotate, 1 335 with every field, the same three files losing the same
fields; language 866 of 866, marked-up flag 1 057 of 1 057. Checked by running
the same harness against v0.6.0, which carries 0 trees of the 1 105 the corpus
offers.
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