diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 9519628a5..81645e3c2 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -184,8 +184,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 }, @@ -1234,7 +1235,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) { @@ -1822,7 +1823,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.as_ref(), *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 21bc1b9bd..17c717005 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). @@ -3148,6 +3151,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, .. } => { @@ -3176,6 +3180,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 }, ] @@ -3187,6 +3192,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 49437f82d..2b60ef8a7 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 501a75c0e..52aa8fe01 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -388,6 +388,14 @@ fn holds_picture(d: &Draw) -> bool { } } +/// 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; @@ -922,8 +930,8 @@ impl Exporter<'_> { // 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| { + // 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); } @@ -945,6 +953,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 bfa5f8488..4d3528ae6 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2047,3 +2047,59 @@ fn shape_gradients_export_as_shadings() { assert!(raw.contains("/ShadingType 3"), "radial shading"); assert!(raw.contains("/ca 0.3"), "30% opaque fill"); } + +/// 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::fill::Fill::solid(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_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() }; + 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"); + } +}