diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 57a156b82..2502bd2bd 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -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)], cells: &mut [Cell], vertical: bool, label: &str, warnings: &mut Vec) { + 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 `` follows, in the text's user space. fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { 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 = vec![]; let mut para_styles: Vec = vec![]; diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index 5fa2b5d7f..7d0e08c0b 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -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#"Hello World"#))); + for adjust in ["spacingAndGlyphs", "spacing"] { + for want in [50.0, 150.0, 300.0] { + let (d, w) = import_with_report(&svg(&format!( + r#"Hello World"# + ))) + .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#"Hi there!"#); + 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#"Hello World"#); + assert!(close(advance(only_text(&t)), natural, 1e-6)); +} diff --git a/crates/svg/tests/it/prop_svg.rs b/crates/svg/tests/it/prop_svg.rs index a25aec175..2dd7ceec1 100644 --- a/crates/svg/tests/it/prop_svg.rs +++ b/crates/svg/tests/it/prop_svg.rs @@ -82,6 +82,27 @@ proptest! { let _ = import(&format!("{s}")); } + /// 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::(), + vertical in any::(), + ) { + 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!( + "a{text}b" + ); + 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}") {