From 73d159b6c966715825c1499dae2d4da29e6f5f93 Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Thu, 15 Oct 2026 15:56:43 +0200 Subject: [PATCH 1/2] fix(pdf): shapes with a description are figures in tagged PDF A shape with a description (Alt Text) was exported as an artifact like any other shape, so screen readers skipped it and the description was lost: an arrow or an icon drawn with shapes that means something was invisible to them. A shape that has a description, is not marked decorative and shows no text is now a Figure with its description, inline (rotated too) and floating. Shapes without a description and decorative shapes stay artifacts. A text box keeps its frame as an artifact and its text read as paragraphs: its description would replace the text, so it isn't used. The rule lives in `shape_figure_alt`. Draw::Figure's `kind` becomes an Option (None for a shape), and Placed::Shape carries the description for floating shapes. Not covered: a group's own description (its members are placed one by one), and shapes inside a Drawing Canvas. Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/display.rs | 17 ++++++++--- crates/layout/src/lib.rs | 15 ++++++++++ crates/layout/src/outline.rs | 1 + crates/pdf/src/lib.rs | 18 ++++++++++-- crates/pdf/src/tests.rs | 55 ++++++++++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 6 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 10fc9faa2..f62f0e3e0 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -89,8 +89,9 @@ pub enum Draw { Ink { pts: Vec<(f32, f32)>, color: Rgb, width: f32, alpha: f32 }, /// A vector path (a chart's or diagram's polygons, slices, lines): filled, then stroked. Path { segs: Vec, fill: Option, stroke: Option, stroke_width: f32, dash: Vec }, - /// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF). - Figure { alt: String, kind: GraphicKind, draws: Vec }, + /// An inline chart, diagram or described shape's draws, as one figure with its alt text (for + /// tagged PDF). `kind` is the graphic's, `None` for a shape (see [`crate::shape_figure_alt`]). + Figure { alt: String, kind: Option, draws: Vec }, /// A formatting mark (¶ · → ↵) in the UI's mark colour, or in `color` (a tracked /// paragraph mark in its reviser's colour). Mark { x: f32, baseline: f32, size: f32, ch: char, color: Option }, @@ -1135,7 +1136,7 @@ fn lines( Some(InlineObject::Graphic { graphic, alt, .. }) => { let mut draws = Vec::new(); spun(spin, rect, graphic_draws(doc, graphic, rect, alpha), &mut draws); - out.push(Draw::Figure { alt: alt.clone(), kind: graphic.kind, draws }) + out.push(Draw::Figure { alt: alt.clone(), kind: Some(graphic.kind), draws }) } Some(InlineObject::Equation { .. }) => { if let Some((_, ml)) = pl.maths.iter().find(|(k, _)| *k == oi) { @@ -1720,7 +1721,15 @@ fn object_draws(o: &InlineObject, rect: Rect, outer: Spin, alpha: f32, opts: &Di let effects = effects_in(*effects, own.within(outer)); let mut items = Vec::new(); shape_draws(rect, *kind, *fill, *stroke, *stroke_width, effects, freeform.as_deref(), extra, opts, &mut items); - spun(own, rect, items, out) + match crate::shape_figure_alt(o) { + // A described shape is one figure with its description (tagged PDF). + Some(alt) => { + let mut draws = Vec::new(); + spun(own, rect, items, &mut draws); + out.push(Draw::Figure { alt: alt.to_string(), kind: None, draws }) + } + None => spun(own, rect, items, out), + } } InlineObject::Group { .. } => { let mut members = Vec::new(); diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1bc00495a..22bdcbeff 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -279,6 +279,9 @@ pub enum Placed { /// The floating object it draws (a Drawing Canvas's members: the canvas's), for leaving /// exactly one object out of a page (see [`crate::display::DisplayOptions::hide_object`]). object: Option, + /// The description it is tagged with as a figure in tagged PDF (see + /// [`shape_figure_alt`]); empty for a shape that is an artifact. + alt: String, }, /// A floating chart or diagram, drawn from its items inside `rect`. The object is the U+FFFC at /// byte `off` of paragraph `path` (its alt text). @@ -3019,6 +3022,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR spin, extra: extra.clone(), object: Some(object), + alt: shape_figure_alt(o).unwrap_or_default().to_string(), }] } InlineObject::Graphic { graphic, .. } => { @@ -3047,6 +3051,7 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR spin, extra: Default::default(), object: Some(wordcraft_doc::Pos { story, path: Path(path.to_vec()), off }), + alt: String::new(), }, Placed::Clip { rect, spin, items: members }, ] @@ -3058,6 +3063,16 @@ fn float_items(o: &InlineObject, rect: Rect, outer: (Spin, Point), story: StoryR } } +/// The description a shape is tagged with as a figure in tagged PDF: its own, when it has one, +/// isn't marked decorative and shows no text. A shape without one is an artifact screen readers +/// skip, and a text box's text is read as text, so its description isn't used. +pub fn shape_figure_alt(o: &InlineObject) -> Option<&str> { + match o { + InlineObject::Shape { story: None, float, .. } if !float.alt.decorative && !float.alt.text.trim().is_empty() => Some(&float.alt.text), + _ => None, + } +} + /// A group's members, each with its rectangle when the group is at `rect`. /// `rect` moved (not turned) to where `spin` about `centre` takes its centre: a group member's /// frame inside its turned group. diff --git a/crates/layout/src/outline.rs b/crates/layout/src/outline.rs index bbbd463cc..9e48566bb 100644 --- a/crates/layout/src/outline.rs +++ b/crates/layout/src/outline.rs @@ -177,6 +177,7 @@ pub(crate) fn symbol_items(pl: &Arc, x: f32, y: f32, level: u8, chil extra: Default::default(), // A heading's symbol, not an object of its own: never left out of a page. object: None, + alt: String::new(), }; let white = Border { style: BorderStyle::Single, width: 1.1, color: Some(Rgb(0xFF, 0xFF, 0xFF)), space: 0.0 }; if level >= BODY { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7e853242e..87c92134d 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -379,6 +379,14 @@ fn graphic_alt(alt: &str, kind: GraphicKind) -> &str { if alt.trim().is_empty() { kind.noun() } else { alt } } +/// The alt text of an inline figure: a graphic's (see [`graphic_alt`]) or a described shape's. +fn figure_alt(alt: &str, kind: Option) -> &str { + match kind { + Some(k) => graphic_alt(alt, k), + None => alt, + } +} + /// 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; @@ -841,8 +849,8 @@ impl Exporter<'_> { 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)) } - // 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| { + // An inline chart, diagram or described shape: its text is part of the figure, not the paragraph. + Draw::Figure { alt, kind, draws: inner } => self.tagged(s, Role::Figure, Some(figure_alt(alt, *kind)), |me, s| { for d in inner { me.draw(s, d); } @@ -862,6 +870,12 @@ impl Exporter<'_> { } }); } + // A floating shape with a description: a figure (see `shape_figure_alt`). + Placed::Shape { alt, .. } if !alt.is_empty() => self.tagged(s, Role::Figure, Some(alt), |me, s| { + for d in &draws { + me.draw(s, d); + } + }), Placed::Graphic { story, path, off, graphic, .. } => { let alt = match self.doc.para(*story, path).and_then(|p| p.object_at(*off)) { Some(InlineObject::Graphic { alt, .. }) => graphic_alt(alt, graphic.kind).to_string(), diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index d112eeb36..da61e7699 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1860,3 +1860,58 @@ fn pages_print_scaled_to_other_paper() { assert_eq!(size(&bytes), size(&plain)); } } + +/// A tagged PDF of a paragraph with one blue rectangle placed by `float`: a text box showing +/// "Inside the box" when `text_box`. +fn tagged_shape_pdf(float: wordcraft_doc::para::Float, text_box: bool) -> String { + use wordcraft_doc::para::ShapeKind; + let mut d = Document::from_text("Body"); + let story = text_box + .then(|| d.add_part(wordcraft_doc::PartKind::TextBox, vec![para_block(Paragraph::with_text("Inside the box", CharProps::default()))])); + let kind = if text_box { ShapeKind::TextBox } else { ShapeKind::Rectangle }; + let shape = InlineObject::Shape { + kind, + w: 120.0, + h: 60.0, + fill: Some(wordcraft_doc::props::Rgb(0, 0, 0xFF)), + stroke: None, + stroke_width: 0.0, + float, + story, + freeform: None, + effects: Default::default(), + extra: Default::default(), + }; + d.insert_object(&wordcraft_doc::Pos::body(0, 4), shape, &Default::default()).unwrap(); + String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, tagged: true, ..Default::default() }).unwrap()).into_owned() +} + +/// A shape with a description is a Figure with it in tagged PDF, inline, rotated or floating; one +/// without, or marked decorative, stays an artifact screen readers skip, and so does a text box's +/// frame: its text is read as text, not replaced by its description. +#[test] +fn described_shapes_are_figures_with_their_description() { + use wordcraft_doc::para::{AltText, Anchor, Float, Wrap}; + let described = AltText { text: "Arrow pointing to the exit".into(), ..Default::default() }; + let floating = Float { wrap: Wrap::InFrontOfText, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 200.0, ..Default::default() }; + let cases = [ + ("inline", Float::default()), + ("rotated inline", Float { rot: 30.0, ..Default::default() }), + ("floating", floating.clone()), + ("rotated floating", Float { rot: 30.0, ..floating }), + ]; + for (name, float) in cases { + let raw = tagged_shape_pdf(Float { alt: described.clone(), ..float.clone() }, false); + assert!(raw.contains("/Alt(Arrow pointing to the exit)"), "{name}: a described shape is a Figure with its description"); + assert!(raw.contains("0 0 1 rg"), "{name}: the shape is drawn"); + assert!(!tagged_shape_pdf(float.clone(), false).contains("/Alt("), "{name}: an undescribed shape stays an artifact"); + let decorative = AltText { decorative: true, ..described.clone() }; + assert!( + !tagged_shape_pdf(Float { alt: decorative, ..float.clone() }, false).contains("/Alt("), + "{name}: a decorative shape stays an artifact" + ); + let text_box = tagged_shape_pdf(Float { alt: described.clone(), ..float }, true); + assert!(!text_box.contains("/Alt("), "{name}: a text box is not a Figure"); + assert_eq!(text_box.matches("/S/P").count(), 2, "{name}: the text box's text is read as a paragraph"); + } +} From 322b3d765c8ba86eb387a6f3eb277d95117e6d2a Mon Sep 17 00:00:00 2001 From: MyAIPlugins Date: Mon, 19 Oct 2026 18:32:07 +0200 Subject: [PATCH 2/2] test(pdf): a described shape is exactly one Figure The test checked that the description is there, so a Figure written twice would still pass. It now also counts the alt text entries. Suggested by the CodeRabbit review of this PR. Co-Authored-By: Claude Opus 5.5 --- crates/pdf/src/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index da61e7699..5a34df0e7 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1903,6 +1903,7 @@ fn described_shapes_are_figures_with_their_description() { for (name, float) in cases { let raw = tagged_shape_pdf(Float { alt: described.clone(), ..float.clone() }, false); assert!(raw.contains("/Alt(Arrow pointing to the exit)"), "{name}: a described shape is a Figure with its description"); + assert_eq!(raw.matches("/Alt(").count(), 1, "{name}: the described shape is one Figure"); assert!(raw.contains("0 0 1 rg"), "{name}: the shape is drawn"); assert!(!tagged_shape_pdf(float.clone(), false).contains("/Alt("), "{name}: an undescribed shape stays an artifact"); let decorative = AltText { decorative: true, ..described.clone() };