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 <noreply@anthropic.com>
This commit is contained in:
MyAIPlugins
2026-10-19 18:36:37 +02:00
committed by myaiplugins
co-authored by Claude Opus 5.5
parent 8664afe58f
commit d16dd5cd72
2 changed files with 72 additions and 9 deletions
+40 -9
View File
@@ -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 {
+32
View File
@@ -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<u8>]) -> Vec<u8> {
let le32 = |vals: &[i32]| -> Vec<u8> { vals.iter().flat_map(|v| v.to_le_bytes()).collect() };