diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 71bc90a03..40a962b46 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -140,6 +140,22 @@ fn mcids(d: &Dict) -> Vec { out } +/// The page's named property lists (`/Resources /Properties`), which a `BDC` can name instead of +/// writing its property list inline. +fn page_properties(doc: &Document, page: &Dict) -> Dict { + let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + res.get(b"Properties").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()).unwrap_or_default() +} + +/// The marked-content id a `BDC` opens, whether its property list is inline or named in `props`. +fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option { + match op.operands.get(1)? { + Object::Dict(d) => d.get(b"MCID").and_then(Object::as_int), + Object::Name(n) => props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int))), + _ => None, + } +} + pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec { let Some(c) = page.get(b"Contents") else { return Vec::new() }; match &*doc.resolve(c) { @@ -170,6 +186,7 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] { fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> { let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default(); + let props = page_properties(doc, page); let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])]; let mut marks: Vec = Vec::new(); let mut bbox = None; @@ -190,9 +207,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; } } b"BMC" => marks.push(false), - b"BDC" => marks.push( - matches!(op.operands.get(1), Some(Object::Dict(d)) if d.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))), - ), + b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))), b"EMC" => { marks.pop(); } @@ -295,12 +310,11 @@ pub fn mark_decorative(doc: &mut Document, obj: ObjRef) -> Result<(), AltError> let pages = pdfcraft_model::pages(doc); let pg = d.get(b"Pg").and_then(Object::as_ref).or_else(|| elements(doc).into_iter().find(|e| e.object == obj).and_then(|e| e.page)); if let Some(page) = pg.and_then(|p| pages.iter().find(|x| x.obj == p)).filter(|_| !ids.is_empty()) { + let props = page_properties(doc, &page.dict); let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops; let mut changed = false; for op in ops.iter_mut() { - let ours = op.op == b"BDC" - && matches!(op.operands.get(1), Some(Object::Dict(m)) if m.get(b"MCID").and_then(Object::as_int).is_some_and(|n| ids.contains(&n))); - if ours { + if op.op == b"BDC" && bdc_mcid(doc, &props, op).is_some_and(|n| ids.contains(&n)) { *op = pdfcraft_content::Op::new("BMC", vec![Object::name("Artifact")]); changed = true; } diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index b0ace2c03..6f85b8cbf 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -624,6 +624,62 @@ fn marking_decorative_survives_a_parent_tree_with_an_unpaired_key() { } } +/// `figures_doc` with its property lists named in the page resources (one inline, one indirect) +/// instead of written inline in the content. +fn named_figures_doc() -> Document { + pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 \ + /Resources << /XObject << /Im 10 0 R >> /Properties << /P0 << /MCID 0 >> /P1 11 0 R /Other << /Note (kept) >> >> >> >>" + .into(), + stream( + "/Figure /P0 BDC q 40 0 0 20 10 150 cm /Im Do Q EMC \ + /Photo /P1 BDC 100 100 50 30 re f EMC", + ), + "<< /Type /StructTreeRoot /K 6 0 R /ParentTree 9 0 R /RoleMap << /Photo /Figure >> >>".into(), + "<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(), + "<< /S /Figure /P 6 0 R /Pg 3 0 R /K 0 >>".into(), + "<< /S /Photo /P 6 0 R /Pg 3 0 R /Alt (A bar) /K 1 >>".into(), + "<< /Nums [0 [7 0 R 8 0 R]] >>".into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream" + .into(), + "<< /MCID 1 >>".into(), + ], + "", + ) +} + +#[test] +fn named_property_lists_are_found_and_marked_decorative_like_inline_ones() { + let (mut inline, mut named) = (figures_doc(), named_figures_doc()); + let place = |doc: &Document| figures(doc).into_iter().map(|f| (f.obj, f.page, f.alt, f.bbox)).collect::>(); + assert_eq!(place(&named), place(&inline), "both figures, placed where their content is drawn"); + for doc in [&mut inline, &mut named] { + mark_decorative(doc, pdfcraft_cos::ObjRef::new(8, 0)).unwrap(); + let left = figures(doc); + assert_eq!((left.len(), left[0].obj.num, left[0].bbox), (1, 7, Some([10.0, 150.0, 50.0, 170.0]))); + let pages = pdfcraft_model::pages(doc); + let c = doc.resolve(pages[0].dict.get(b"Contents").unwrap()); + let Object::Stream(c) = &*c else { panic!("one content stream") }; + let text = String::from_utf8(c.decoded().unwrap()).unwrap(); + assert!(text.contains("/Artifact BMC") && !text.contains("/P1") && !text.contains("/MCID 1"), "{text}"); + assert!(text.contains("/Im Do"), "the drawing itself stays: {text}"); + let nums = doc.get(pdfcraft_cos::ObjRef::new(9, 0)).as_dict().and_then(|d| d.get(b"Nums").cloned()); + assert_eq!( + nums, + Some(Object::Array(vec![Object::Int(0), Object::Array(vec![Object::Ref(pdfcraft_cos::ObjRef::new(7, 0)), Object::Null])])), + "the parent tree no longer points MCID 1 at the removed figure" + ); + let r = check(doc, &Options { rules: [Rule::FiguresAltText, Rule::TaggedContent].into(), pages: None }); + assert_eq!(r.result(Rule::TaggedContent).unwrap().status, Status::Passed, "{:?}", r.results); + } + let props = pdfcraft_model::pages(&named)[0].dict.get(b"Resources").and_then(|r| named.resolve(r).as_dict().cloned()); + let props = props.and_then(|r| r.get(b"Properties").and_then(|p| named.resolve(p).as_dict().cloned())).unwrap(); + assert!(props.contains(b"P0") && props.contains(b"P1") && props.contains(b"Other"), "resources are left as they were"); +} + fn ref_(n: u32) -> Option { Some(pdfcraft_cos::ObjRef::new(n, 0)) }