From d16dd5cd72d1feb63c4f74dce66372ca0d386960 Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Mon, 19 Oct 2026 18:28:57 +0200 Subject: [PATCH] fix(pdf): tag each picture in a rotated group on its own A rotated inline group is drawn inside one rotation. Its tag was chosen for the whole group: an artifact as soon as one member was decorative, so a picture next to it lost its Figure and alt text; otherwise one Figure with the first picture's alt text. The group is still turned as a whole, and each picture in it is now tagged on its own: decorative ones as artifacts, the others as Figures with their own alt text. Found by the CodeRabbit review of this PR. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/lib.rs | 49 +++++++++++++++++++++++++++++++++-------- crates/pdf/src/tests.rs | 32 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e65c2b44b..45ce29fd8 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -379,6 +379,15 @@ fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } } +/// A picture, or a rotation holding one at any depth (a rotated group's turned members). +fn holds_picture(d: &Draw) -> bool { + match d { + Draw::Image { .. } => true, + Draw::Rotated { items, .. } => items.iter().any(holds_picture), + _ => false, + } +} + /// Add the segments of a kurbo path to a krilla path. fn append_path(pb: &mut PathBuilder, path: &kurbo::BezPath) { let f = |v: f64| v as f32; @@ -792,6 +801,33 @@ impl Exporter<'_> { } } + /// A rotated inline picture or group: turned as a whole, with each picture in it tagged on its + /// own, so a decorative picture is an artifact while the others stay figures with their alt + /// text. A described shape's figure keeps its description; anything else is an artifact. + fn rotated_pictures(&mut self, s: &mut Surface, d: &Draw) { + match d { + Draw::Rotated { cx, cy, spin, items } => { + if !ok(*cx) || !ok(*cy) { + return; + } + let [a, b, c, d, e, f] = spin.matrix(*cx, *cy); + s.push_transform(&Transform::from_row(a, b, c, d, e, f)); + for it in items { + self.rotated_pictures(s, it); + } + s.pop(); + } + Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), + Draw::Figure { alt, draws, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| { + for d in draws { + me.draw(s, d); + } + }), + _ => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), + } + } + fn page(&mut self, s: &mut Surface, page: &Page, w: f32, h: f32) { if let Some(c) = self.doc.settings.page_color && let Some(p) = rect_path(&Rect::new(0.0, 0.0, w, h)) @@ -835,17 +871,12 @@ impl Exporter<'_> { for d in &draws { match d { Draw::Glyphs { .. } | Draw::Turned { .. } => self.tagged(s, Role::Para(idx), None, |me, s| me.draw(s, d)), - // A picture marked decorative, straight or rotated: an artifact screen readers skip. + // A picture marked decorative: an artifact screen readers skip. Draw::Image { decorative: true, .. } => self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)), - Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { decorative: true, .. })) => { - self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| me.draw(s, d)) - } Draw::Image { alt, .. } => self.tagged(s, Role::Figure, Some(alt), |me, s| me.draw(s, d)), - // A rotated inline picture. - Draw::Rotated { items, .. } if items.iter().any(|i| matches!(i, Draw::Image { .. })) => { - let alt = items.iter().find_map(|i| if let Draw::Image { alt, .. } = i { Some(alt.as_str()) } else { None }); - self.tagged(s, Role::Figure, alt, |me, s| me.draw(s, d)) - } + // A rotated inline picture, or a rotated group holding pictures: turned as a + // whole, each picture tagged on its own (see `rotated_pictures`). + Draw::Rotated { items, .. } if items.iter().any(holds_picture) => self.rotated_pictures(s, d), // An inline chart or diagram: its text is part of the figure, not the paragraph. Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(graphic_alt(alt, *kind)), |me, s| { for d in inner { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 6357bd367..196e87ec2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -928,6 +928,38 @@ fn decorative_pictures_are_artifacts_not_figures() { } } +/// In a rotated group, each picture is tagged on its own: the decorative one is an artifact, the +/// other stays a Figure with its alt text. +#[test] +fn rotated_group_tags_each_picture_on_its_own() { + use wordcraft_doc::para::{AltText, Float, GroupChild}; + let mut d = Document::new(); + let media = d.add_media(red_right_half_emf(), "emf"); + let picture = + |alt: &str, float: Float| InlineObject::Image { media: media.clone(), w: 70.0, h: 70.0, alt: alt.into(), float, crop: [0.0; 4], ole: None }; + let decorative = Float { alt: AltText { decorative: true, ..Default::default() }, ..Default::default() }; + let group = InlineObject::Group { + w: 160.0, + h: 70.0, + float: Float { rot: 30.0, ..Default::default() }, + ch_w: 160.0, + ch_h: 70.0, + children: vec![ + GroupChild { x: 0.0, y: 0.0, obj: picture("A decorative border", decorative) }, + GroupChild { x: 90.0, y: 0.0, obj: picture("A red square", Float::default()) }, + ], + canvas: None, + }; + let mut p = Paragraph::with_text("Group: ", CharProps::default()); + let n = p.len(); + p.insert_object(n, group, &CharProps::default()).unwrap(); + d.body = vec![para_block(p)]; + let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned(); + assert!(raw.contains("/Alt(A red square)"), "the picture not marked decorative is a Figure with its alt text"); + assert!(!raw.contains("A decorative border"), "the decorative picture has no Figure and no alt text"); + assert_eq!(raw.matches("/Alt(").count(), 1, "one Figure, for the picture not marked decorative"); +} + /// An EMF file of the given records, with a 25 mm frame and 1000 device pixels across (0.04 per 0.01 mm). fn emf_file(records: &[Vec]) -> Vec { let le32 = |vals: &[i32]| -> Vec { vals.iter().flat_map(|v| v.to_le_bytes()).collect() };