mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 21:37:41 -06:00
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:
committed by
myaiplugins
co-authored by
Claude Opus 5.5
parent
8664afe58f
commit
d16dd5cd72
+40
-9
@@ -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 {
|
||||
|
||||
@@ -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() };
|
||||
|
||||
Reference in New Issue
Block a user