Repository navigation
M9: stream structure-tree checks without a total-element cutoff - #926
Merged
Merged
Conversation
Tree::read keeps MAX_ELEMENTS; Tree::read_with_limit takes the limit, so a unit test can reach the cut-off with a few elements. The characterisation test records today's behaviour: a figure past the limit is not checked. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
The structure checks built the whole tree as a Vec of elements, and stopped adding elements at 2,000,000, so later elements were never checked. One walk now reads the tree with an explicit stack and reports each element as it opens and closes. Each rule keeps only what it needs about the open elements and, for tables, the row spans that reach into later rows. Findings stay in document order, and the report still shows the first 200 per rule. Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
Member
|
Thank you, @cyfung1031! This has landed on main: "M9: stream structure-tree checks without a total-element cutoff". Please join us on Discord if you haven't already: https://discord.gg/artcraft |
h-a-n-a-s-h-i
pushed a commit
to feraremag2680/pdfcraft
that referenced
this pull request
Oct 11, 2026
…moved Conflicts, each resolved so both sides survive: - crates/a11y/src/structure.rs: main (storytold#926) rewrote the structure rules as a streaming visitor. The branch's wordings are ported onto it: the `{ty} element[ on page {p}]: {what}` frames via Finding::nested, the IRREGULAR wording with {min}/{max}, and the two heading sentences. - crates/engine/src/lib.rs: the branch's Label arms, plus main's new FillAnnotation (comment properties) and MoveObjects ("Move object" / "Move objects", added to EDIT_LABELS; es.tsv already has them). - crates/ui-egui/src/editing.rs: main's move_error message with the branch's edit_label. - es.tsv, parity/acrobat-features.toml: both sides' entries; the parity entry has main's shortcut evidence and sentence plus the branch's. - ATTRIBUTION.toml: main's, with es.tsv's sha256 recomputed. - engine tests: main's new MoveObjects undo assertion compares as_str(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The structure-tree checks (alternate text, tables, lists and headings) first copied the whole structure tree into a vector of elements, and stopped adding elements at 2,000,000. Elements past that point were never checked, so a missing figure or an irregular table late in a large file went unreported. This change reads the tree once with an explicit stack and reports each element as it opens and closes; each rule keeps only the state it needs. The cap is removed. The report still lists the first 200 findings per rule and counts the rest.
Problem and root cause
structure::Tree::readmaterialises every structure element (type, parent, children, annotation references and cloned attribute dictionaries) before any rule runs, andReader::kidsstops atMAX_ELEMENTS = 2_000_000. That cap is a correctness cap, not a presentation limit: elements after it are absent from all 13 structure rules.check()also built the tree on every run, even when no structure rule was selected.Scope and non-goals
crates/a11y/src/structure.rs), their call site incrates/a11y/src/lib.rs, the tree-existence flag used by four page rules incrates/a11y/src/content.rs, and their tests.MAX_HEADINGSand the visit, depth and title guards inheadings.rs), page-content decode limits, PDF/UA policy, finding wording, the 200-finding presentation limit (unchanged), the public API (unchanged), and dependencies (unchanged).Rule inventory
Every rule that reads the structure tree, and what this change does with it:
Four page rules (tagged-content, tagged-annotations, tagged-multimedia, tagged-form-fields) read only whether the catalog has a structure tree. They now receive that as a boolean and never traverse the tree.
Nothing is left on the old path.
MAX_ELEMENTSis removed.Before and after behaviour
6396e53makes the element limit injectable throughTree::read_with_limit, whichreadcalls withMAX_ELEMENTS, so behaviour is unchanged. Its testelements_past_the_limit_are_not_readrecords the cut-off: with a limit of 5, a figure after ten paragraphs is absent, and with a limit of 100 it is found.4b25bc2removes the cap.figures_and_tables_past_the_old_element_cut_off_are_checkedputs a figure without alternate text and an irregular table after 5,000 paragraphs, and both are reported. The same commit adds tests for: document order of findings (nested alternate text closes before its ancestors, and the findings still list in document order); the 200-finding bound with an exact count of the rest; cycles, shared kids and missing kids; the depth limit; arrays nested 300 deep; a marked-content list of 20,000 items; huge spans; and row spans that carry into later rows.Time and memory
Five interleaved rounds after one warm-up, medians reported. Each run is
/usr/bin/time -lon a small harness that callspdfcraft_a11y::checkon one file and writes each rule's status and findings. The harness is not part of this change. Peak footprint is thepeak memory footprintline; net is that peak minus the median peak of load-only runs of the same binary (open the file, no check).a11y_bench <pdf> check <out>under/usr/bin/time -lFull SHA-256 of each output: tagged
225b34f8f0c7b236edf8d4aac692c5839c5d622ac901a326e906293927516769; figures2d5ae5ae5d0100696894bcd88ff361355a1338350c79835f99426c452c791bac; tables303db6e35b5dd2f62ab2220a08b20e9d5b15e4f0f4521a8d4dbe7d9ce59f35d9. Each is the same on both sides.Peak footprint falls by 21% to 33% on all three inputs. Median wall time rises about 12% on the tagged input (0.48 s against 0.43 s) and falls by 11% and 5% on the other two. The walk allocates a small location record for each kid, which is the likely cause of the tagged result; the cause was not profiled.
Per element, the walk retains nothing once the element closes. While an element is open it holds one frame, about 100 bytes with its type name, so the stack grows with nesting depth, which is capped at 256. Three other structures are not per open element:
seenset of indirect kids is unchanged from before. It holds one reference key per indirect kid reached: about 8 bytes plus hash-table overhead.The remaining net footprint is mostly the document's object cache.
Document::try_getkeeps every object it loads, as it did before, and both versions read the same objects.Output identity
Local checks run
cargo fmt --all -- --check: exit 0.cargo clippy -p pdfcraft-a11y --all-targets -- -D warnings: exit 0.cargo test -p pdfcraft-a11y: 22 passed, 0 failed.cargo test -p pdfcraft-automation --test automation accessibility_check_report_and_fixes_through_tools: 1 passed, 0 failed. The engine and automation crates compile as part of this test.cargo test --workspace,cargo xtask ci,cargo xtask fuzz, andcargo xtask layers. No dependency or layering changed:Cargo.tomlandCargo.lockare untouched.Checks deferred to CI
cargo xtask ci(assets, parity, layers, WASM check). The local disk and CPU budget does not cover them.cargo xtask fuzz). The hostile-input cases above are unit tests.Malformed and hostile input safety
unsafe, and nounwrap,expectorpanicoutside tests. The crate's deny lints are unchanged and clippy passes. The walk uses an explicit stack, not Rust recursion. Depth is capped at 256, as before.cycles_shared_kids_and_missing_kids_are_read_once).nesting_is_read_to_the_depth_limit_and_no_further). Arrays nested 300 deep terminate (arrays_nested_deeper_than_the_limit_are_not_read).huge_spans_do_not_size_the_table).a_long_list_of_marked_content_counts_as_content).Independence
b8ddf71e30959dc8a5ca4b66c40b8fcaea68cf76. The branch is rebased onto it with no conflicts. Commits:6396e53(test seam) and4b25bc2(streaming walk).crates/render/src/raster.rsandvendor/(README and the hayro-interpret and hayro-syntax sources). This change touchescrates/a11y/src/content.rs,crates/a11y/src/lib.rs,crates/a11y/src/structure.rsandcrates/a11y/src/tests.rs. No path overlaps. The behaviour is separate: that change concerns renderer and vendored parser limits, while this one changes how the accessibility checker walks the structure tree.crates/fonts/src/pdf.rs,crates/edit/src/text.rs), M4: make page-label generation lazy across large documents #845 (crates/organize/src/labels.rs,crates/organize/src/lib.rs,crates/organize/src/tests.rs) and M4: traverse large bookmark trees lazily without dropping later items #876 (crates/automation,crates/engine/src/lib.rs,crates/organize/src/outline.rs,crates/organize/src/lib.rs,crates/organize/src/tests.rs,crates/render/src/inspect.rs,crates/ui-egui/src/panels.rs,parity/acrobat-features.toml). None of these paths is changed here.lib.rsandtests.rsalso exist under other crates, but those are different files.82b1093tob8ddf71) that touchcrates/annot,crates/ui-eguiandparity/acrobat-features.toml. None of those paths is in this change, and the a11y and automation tests were rerun after the rebase.Environment
🤖 Generated with Claude Code