Merge PR #2099: fix(pdf): shapes with a description are figures in tagged PDF

crates/layout/src/display.rs: main's Paint path fill and fill.as_ref() with the PR's Option<GraphicKind> figure and described-shape figure.
crates/pdf/src/lib.rs: main's holds_picture and rotated_pictures arm kept beside the PR's figure_alt.
crates/pdf/src/tests.rs: union; the PR's shape test builds its fill with Fill::solid (main's shape fill type).
This commit is contained in:
aiongg
2026-10-22 01:22:37 +00:00
5 changed files with 101 additions and 6 deletions
+13 -4
View File
@@ -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<PathSeg>, fill: Option<Paint>, stroke: Option<Rgb>, stroke_width: f32, dash: Vec<f32> },
/// An inline chart or diagram's draws, as one figure with its alt text (for tagged PDF).
Figure { alt: String, kind: GraphicKind, draws: Vec<Draw> },
/// 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<GraphicKind>, draws: Vec<Draw> },
/// 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<Rgb> },
@@ -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();
+15
View File
@@ -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<wordcraft_doc::Pos>,
/// 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.
+1
View File
@@ -177,6 +177,7 @@ pub(crate) fn symbol_items(pl: &Arc<ParaLayout>, 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 {
+16 -2
View File
@@ -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<GraphicKind>) -> &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(),
+56
View File
@@ -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");
}
}