mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-07 13:23:22 +00:00
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:
@@ -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![];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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}") {
|
||||
|
||||
Reference in New Issue
Block a user