From 0e7bd1003338d8cf40312e02ce88c590846dcf9b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:52:29 -0800 Subject: [PATCH] SVG export: outlined type keeps its text as an accessible name Type that SVG text can't draw is written as outlines: vertical type, ruby, right-to-left text, type on a path with an effect other than Rainbow, an end bracket, Align to Path, Spacing, hanging punctuation, bullets or inline graphics. Only glyphs bent along a path gave the outlined group a `role="img"` and `aria-label`; every other case left screen readers and search with no text. Each of them now names the group with its text. Convert to Outlines still writes the paths alone, as before. Inline graphics' placeholder characters stay out of the name, and empty type gets none. An object whose id is its name also gets `data-name`, so opening the file again keeps the name instead of taking the label. --- crates/svg/src/export/text.rs | 19 +++++++--- crates/svg/tests/it/roundtrip.rs | 60 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index c9d9027b8..19bbbd5be 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -1,4 +1,4 @@ -use super::output::{xml_attr_valid, xml_text}; +use super::output::{xml_attr_escape, xml_attr_valid, xml_text}; use super::{ Affine, AppearanceItem, Arc, BTreeSet, BezPath, BlendMode, CharStyle, FillRule, FontDb, FontFace, GradientKind, HashMap, Justify, Node, NodeId, NodeKind, Paint, PathData, Props, Rect, StrokeAlign, Styling, TextKind, TextObject, Writer, base64_encode, css, css_string, fmt_num, @@ -166,10 +166,19 @@ impl Writer<'_> { } let id = self.id_attr(n); let a = self.attrs(&css::transparency(n)); - // Glyphs bent along a path (joined scripts on a curve) can only be outlines: the group - // names its text for screen readers and search. - let label = if lay.glyphs.iter().any(|g| g.bend.is_some()) { - format!(" role=\"img\" aria-label=\"{}\"", xml_attr_valid(&t.plain_text())) + // Text outlined because SVG text can't draw it (bent glyphs, vertical type, ruby, effects on + // a path and the like) names its text for screen readers and search; Convert to Outlines + // leaves only the paths, except for bent glyphs. Inline graphics are written apart from the + // glyphs, so their placeholder characters stay out of the name; empty text has none. + let text: String = t.plain_text().chars().filter(|&c| c != vectorcraft_doc::text::INLINE_CHAR).collect(); + let label = if !text.trim().is_empty() && (!self.opts.outline_text || lay.glyphs.iter().any(|g| g.bend.is_some())) { + // SVG import names an object by its `aria-label` before its id, so an id that stands for + // the name is written as `data-name` too, and the object keeps its name. + let name = match self.names.get(&n.id) { + Some(named) if !id.is_empty() && !id.contains(" data-name=\"") => format!(" data-name=\"{}\"", xml_attr_escape(named)), + _ => String::new(), + }; + format!("{name} role=\"img\" aria-label=\"{}\"", xml_attr_valid(&text)) } else { String::new() }; diff --git a/crates/svg/tests/it/roundtrip.rs b/crates/svg/tests/it/roundtrip.rs index 8f479f348..b13ecf160 100644 --- a/crates/svg/tests/it/roundtrip.rs +++ b/crates/svg/tests/it/roundtrip.rs @@ -527,6 +527,66 @@ fn text_on_path_effects_svg_cannot_express_are_outlined() { } } +/// Type that SVG text can't draw is outlined with its text as the group's accessible name: type on +/// a path with an effect other than Rainbow, vertical type, right-to-left text, and type on a path +/// with inline graphics. With Convert to Outlines the paths carry no name, and empty type gets none +/// either. +#[test] +fn text_outlined_for_svg_keeps_an_accessible_name() { + use vectorcraft_doc::PathEffect; + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((20.0, 150.0)); + curve.curve_to((60.0, 40.0), (140.0, 40.0), (180.0, 150.0)); + let style = || CharStyle { size: 14.0, ..CharStyle::default() }; + let svg_of = |t: TextObject, outline_text: bool| { + let mut d = Document::new(200.0, 200.0); + let id = d.alloc_id(); + let opts = ExportOptions { outline_text, ..ExportOptions::default() }; + export(&doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]), &opts) + }; + let mut skewed = TextObject::point(Point::ZERO, "On a curve", style()); + skewed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None }; + skewed.path_effect = PathEffect::Skew; + let mut vertical = TextObject::point(Point::new(50.0, 20.0), "On a curve", style()); + vertical.vertical = true; + let mut rtl = TextObject::point(Point::new(20.0, 50.0), "On a curve", style()); + rtl.runs[0].style.char_direction = Some(vectorcraft_doc::ParaDirection::RightToLeft); + for (name, t) in [("skew", skewed), ("vertical", vertical), ("rtl", rtl)] { + let svg = svg_of(t.clone(), false); + assert!(!svg.contains("`.