SVG import: textLength and lengthAdjust fit text to its length (#5842)

SVG text with a textLength came in at its natural width. Following
SVG 1.1 §10.9.1, the characters of each text or tspan with a textLength
are now fitted to it: by spacing them out (lengthAdjust="spacing", the
default), which becomes kerning, or by stretching them
(spacingAndGlyphs), which becomes horizontal scale (kept within
1–10000%). Inner elements are fitted first; vertical text is spaced out
only, with a warning for spacingAndGlyphs. Negative lengths are ignored.
This commit is contained in:
Don Omondi
2026-11-06 05:38:51 +07:00
committed by bflatastic
parent 483de72dbb
commit 8236a8e006
3 changed files with 106 additions and 0 deletions
+48
View File
@@ -503,6 +503,53 @@ impl Ctx {
out.spans[slot].1 = start..out.chars.len();
}
/// `textLength` and `lengthAdjust` (SVG 1.1 §10.9.1,
/// https://www.w3.org/TR/SVG11/text.html#TextElementTextLengthAttribute): the characters of
/// each element with a `textLength` are fitted to that advance, by adding the difference evenly
/// between them (`spacing`, the default; it becomes kerning) or by stretching them
/// (`spacingAndGlyphs`; it becomes horizontal scale, kept within 1–10000%). Innermost elements
/// first, so an element's length takes in its children's. Vertical type is spaced out only.
fn fit_lengths(&self, spans: &[(XNode, Range<usize>)], cells: &mut [Cell], vertical: bool, label: &str, warnings: &mut Vec<String>) {
let n = cells.len();
for (el, r) in spans.iter().rev() {
let Some(want) = el.attribute("textLength").and_then(|v| self.length(v, self.font_size(*el), self.viewport.0)).filter(|w| *w >= 0.0)
else {
continue;
};
// Past the largest coordinate, the characters would only be out of reach.
let want = want.min(vectorcraft_doc::MAX_COORD);
let Some(part) = cells.get_mut(r.start.min(n)..r.end.min(n)) else { continue };
let Some(gaps) = part.len().checked_sub(1) else { continue };
let stretch = el.attribute("lengthAdjust").map(str::trim) == Some("spacingAndGlyphs");
if stretch && vertical {
warnings.push(format!("{label}: lengthAdjust=\"spacingAndGlyphs\" on vertical text spaces the characters out instead"));
}
// Kerning and scale change shaping (manual kerning turns pair kerning off): measure again.
for _ in 0..4 {
let have = advance(&point_text(part));
if !have.is_finite() || (want - have).abs() < 0.01 {
break;
}
if stretch && !vertical {
let k = want / have;
if !k.is_finite() || k <= 0.0 {
break;
}
for c in part.iter_mut() {
c.style.h_scale = (c.style.h_scale * k).clamp(1.0, 10_000.0);
}
} else if gaps > 0 {
let d = (want - have) / gaps as f64;
for c in part.iter_mut().take(gaps) {
c.kern += d;
}
} else {
break;
}
}
}
}
/// The path a `<textPath>` follows, in the text's user space.
fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option<BezPath> {
let mut bp = match tp.attribute("path") {
@@ -730,6 +777,7 @@ impl Ctx {
}
});
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings);
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
let mut breaks: Vec<Break> = vec![];
let mut para_styles: Vec<ParaStyle> = vec![];
+37
View File
@@ -693,3 +693,40 @@ fn flowed_text_that_cannot_be_read_is_reported() {
assert!(w.iter().any(|w| w.contains(id) && w.contains("flowRoot")), "{id}: {w:?}");
}
}
/// Total advance of a single-line text.
fn advance(t: &TextObject) -> f64 {
layout(FontDb::global(), t).glyphs.iter().map(|g| g.advance).sum()
}
/// SVG 1.1 §10.9.1: `textLength` fits the characters to that advance, by spacing them out
/// (`lengthAdjust="spacing"`, the default) or by stretching them (`spacingAndGlyphs`).
/// https://www.w3.org/TR/SVG11/text.html#TextElementTextLengthAttribute
#[test]
fn text_length_fits_the_advance() {
let natural = advance(only_text(&open(r#"<text x="20" y="100" font-size="20">Hello World</text>"#)));
for adjust in ["spacingAndGlyphs", "spacing"] {
for want in [50.0, 150.0, 300.0] {
let (d, w) = import_with_report(&svg(&format!(
r#"<text x="20" y="100" font-size="20" textLength="{want}" lengthAdjust="{adjust}">Hello World</text>"#
)))
.unwrap();
assert!(w.is_empty(), "{w:?}");
let t = only_text(&d);
assert_eq!(t.plain_text(), "Hello World");
let got = advance(t);
assert!(close(got, want, 0.05), "{adjust} {want}: {got} (natural {natural})");
let scaled = t.runs.iter().all(|r| close(r.style.h_scale, 100.0 * want / natural, 0.5));
assert_eq!(scaled, adjust == "spacingAndGlyphs", "{adjust} {want}: {:?}", t.runs);
}
}
// On a tspan, only its characters are fitted; units and percentages of the viewport resolve.
let t = open(r#"<text x="20" y="100" font-size="20">Hi <tspan textLength="25%">there</tspan>!</text>"#);
let t = only_text(&t);
let lay = layout(FontDb::global(), t);
let span: f64 = lay.glyphs.iter().filter(|g| (3..8).contains(&g.byte)).map(|g| g.advance).sum();
assert!(close(span, 100.0, 0.05), "{span}");
// A negative length is ignored.
let t = open(r#"<text x="20" y="100" font-size="20" textLength="-5">Hello World</text>"#);
assert!(close(advance(only_text(&t)), natural, 1e-6));
}
+21
View File
@@ -82,6 +82,27 @@ proptest! {
let _ = import(&format!("<svg xmlns=\"http://www.w3.org/2000/svg\">{s}</svg>"));
}
/// Any `textLength` and `lengthAdjust`, on the text or a tspan (vertical too), imports to a
/// valid document without panicking.
#[test]
fn import_random_text_lengths(
len in "-?[0-9]{0,12}(\\.[0-9]{0,4})?(e[+-]?[0-9]{1,3})?(%|px|em|pt)?|[a-z ]{0,6}",
adjust in "spacing|spacingAndGlyphs|[a-zA-Z]{0,8}",
text in "[A-Za-z \u{5d0}-\u{5ea}]{0,12}",
on_span in any::<bool>(),
vertical in any::<bool>(),
) {
let attrs = format!("textLength=\"{len}\" lengthAdjust=\"{adjust}\"");
let (outer, inner) = if on_span { ("", attrs.as_str()) } else { (attrs.as_str(), "") };
let wm = if vertical { " writing-mode=\"tb\"" } else { "" };
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\"><text x=\"10\" y=\"50\"{wm} {outer}>a<tspan {inner}>{text}</tspan>b</text></svg>"
);
if let Ok(doc) = import(&svg) {
check_document(&doc).map_err(TestCaseError::fail)?;
}
}
/// Importing random path data never panics and gives a valid document.
#[test]
fn import_random_path_data(d in "[MLCQZHVAmlcqzhva0-9 .,eE+-]{0,80}") {