Merge pull request #6255 from dotCooCoo/fix/svg-outlined-text-label

SVG export: outlined type keeps its text as an accessible name
This commit is contained in:
BFlat
2026-11-09 05:42:04 +08:00
committed by GitHub
2 changed files with 74 additions and 5 deletions
+14 -5
View File
@@ -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()
};
+60
View File
@@ -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("<text"), "{name}: {svg}");
assert!(svg.contains("role=\"img\" aria-label=\"On a curve\""), "{name}: {svg}");
assert!(!svg_of(t, true).contains("aria-label"), "{name} with Convert to Outlines");
}
let mut empty = TextObject::point(Point::new(50.0, 20.0), "", style());
empty.vertical = true;
assert!(!svg_of(empty, false).contains("aria-label"));
// An inline graphic's placeholder character stays out of the name; type on a path holding
// only inline graphics gets none.
let inline = || vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Mark"), style());
let mut mixed = TextObject::point(Point::ZERO, "On a curve", style());
mixed.kind = TextKind::OnPath { path: PathData::from_bezpath(&curve), start: 0.1, end: None };
mixed.runs.insert(0, inline());
let mut only = mixed.clone();
only.runs = vec![inline()];
let svg = svg_of(mixed, false);
assert!(svg.contains("aria-label=\"On a curve\""), "{svg}");
assert!(!svg.contains('\u{FFFC}'), "{svg}");
let svg = svg_of(only, false);
assert!(!svg.contains("aria-label"), "{svg}");
// A named object keeps its name when the file is opened again, whether the name is its id or not.
for name in ["Heading", "Main heading"] {
let mut t = TextObject::point(Point::new(50.0, 20.0), "Hello", style());
t.vertical = true;
let mut d = Document::new(200.0, 200.0);
let mut node = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
node.name = Some(name.to_string());
let svg = export(&doc_with(vec![node]), &ExportOptions::default());
assert!(svg.contains("aria-label=\"Hello\""), "{svg}");
let r = import(&svg).unwrap();
assert!(art(&r).iter().any(|n| n.name.as_deref() == Some(name)), "{name}: {svg}");
}
}
/// Type on a path whose punctuation hangs outside its brackets (Optical Margin Alignment or Roman
/// Hanging Punctuation) keeps its look as outlines; with the option on and nothing at its ends to
/// hang, it stays a `<textPath>`.