mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-26 11:27:10 -06:00
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:
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user