Phase 8 #7 of REVIEWS/DART_PDF_VS_MAKEPAD_PDF_GAP_ANALYSIS.md ("PDF/UA structure tree"). Design and merge criteria in REVIEWS/adr/0011-pdf-structure-tree.md. Investigating the accessibility gap surfaced four defects in the marked-content operators the structure tree depends on. The first is not an accessibility problem at all. 1. BMC never parsed, and corrupted the colour of everything after it. The dispatcher matched b'B'+b'M' only when the third byte was a delimiter; BMC's third byte is 'C', so the arm never fired. Because the operator was never recognised it never CLEARED ITS OPERAND, and the leftover /Tag shifted the operands of whatever came next: 1 0 0 rg -> RgbFill(1.0, 0.0, 0.0) red /Span BMC 1 0 0 rg -> RgbFill(0.0, 1.0, 0.0) green Tagged documents are precisely the ones containing BMC, so the documents that tried hardest to be accessible rendered wrong colours. This is the fifth instance of the operator-shadowing family already fixed for cm, rg, gs, b/b* and end-of-stream text operators. ADR 0009's every_multi_char_operator_parses_as_itself test exists to stop exactly this - and would have caught it, except BMC was one of two operators excluded from its table as "genuinely unimplemented". Excluding a known-broken operator from the test whose job is finding broken operators is how it survived. The exclusion list is gone. 2. BDC discarded its property list, which carries /MCID - the only link between a run of page content and the structure element describing it. Without it a tree can be parsed but never attached to anything. 3. The op produced by BDC was named MarkContentBmc, and BMC produced nothing. The names were the wrong way round, which is how the missing arm survived review: the enum looked like it had a BMC case. 4. Found while fixing 2: the content lexer had no dictionary support at all. It read `<` as a hex string without checking for a second `<`, so `<</MCID 0>>` parsed as the string "0C0D0". Content streams now parse direct objects properly, bounded at 16 levels; a single `<` is still a hex string and a test pins that. On top of that, new pdf-document/src/structure.rs: /StructTreeRoot, /StructElem trees, /RoleMap resolution, depth-first reading order, /Alt, /ActualText, /E, /Lang, and MCID-to-element lookup. Cycles in /K are cut at the first repeat and reported by object number rather than expanded to the depth bound. Accessibility findings are mechanical checks reported as findings, NOT a conformance verdict: there is no is_pdf_ua and no ComplianceReport, and a mutation-checked tripwire test fails if either appears. Real PDF/UA conformance needs human judgement - whether /Alt text is accurate is not mechanically decidable - so claiming it from six checks would be exactly the overclaim this codebase keeps removing. 7 corpus fixtures, 14 acceptance tests, 22 unit tests, and a parse_content_dict fuzz target for the new object parser. TEST_TARGET=pdf 536 -> 575, TEST_TARGET=pdf-ui 580 -> 619. rustfmt and clippy -D warnings clean.
342 lines
11 KiB
Rust
342 lines
11 KiB
Rust
//! Structure-tree and accessibility acceptance tests.
|
|
//!
|
|
//! The merge criteria from `REVIEWS/adr/0011-pdf-structure-tree.md`.
|
|
//!
|
|
//! The most valuable test here is not about accessibility at all:
|
|
//! [`bmc_no_longer_turns_red_into_green`] guards a colour-corruption bug
|
|
//! that affected every tagged document.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use nigig_pdf_document::structure::{AccessibilityIssue, StructRole};
|
|
use nigig_pdf_document::PdfDocument;
|
|
use nigig_pdf_graphics::content::parse_content_stream;
|
|
use nigig_pdf_graphics::recording::{RecordingDevice, RenderCommand};
|
|
|
|
fn corpus(relative: &str) -> Vec<u8> {
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../tests/corpus")
|
|
.join(relative);
|
|
std::fs::read(&path).unwrap_or_else(|e| panic!("missing {}: {e}", path.display()))
|
|
}
|
|
|
|
/// `BMC` never matched its dispatch arm, so it was neither recognised nor
|
|
/// *cleared from the operand stack*. The leftover `/Tag` shifted the
|
|
/// operands of every following operator: `1 0 0 rg` became
|
|
/// `RgbFill(0.0, 1.0, 0.0)`.
|
|
///
|
|
/// Tagged documents are exactly the ones containing `BMC`, so the documents
|
|
/// that tried hardest to be accessible rendered with the wrong colours.
|
|
#[test]
|
|
fn bmc_no_longer_turns_red_into_green() {
|
|
let bytes = corpus("structure/marked_content_colour.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let ops = parse_content_stream(&page.content_data).expect("content parses");
|
|
let cmds = RecordingDevice::from_ops(&ops);
|
|
|
|
let fill = cmds
|
|
.iter()
|
|
.find_map(|c| match c {
|
|
RenderCommand::SetFillColor(c) => Some(*c),
|
|
_ => None,
|
|
})
|
|
.expect("the page fills a rectangle");
|
|
|
|
assert!(
|
|
(fill[0] - 1.0).abs() < 1e-9 && fill[1].abs() < 1e-9 && fill[2].abs() < 1e-9,
|
|
"the page fills red, but got {fill:?} - BMC shifted the operands"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tagged_document_yields_its_tree() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("structure tree");
|
|
|
|
assert!(!tree.untagged);
|
|
assert!(tree.marked, "/MarkInfo /Marked true");
|
|
assert_eq!(tree.lang.as_deref(), Some("en-GB"));
|
|
assert_eq!(tree.elements.len(), 4);
|
|
|
|
let order = tree.reading_order();
|
|
let roles: Vec<&StructRole> = order.iter().map(|i| &tree.elements[*i].role).collect();
|
|
assert_eq!(
|
|
roles,
|
|
vec![
|
|
&StructRole::Document,
|
|
&StructRole::Heading(Some(1)),
|
|
&StructRole::Paragraph,
|
|
&StructRole::Figure,
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reading_order_is_depth_first_not_geometric() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
|
|
let order = tree.reading_order();
|
|
// The Document root must precede all of its children.
|
|
assert_eq!(tree.elements[order[0]].role, StructRole::Document);
|
|
for i in &order[1..] {
|
|
assert_eq!(
|
|
tree.elements[*i].parent,
|
|
Some(order[0]),
|
|
"every other element is a child of the root here"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mcids_link_content_to_its_structure_element() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
|
|
// The heading owns MCID 0, the paragraph 1, the figure 2.
|
|
for (mcid, expected) in [
|
|
(0, StructRole::Heading(Some(1))),
|
|
(1, StructRole::Paragraph),
|
|
(2, StructRole::Figure),
|
|
] {
|
|
let el = tree
|
|
.element_for_mcid(None, mcid)
|
|
.unwrap_or_else(|| panic!("no element owns MCID {mcid}"));
|
|
assert_eq!(el.role, expected, "MCID {mcid}");
|
|
}
|
|
}
|
|
|
|
/// The `/MCID` must survive all the way to the device, or the tree cannot
|
|
/// be attached to what was actually drawn.
|
|
#[test]
|
|
fn mcids_reach_the_device() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
let ops = parse_content_stream(&page.content_data).expect("parses");
|
|
let cmds = RecordingDevice::from_ops(&ops);
|
|
|
|
let mcids: Vec<i64> = cmds
|
|
.iter()
|
|
.filter_map(|c| match c {
|
|
RenderCommand::BeginMarkedContent { mcid, .. } => *mcid,
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
assert_eq!(
|
|
mcids,
|
|
vec![0, 1, 2],
|
|
"every BDC's /MCID must reach the device"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn alt_text_is_exposed() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
|
|
let figure = tree
|
|
.elements
|
|
.iter()
|
|
.find(|e| e.role == StructRole::Figure)
|
|
.expect("a figure");
|
|
assert_eq!(
|
|
figure.alt.as_deref(),
|
|
Some("A red rectangle illustrating the layout")
|
|
);
|
|
assert_eq!(figure.description(), figure.alt.as_deref());
|
|
}
|
|
|
|
#[test]
|
|
fn a_role_map_translates_custom_tags() {
|
|
let bytes = corpus("structure/role_map.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
|
|
assert_eq!(tree.role_map.get("Chapter").map(String::as_str), Some("H1"));
|
|
|
|
let chapter = tree
|
|
.elements
|
|
.iter()
|
|
.find(|e| e.raw_type == "Chapter")
|
|
.expect("the custom-tagged element");
|
|
assert_eq!(
|
|
chapter.role,
|
|
StructRole::Heading(Some(1)),
|
|
"/RoleMap must map /Chapter onto /H1"
|
|
);
|
|
// The original tag is kept, so a caller can see it was remapped.
|
|
assert_eq!(chapter.raw_type, "Chapter");
|
|
|
|
let body = tree
|
|
.elements
|
|
.iter()
|
|
.find(|e| e.raw_type == "BodyCopy")
|
|
.expect("the second custom tag");
|
|
assert_eq!(body.role, StructRole::Paragraph);
|
|
}
|
|
|
|
#[test]
|
|
fn accessibility_faults_are_each_reported() {
|
|
let bytes = corpus("structure/inaccessible.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
let findings = tree.findings();
|
|
|
|
assert!(
|
|
findings.contains(&AccessibilityIssue::NoDocumentLanguage),
|
|
"{:?}",
|
|
findings.issues
|
|
);
|
|
assert!(
|
|
findings
|
|
.issues
|
|
.iter()
|
|
.any(|i| matches!(i, AccessibilityIssue::FigureWithoutAlt { .. })),
|
|
"{:?}",
|
|
findings.issues
|
|
);
|
|
assert!(
|
|
findings.issues.iter().any(|i| matches!(
|
|
i,
|
|
AccessibilityIssue::HeadingLevelSkipped { from: 1, to: 3, .. }
|
|
)),
|
|
"{:?}",
|
|
findings.issues
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_well_formed_document_reports_no_issues() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
let findings = tree.findings();
|
|
assert!(
|
|
findings.no_issues_found(),
|
|
"unexpected findings: {:?}",
|
|
findings.issues
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_cell_outside_a_row_is_reported_on_a_real_document() {
|
|
let bytes = corpus("structure/table.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
let findings = tree.findings();
|
|
assert!(
|
|
findings
|
|
.issues
|
|
.iter()
|
|
.any(|i| matches!(i, AccessibilityIssue::CellOutsideRow { .. })),
|
|
"the un-rowed cell was not reported: {:?}",
|
|
findings.issues
|
|
);
|
|
}
|
|
|
|
/// A `/K` graph pointing back at an ancestor must be cut, not expanded
|
|
/// until the depth bound, and certainly not recursed until the stack dies.
|
|
#[test]
|
|
fn a_cyclic_structure_tree_terminates_and_is_reported() {
|
|
let bytes = corpus("structure/cyclic.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("a cyclic tree must not error");
|
|
|
|
assert_eq!(
|
|
tree.elements.len(),
|
|
2,
|
|
"the cycle must be cut at first repeat, not expanded: {:?}",
|
|
tree.elements.len()
|
|
);
|
|
assert!(
|
|
!tree.cycles_cut.is_empty(),
|
|
"the cycle must be reported, not silently pruned"
|
|
);
|
|
assert!(tree
|
|
.findings()
|
|
.issues
|
|
.iter()
|
|
.any(|i| matches!(i, AccessibilityIssue::CyclicStructure { .. })));
|
|
}
|
|
|
|
/// An untagged document must report as untagged, which is a different state
|
|
/// from a tagged document whose tree happens to be empty.
|
|
#[test]
|
|
fn an_untagged_document_says_so() {
|
|
let bytes = corpus("structure/untagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
|
|
assert!(tree.untagged);
|
|
assert!(tree.elements.is_empty());
|
|
assert_eq!(
|
|
tree.findings().issues,
|
|
vec![AccessibilityIssue::NotTagged],
|
|
"an untagged document has one finding, not a pile of derived ones"
|
|
);
|
|
}
|
|
|
|
/// ADR 0011 rule 4: mechanical checks are not a conformance verdict.
|
|
///
|
|
/// This asserts the *absence* of a conformance API. If someone adds an
|
|
/// `is_pdf_ua` or similar, this test is where the argument happens.
|
|
#[test]
|
|
fn nothing_claims_pdf_ua_conformance() {
|
|
let bytes = corpus("structure/tagged.pdf");
|
|
let mut doc = PdfDocument::parse(&bytes).expect("parses");
|
|
let tree = doc.structure_tree().expect("tree");
|
|
let findings = tree.findings();
|
|
|
|
// The only verdict offered is "these particular checks found nothing".
|
|
assert!(findings.no_issues_found());
|
|
|
|
// Tripwire for a future rename. Comment lines are skipped so that the
|
|
// module documentation can go on explaining *why* there is no
|
|
// conformance verdict without tripping the check that enforces it.
|
|
let source = include_str!("../src/structure.rs");
|
|
let code_only: String = source
|
|
.lines()
|
|
.filter(|l| {
|
|
let t = l.trim_start();
|
|
!t.starts_with("//") && !t.starts_with("*")
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
for forbidden in ["is_pdf_ua", "is_conformant", "ComplianceReport"] {
|
|
assert!(
|
|
!code_only.contains(forbidden),
|
|
"structure.rs gained `{forbidden}` in code; mechanical checks \
|
|
cannot decide PDF/UA conformance (ADR 0011 rule 4)"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_structure_fixture_parses_without_panicking() {
|
|
for name in [
|
|
"tagged.pdf",
|
|
"role_map.pdf",
|
|
"inaccessible.pdf",
|
|
"cyclic.pdf",
|
|
"untagged.pdf",
|
|
"table.pdf",
|
|
"marked_content_colour.pdf",
|
|
] {
|
|
let bytes = corpus(&format!("structure/{name}"));
|
|
let mut doc = PdfDocument::parse(&bytes)
|
|
.unwrap_or_else(|e| panic!("structure/{name} should parse: {e}"));
|
|
let tree = doc.structure_tree();
|
|
if let Ok(t) = tree {
|
|
let _ = t.findings();
|
|
let _ = t.reading_order();
|
|
let _ = t.headings();
|
|
}
|
|
let _ = doc.page(0);
|
|
}
|
|
}
|