Files
vectorcraft/crates/svg/src/import/text.rs
T
bflatasticandClaude Opus 5.5 65dd757d59 Fix #131: SVG import keeps non-scaling strokes at their screen width
usvg knows vector-effect but drops it from the tree it returns, so every
stroke was scaled by its transforms. The importer now records the shapes
that ask for vector-effect="non-scaling-stroke" in its XML prepass (by id)
and gives their strokes and dashes the width they have on screen; type
does the same on its character strokes. Where the width can't be kept
(art in patterns, markers or <use> copies, stretched or skewed type) the
import warns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:36:29 +07:00

803 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `<text>` import.
//!
//! usvg only converts text when it has fonts, and a font database is too expensive to load (and
//! unavailable on wasm), so text is read from the XML. Before usvg runs, [`prepare`] replaces each
//! `<text>` element with a placeholder `<g>` that carries the element's id, transform, conditional
//! attributes, opacity, blending, clip, mask and filter. The group holds a rectangle over the text's
//! bounds (id `{prefix}{i}`, filled with colour `i` so it is still found inside `<use>` instances,
//! where usvg drops ids), followed by one rectangle per `url(#…)` paint, painted with it. usvg then
//! keeps the text's z-order, parent groups, clips, masks and `<use>` instances, and resolves its
//! gradients and patterns in the text's user space; the importer swaps each placeholder for the
//! [`PendingText`] it stands for.
//!
//! Characters keep SVG's per-character `x`/`y`/`dx`/`dy`/`rotate` lists: a character with an
//! absolute `x` that moves down by at least half a line starts a new line (the leading becomes the
//! line spacing), other vertical moves become baseline shift, horizontal moves become kerning (an
//! absolute `x` is measured against the laid-out text), and rotations become character rotation.
//! `<textPath>` becomes type on a path; vertical `writing-mode` becomes type on a vertical path.
use std::borrow::Cow;
use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::ops::Range;
use std::rc::Rc;
use std::str::FromStr;
use usvg::roxmltree;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{CharStyle, Dash, Justify, LineCap, LineJoin, TextKind, TextObject, TextRun};
use vectorcraft_geom::kurbo::ParamCurveArclen;
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect};
use vectorcraft_text::{FontDb, TextLayout};
use super::css::{Styles, XNode};
use super::{DEFAULT_FONT_SIZE, PT_PER_IN, href, vector_effect};
use crate::xml_escape;
const SVG_NS: &str = "http://www.w3.org/2000/svg";
/// Most line breaks one absolutely positioned character adds before itself.
const MAX_LINE_GAP: f64 = 10_000.0;
/// A whitespace/comma separated list.
fn list(v: &str) -> impl Iterator<Item = &str> {
v.split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty())
}
/// Style name for a CSS weight (rounded to the nearest hundred) and slant.
fn style_name(weight: u16, italic: bool) -> String {
const NAMES: [&str; 9] = ["Thin", "ExtraLight", "Light", "Regular", "Medium", "SemiBold", "Bold", "ExtraBold", "Black"];
let name = NAMES.get((weight.clamp(100, 900) as usize + 50) / 100 - 1).copied().unwrap_or("Regular");
match (name, italic) {
("Regular", true) => "Italic".into(),
(n, true) => format!("{n} Italic"),
(n, false) => n.into(),
}
}
fn solid(c: svgtypes::Color) -> Paint {
Paint::solid(Color::rgb8(c.red, c.green, c.blue))
}
fn parse_transform(s: Option<&str>) -> Affine {
s.and_then(|s| svgtypes::Transform::from_str(s).ok()).map(|t| Affine::new([t.a, t.b, t.c, t.d, t.e, t.f])).unwrap_or(Affine::IDENTITY)
}
/// Character attributes an element gives its text.
struct ElemStyle {
style: CharStyle,
/// `url(#…)` fill and stroke paints (resolved by usvg).
fill: Option<String>,
stroke: Option<String>,
/// Extra space after each space character (`word-spacing`).
word_spacing: f64,
}
/// One character of a text and the adjustments its position needs.
struct Cell {
ch: char,
style: CharStyle,
fill: Option<usize>,
stroke: Option<usize>,
/// Space added after the character, in points (becomes manual kerning).
kern: f64,
}
impl Cell {
fn final_style(&self) -> CharStyle {
let mut st = self.style.clone();
st.baseline_shift = finite(st.baseline_shift);
if self.kern.is_finite() && self.kern.abs() > 1e-9 && st.size > 0.0 {
st.kerning = Some(st.kerning.unwrap_or(0.0) + self.kern / st.size * 1000.0);
}
st
}
}
/// The characters of a `<text>` after white-space processing.
struct Chars<'a, 'i> {
/// Each character and the element it comes from.
chars: Vec<(char, XNode<'a, 'i>)>,
/// Every element in tree order with the range of characters inside it.
spans: Vec<(XNode<'a, 'i>, Range<usize>)>,
/// The first `<textPath>`.
path: Option<XNode<'a, 'i>>,
}
/// A line break before character `at`: `count` newlines, `leading` apart.
struct Break {
at: usize,
count: usize,
leading: f64,
}
/// A `<text>` element read from the XML, waiting for usvg to place it.
#[derive(Clone)]
pub(super) struct PendingText {
/// The element's `id`.
pub name: String,
/// The text; `xf` maps text space into the element's user space.
pub obj: TextObject,
/// For each run, the index of its fill's and stroke's `url(#…)` paint, if any.
pub servers: Vec<(Option<usize>, Option<usize>)>,
/// Number of `url(#…)` paints (the placeholder paths after the main one, in order).
pub paints: usize,
/// `vector-effect="non-scaling-stroke"`: the character strokes keep their width on screen.
pub non_scaling: bool,
/// The placeholder rectangle in the element's user space.
marker: Rect,
}
/// The texts [`prepare`] replaced, by placeholder index.
pub(super) struct TextSlots {
prefix: String,
pub texts: Vec<PendingText>,
}
impl TextSlots {
/// The text a placeholder's main path stands for: by its id, or inside `<use>` instances
/// (where usvg drops ids) by its colour (the index) and its rectangle.
pub fn find(&self, p: &usvg::Path) -> Option<usize> {
if !p.id().is_empty() {
return p.id().strip_prefix(&self.prefix)?.parse().ok();
}
let Some(usvg::Paint::Color(c)) = p.fill().map(|f| f.paint()) else { return None };
if p.stroke().is_some() || p.data().segments().count() != 5 {
return None;
}
let i = (c.red as usize) << 16 | (c.green as usize) << 8 | c.blue as usize;
let m = self.texts.get(i)?.marker;
let b = p.data().bounds();
let near = |a: f32, b: f64| (a as f64 - b).abs() <= 1e-3 * b.abs().max(1.0);
(near(b.left(), m.x0) && near(b.top(), m.y0) && near(b.right(), m.x1) && near(b.bottom(), m.y1)).then_some(i)
}
}
/// Replace every `<text>` usvg would draw by a placeholder group (see the module docs).
pub(super) fn prepare<'s>(svg: &'s str, xml: &roxmltree::Document, dpi: f64, warnings: &mut Vec<String>) -> (Cow<'s, str>, TextSlots) {
let mut prefix = String::from("vectorcraft-text-");
while xml.descendants().any(|n| n.attribute("id").is_some_and(|id| id.starts_with(&prefix))) {
prefix.insert(0, '_');
}
let mut slots = TextSlots { prefix, texts: vec![] };
let cx = Ctx::new(xml, dpi);
let mut out = String::new();
let mut copied = 0;
for t in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text" && n.tag_name().namespace() == Some(SVG_NS)) {
let label = t.attribute("id").map_or_else(|| "a text".to_string(), |id| format!("text '{id}'"));
if t.ancestors().skip(1).any(|a| a.is_element() && a.tag_name().name() == "clipPath") {
warnings.push(format!("{label} in a clip path ignored"));
continue;
}
let r = t.range();
// The element's qualified name (`text` or `svg:text`), so the placeholder lands in the same
// namespace; skip anything whose source range isn't the element itself (entity expansions).
let Some(qname) = svg
.get(r.start..r.end)
.and_then(|s| s.strip_prefix('<'))
.map(|s| s.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or(""))
else {
continue;
};
let Some(q) = qname.strip_suffix("text").filter(|q| q.is_empty() || q.ends_with(':')) else { continue };
if cx.css.own(t, "display").as_deref() == Some("none") || r.start < copied {
continue;
}
let Some((text, paints)) = cx.build(xml, t, &label, warnings) else { continue };
out.push_str(&svg[copied..r.start]);
placeholder(&mut out, q, &cx.css, t, &slots.prefix, slots.texts.len(), text.marker, &paints);
copied = r.end;
slots.texts.push(text);
}
if slots.texts.is_empty() {
return (Cow::Borrowed(svg), slots);
}
out.push_str(&svg[copied..]);
(Cow::Owned(out), slots)
}
/// Write the placeholder group for text `t` (namespace prefix `q`), slot `index`.
#[allow(clippy::too_many_arguments)]
fn placeholder(out: &mut String, q: &str, css: &Styles, t: XNode, prefix: &str, index: usize, b: Rect, paints: &[String]) {
let _ = write!(out, "<{q}g");
for a in ["id", "transform", "systemLanguage", "requiredFeatures", "requiredExtensions"] {
if let Some(v) = t.attribute(a) {
let _ = write!(out, " {a}=\"{}\"", xml_escape(v));
}
}
// The text's own non-inherited properties, overriding any rule that matches the `<g>`.
let mut style = String::new();
for (p, initial) in
[("opacity", "1"), ("clip-path", "none"), ("mask", "none"), ("filter", "none"), ("mix-blend-mode", "normal"), ("isolation", "auto")]
{
let _ = write!(style, "{p}:{};", css.own(t, p).as_deref().unwrap_or(initial));
}
if let Some(v) = css.own(t, "visibility") {
let _ = write!(style, "visibility:{v};");
}
let _ = write!(out, " style=\"{}display:inline\">", xml_escape(&style));
let d = format!("M{} {}H{}V{}H{}Z", b.x0, b.y0, b.x1, b.y1, b.x0);
const REST: &str = "stroke:none;opacity:1;display:inline;marker:none;clip-path:none;mask:none;filter:none";
let _ = write!(out, "<{q}path id=\"{prefix}{index}\" style=\"fill:#{:06x};{REST}\" d=\"{d}\"/>", index & 0xff_ffff);
for p in paints {
let _ = write!(out, "<{q}path style=\"fill:{};{REST}\" d=\"{d}\"/>", xml_escape(p));
}
let _ = write!(out, "</{q}g>");
}
/// Computed text properties of the document's elements.
struct Ctx {
css: Styles,
/// Pixels per inch absolute lengths convert at ([`super::RootUnits::dpi`]).
dpi: f64,
/// Viewport size in user units (the base of `x`/`y` percentages).
viewport: (f64, f64),
sizes: RefCell<HashMap<roxmltree::NodeId, f64>>,
weights: RefCell<HashMap<roxmltree::NodeId, u16>>,
elems: RefCell<HashMap<roxmltree::NodeId, Rc<ElemStyle>>>,
}
impl Ctx {
fn new(xml: &roxmltree::Document, dpi: f64) -> Self {
let mut cx = Self {
css: Styles::new(xml),
dpi,
viewport: (100.0, 100.0),
sizes: RefCell::default(),
weights: RefCell::default(),
elems: RefCell::default(),
};
let root = xml.root_element();
let dim = |a: &str| root.attribute(a).and_then(|v| cx.length(v, DEFAULT_FONT_SIZE, 0.0)).filter(|v| *v > 0.0);
cx.viewport = match root.attribute("viewBox").and_then(|v| svgtypes::ViewBox::from_str(v).ok()) {
Some(vb) if vb.w > 0.0 && vb.h > 0.0 => (vb.w, vb.h),
_ => (dim("width").unwrap_or(100.0), dim("height").unwrap_or(100.0)),
};
cx
}
/// A length in user units: absolute units convert at [`Self::dpi`], `em` and `ex` are relative
/// to `em` (the font size) and percentages to `pct`.
fn length(&self, v: &str, em: f64, pct: f64) -> Option<f64> {
use svgtypes::LengthUnit as L;
let l = svgtypes::Length::from_str(v.trim()).ok()?;
let n = l.number;
let v = match l.unit {
L::None | L::Px => n,
L::Pt => n * self.dpi / PT_PER_IN,
L::Pc => n * self.dpi / 6.0,
L::In => n * self.dpi,
L::Cm => n * self.dpi / 2.54,
L::Mm => n * self.dpi / 25.4,
L::Em => n * em,
L::Ex => n * em / 2.0,
L::Percent => n / 100.0 * pct,
};
v.is_finite().then_some(v)
}
/// A `font-size` value given the parent's computed size (CSS keywords are relative to `medium`).
fn parse_font_size(&self, v: &str, parent: f64) -> Option<f64> {
let k = match v.trim() {
"xx-small" => 3.0 / 5.0,
"x-small" => 3.0 / 4.0,
"small" => 8.0 / 9.0,
"medium" => 1.0,
"large" => 6.0 / 5.0,
"x-large" => 3.0 / 2.0,
"xx-large" => 2.0,
"larger" => return Some(parent * 1.2),
"smaller" => return Some(parent / 1.2),
v => return self.length(v, parent, parent).filter(|s| *s > 0.0),
};
Some(DEFAULT_FONT_SIZE * k)
}
/// Computed `font-size` of an element.
fn font_size(&self, n: XNode) -> f64 {
if let Some(s) = self.sizes.borrow().get(&n.id()) {
return *s;
}
let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p));
let s = self.css.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent);
self.sizes.borrow_mut().insert(n.id(), s);
s
}
/// Computed `font-weight` of an element (100–900).
fn weight(&self, n: XNode) -> u16 {
if let Some(w) = self.weights.borrow().get(&n.id()) {
return *w;
}
let parent = n.parent_element().map_or(400, |p| self.weight(p));
let w = match self.css.own(n, "font-weight").as_deref() {
Some("normal") => 400,
Some("bold") => 700,
Some("bolder") => match parent {
..=349 => 400,
350..=549 => 700,
_ => 900,
},
Some("lighter") => match parent {
..=549 => 100,
550..=749 => 400,
_ => 700,
},
Some(v) => v.parse::<f64>().ok().filter(|w| (1.0..=1000.0).contains(w)).map_or(parent, |w| w.round() as u16),
None => parent,
};
self.weights.borrow_mut().insert(n.id(), w);
w
}
/// A fill or stroke: a ready paint, or a `url(#…)` paint for usvg to resolve.
fn paint(&self, n: XNode, prop: &str) -> (Paint, Option<String>) {
let initial = if prop == "fill" { Paint::solid(Color::BLACK) } else { Paint::None };
let Some(v) = self.css.prop(n, prop) else { return (initial, None) };
match svgtypes::Paint::from_str(&v) {
Ok(svgtypes::Paint::None) => (Paint::None, None),
Ok(svgtypes::Paint::Color(c)) => (solid(c), None),
Ok(svgtypes::Paint::CurrentColor) => {
(self.css.prop(n, "color").and_then(|c| svgtypes::Color::from_str(&c).ok()).map_or(Paint::solid(Color::BLACK), solid), None)
}
Ok(svgtypes::Paint::FuncIRI(..)) => (Paint::None, Some(v)),
_ => (initial, None),
}
}
/// Character attributes of an element inside `<text>` (cached).
fn elem(&self, n: XNode) -> Rc<ElemStyle> {
if let Some(e) = self.elems.borrow().get(&n.id()) {
return e.clone();
}
let css = &self.css;
let mut st = CharStyle::default();
if let Some(f) = css.prop(n, "font-family") {
let fam = f.split(',').next().unwrap_or("").trim().trim_matches(|c| c == '\'' || c == '"').to_string();
if !fam.is_empty() {
st.font_family = fam;
}
}
let size = self.font_size(n);
st.size = size;
let italic = css.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
st.font_style = style_name(self.weight(n), italic);
let (fill, mut fill_url) = self.paint(n, "fill");
st.fill = fill;
let (stroke, mut stroke_url) = self.paint(n, "stroke");
if !stroke.is_none() || stroke_url.is_some() {
st.stroke = stroke;
st.stroke_width = css.prop(n, "stroke-width").and_then(|w| self.length(&w, size, size)).unwrap_or(1.0);
st.stroke_cap = match css.prop(n, "stroke-linecap").as_deref() {
Some("round") => LineCap::Round,
Some("square") => LineCap::Square,
_ => LineCap::Butt,
};
st.stroke_join = match css.prop(n, "stroke-linejoin").as_deref() {
Some("round") => LineJoin::Round,
Some("bevel") => LineJoin::Bevel,
_ => LineJoin::Miter,
};
st.stroke_miter_limit =
css.prop(n, "stroke-miterlimit").and_then(|m| m.trim().parse::<f64>().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0));
let pattern: Vec<f64> = css.prop(n, "stroke-dasharray").map_or(vec![], |d| list(&d).filter_map(|v| self.length(v, size, size)).collect());
let offset = css.prop(n, "stroke-dashoffset").and_then(|o| self.length(&o, size, size)).unwrap_or(0.0);
st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed);
}
if let Some(v) = css.prop(n, "letter-spacing").and_then(|v| self.length(&v, size, size))
&& size > 0.0
{
st.tracking = v / size * 1000.0;
}
if let Some(v) = css.prop(n, "kerning").and_then(|v| self.length(&v, size, size))
&& size > 0.0
{
st.kerning = Some(v / size * 1000.0);
}
if let Some(d) = css.prop(n, "text-decoration") {
st.underline = d.contains("underline");
st.strikethrough = d.contains("line-through");
}
// `baseline-shift` isn't inherited but nests: each element shifts its parent's baseline.
let own = match css.own(n, "baseline-shift").as_deref().map(str::trim) {
Some("sub") => -0.2 * size,
Some("super") => 0.4 * size,
Some(v) => self.length(v, size, size).unwrap_or(0.0),
None => 0.0,
};
let parent = if n.tag_name().name() == "text" { 0.0 } else { n.parent_element().map_or(0.0, |p| self.elem(p).style.baseline_shift) };
st.baseline_shift = parent + own;
if css.prop(n, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
(st.fill, st.stroke, fill_url, stroke_url) = (Paint::None, Paint::None, None, None);
}
let word_spacing = css.prop(n, "word-spacing").and_then(|v| self.length(&v, size, size)).unwrap_or(0.0);
let e = Rc::new(ElemStyle { style: st, fill: fill_url, stroke: stroke_url, word_spacing });
self.elems.borrow_mut().insert(n.id(), e.clone());
e
}
/// Characters of `el` and its `<tspan>`, `<textPath>` and `<a>` children, with white space
/// handled like `xml:space` (newlines and tabs become spaces; runs of spaces collapse unless
/// preserved).
fn collect<'b, 'i>(&self, el: XNode<'b, 'i>, preserve: bool, out: &mut Chars<'b, 'i>) {
let start = out.chars.len();
let slot = out.spans.len();
out.spans.push((el, start..start));
for c in el.children() {
if c.is_text() {
for ch in c.text().unwrap_or("").chars() {
let ch = if matches!(ch, '\n' | '\r' | '\t') { ' ' } else { ch };
if !preserve && ch == ' ' && out.chars.last().is_none_or(|(p, _)| *p == ' ') {
continue;
}
out.chars.push((ch, el));
}
} else if c.is_element()
&& c.tag_name().namespace() == Some(SVG_NS)
&& matches!(c.tag_name().name(), "tspan" | "textPath" | "a")
&& self.css.own(c, "display").as_deref() != Some("none")
{
if c.tag_name().name() == "textPath" {
out.path.get_or_insert(c);
}
self.collect(c, preserve, out);
}
}
out.spans[slot].1 = start..out.chars.len();
}
/// 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") {
Some(d) => BezPath::from_svg(d).ok()?,
None => {
let id = href(tp)?.trim().strip_prefix('#')?;
let el = xml.descendants().find(|n| n.is_element() && n.attribute("id") == Some(id))?;
if el.tag_name().name() != "path" {
return None;
}
let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?;
bp.apply_affine(parse_transform(el.attribute("transform")));
bp
}
};
if tp.attribute("side") == Some("right") {
bp = bp.reverse_subpaths();
}
let usable = bp.segments().next().is_some();
usable.then_some(bp)
}
/// Read one `<text>`: the pending text and its `url(#…)` paints.
fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec<String>) -> Option<(PendingText, Vec<String>)> {
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
let mut cs = Chars { chars: vec![], spans: vec![], path: None };
self.collect(t, preserve, &mut cs);
if !preserve {
while cs.chars.last().is_some_and(|c| c.0 == ' ') {
cs.chars.pop();
}
}
let n = cs.chars.len();
if n == 0 {
return None;
}
// Positioning lists, applied in tree order so descendants override their ancestors.
let (vw, vh) = self.viewport;
let (mut x, mut y, mut dx, mut dy, mut rot) = (vec![None; n], vec![None; n], vec![0.0; n], vec![0.0; n], vec![None; n]);
for (el, r) in &cs.spans {
let r = r.start.min(n)..r.end.min(n);
let em = self.font_size(*el);
let apply = |name: &str, pct: f64, put: &mut dyn FnMut(usize, f64)| {
if let Some(v) = el.attribute(name) {
for (i, l) in r.clone().zip(list(v).filter_map(|t| self.length(t, em, pct))) {
put(i, l);
}
}
};
apply("x", vw, &mut |i, v| x[i] = Some(v));
apply("y", vh, &mut |i, v| y[i] = Some(v));
apply("dx", vw, &mut |i, v| dx[i] = v);
apply("dy", vh, &mut |i, v| dy[i] = v);
// A shorter rotate list repeats its last value.
let angles: Vec<f64> =
el.attribute("rotate").map(|v| list(v).filter_map(|t| t.parse().ok()).filter(|a: &f64| a.is_finite()).collect()).unwrap_or_default();
if let Some(&last) = angles.last() {
for (k, i) in r.enumerate() {
rot[i] = Some(*angles.get(k).unwrap_or(&last));
}
}
}
let mut paints: Vec<String> = vec![];
let mut paint_index = |url: &Option<String>| {
url.as_ref().map(|u| {
paints.iter().position(|p| p == u).unwrap_or_else(|| {
paints.push(u.clone());
paints.len() - 1
})
})
};
let mut cells: Vec<Cell> = cs
.chars
.iter()
.zip(&rot)
.map(|((ch, el), r)| {
let e = self.elem(*el);
let mut style = e.style.clone();
if let Some(r) = r {
style.rotation = -r;
}
Cell {
ch: *ch,
style,
fill: paint_index(&e.fill),
stroke: paint_index(&e.stroke),
kern: if *ch == ' ' { e.word_spacing } else { 0.0 },
}
})
.collect();
let anchor = match self.css.prop(cs.chars[0].1, "text-anchor").as_deref() {
Some("middle") => 0.5,
Some("end") => 1.0,
_ => 0.0,
};
let path = cs.path.and_then(|tp| match self.text_path(xml, tp) {
Some(p) => Some((tp, p)),
None => {
warnings.push(format!("{label}: textPath has no usable path; imported as point type"));
None
}
});
let vertical = self.css.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical"));
let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default());
let mut breaks: Vec<Break> = vec![];
if let Some((tp, bp)) = path {
// Along the path: dx is extra advance, dy shifts off the path.
let mut off = 0.0;
for i in 0..n {
off -= dy[i];
cells[i].style.baseline_shift += off;
if i > 0 {
cells[i - 1].kern += dx[i];
}
}
let len: f64 = bp.segments().map(|s| s.arclen(1e-4)).sum();
let em = self.font_size(tp);
let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0];
let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 };
let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0));
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start };
obj.xf = Affine::IDENTITY;
} else if vertical {
// Type on a vertical path: dy is extra advance, dx shifts across the column.
let mut off = 0.0;
for i in 0..n {
off += dx[i];
cells[i].style.baseline_shift += off;
if i > 0 {
cells[i - 1].kern += dy[i];
}
}
let lay = measure(&point_text(&cells));
let width: f64 = lay.glyphs.iter().map(|g| g.advance).sum();
// Glyphs turn 90° clockwise; centre the em box on the column (SVG's central baseline).
let centre = lay.lines.first().map_or(0.0, |l| (l.ascent - l.descent) / 2.0);
let (x0, y0) = (finite(x[0].unwrap_or(0.0) - centre), finite(y[0].unwrap_or(0.0) + dy[0] - anchor * width));
let mut bp = BezPath::new();
bp.move_to((x0, y0));
bp.line_to((x0, y0 + width + 1.0));
obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start: 0.0 };
obj.xf = Affine::IDENTITY;
} else {
let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]);
let (justify, left) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks);
obj.para.justify = justify;
obj.xf = Affine::translate((finite(left), finite(origin.y)));
}
let (runs, servers) = assemble(&cells, &breaks).0;
obj.runs = runs;
// The placeholder covers the text (laid out when a paint server needs its bounding box).
let b = if paints.is_empty() { obj.bounds() } else { Some(obj.xf.transform_rect_bbox(measure(&obj).bounds)) }.unwrap_or_default();
let marker = Rect::new(b.x0, b.y0, b.x1.max(b.x0 + 1.0), b.y1.max(b.y0 + 1.0));
let name = t.attribute("id").unwrap_or("").to_string();
let non_scaling = vector_effect::is_non_scaling(&self.css, t);
Some((PendingText { name, obj, servers, paints: paints.len(), non_scaling, marker }, paints))
}
}
/// `v`, or 0 when far-off positions added up past f64's range.
fn finite(v: f64) -> f64 {
if v.is_finite() { v } else { 0.0 }
}
/// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the
/// justification and the x of the first line's left (or anchor) edge.
#[allow(clippy::too_many_arguments)]
fn lines(
cells: &mut [Cell],
x: &[Option<f64>],
y: &[Option<f64>],
dx: &[f64],
dy: &[f64],
anchor: f64,
x0: f64,
breaks: &mut Vec<Break>,
) -> (Justify, f64) {
let n = cells.len();
let y0 = y[0].unwrap_or(0.0) + dy[0];
let (mut base, mut cur) = (y0, y0);
// Line starts (first character, anchor x) and absolutely placed characters (index, x).
let mut lines = vec![(0, x0)];
let mut targets: Vec<(usize, f64)> = vec![];
for i in 1..n {
let ny = y[i].unwrap_or(cur) + dy[i];
let lead = cells[i].style.effective_leading();
let gap = ny - base;
if let Some(ax) = x[i].filter(|_| gap.is_finite() && gap > lead * 0.5) {
// Whole multiples of the auto leading add blank lines (not without limit: a far-off y
// mustn't ask for billions); other spacing is one break.
let count = (gap / lead).round().clamp(1.0, MAX_LINE_GAP);
let count = if (gap / count - lead).abs() <= lead * 0.05 { count } else { 1.0 };
breaks.push(Break { at: i, count: count as usize, leading: gap / count });
lines.push((i, ax + dx[i]));
(base, cur) = (ny, ny);
continue;
}
cur = ny;
match x[i] {
Some(ax) => targets.push((i, ax + dx[i])),
None => cells[i - 1].kern += dx[i],
}
cells[i].style.baseline_shift += base - cur;
}
// A line's characters take its spacing as leading unless it is their auto leading.
for (k, b) in breaks.iter().enumerate() {
let end = breaks.get(k + 1).map_or(n, |nb| nb.at);
if cells[b.at..end].iter().any(|c| (c.style.effective_leading() - b.leading).abs() > 0.01) {
for c in &mut cells[b.at..end] {
c.style.leading = Some(b.leading);
}
}
}
if targets.is_empty() {
let justify = match anchor {
a if a >= 1.0 => Justify::Right,
a if a > 0.0 => Justify::Center,
_ => Justify::Left,
};
return (justify, x0);
}
// Absolutely placed characters: lay the text out left-aligned and kern each one onto its x.
// SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each
// chunk's target moves back by its share of the chunk's width.
let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1;
let mut left = x0;
let mut wants: Vec<(usize, f64)> = targets.clone();
if anchor > 0.0 {
let (runs, bytes) = assemble(cells, breaks);
let lay = measure(&with_runs(runs.0));
let (pens, ends) = pens(&lay, &bytes);
// Chunk starts in order: line starts and absolutely placed characters.
let mut starts: Vec<usize> = lines.iter().map(|l| l.0).chain(targets.iter().map(|t| t.0)).collect();
starts.sort_unstable();
let width = |s: usize| {
let k = starts.partition_point(|&c| c <= s);
let e = starts.get(k).copied().unwrap_or(n);
let end = (s..e).filter_map(|j| ends[j]).fold(f64::NEG_INFINITY, f64::max);
match pens[s] {
Some(p) if end.is_finite() => end - p,
_ => 0.0,
}
};
let line_left: Vec<f64> = lines.iter().map(|&(s, lx)| lx - anchor * width(s)).collect();
left = line_left[0];
for w in &mut wants {
w.1 -= anchor * width(w.0) + line_left[line_of(w.0)];
}
} else {
for w in &mut wants {
w.1 -= lines[line_of(w.0)].1;
}
}
// Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until
// every character sits on its x.
for _ in 0..4 {
let (runs, bytes) = assemble(cells, breaks);
let lay = measure(&with_runs(runs.0));
let (pens, _) = pens(&lay, &bytes);
let (mut line, mut moved, mut worst) = (usize::MAX, 0.0, 0.0f64);
for &(i, want) in &wants {
let l = line_of(i);
if l != line {
(line, moved) = (l, 0.0);
}
let (Some(p), Some(p0)) = (pens[i], pens[lines[l].0]) else { continue };
let d = want - (p - p0 + moved);
cells[i - 1].kern += d;
moved += d;
worst = worst.max(d.abs());
}
if worst < 0.01 {
break;
}
}
(Justify::Left, left)
}
/// A left-aligned point text of `runs` at the origin (for measuring).
fn with_runs(runs: Vec<TextRun>) -> TextObject {
let mut t = TextObject::point(Point::ZERO, "", CharStyle::default());
t.runs = runs;
t
}
fn point_text(cells: &[Cell]) -> TextObject {
with_runs(assemble(cells, &[]).0.0)
}
fn measure(t: &TextObject) -> TextLayout {
vectorcraft_text::layout(FontDb::global(), t)
}
/// Total advance of a laid-out single line.
fn advance(t: &TextObject) -> f64 {
measure(t).glyphs.iter().map(|g| g.advance).sum()
}
/// Pen x (start) and end x of the glyph that starts at each character's byte offset.
fn pens(lay: &TextLayout, bytes: &[usize]) -> (Vec<Option<f64>>, Vec<Option<f64>>) {
let mut at: HashMap<usize, (f64, f64)> = HashMap::with_capacity(lay.glyphs.len());
for g in &lay.glyphs {
at.entry(g.byte).or_insert((g.origin.x, g.origin.x + g.advance));
}
bytes.iter().map(|b| at.get(b).map_or((None, None), |&(s, e)| (Some(s), Some(e)))).unzip()
}
type Key = (Option<usize>, Option<usize>);
type Runs = (Vec<TextRun>, Vec<Key>);
/// Runs (and their paint indices) from the characters, with `breaks` inserted; also the byte offset
/// of each character in the text.
fn assemble(cells: &[Cell], breaks: &[Break]) -> (Runs, Vec<usize>) {
fn push((runs, keys): &mut Runs, ch: char, style: CharStyle, key: Key) {
if let Some(r) = runs.last_mut()
&& keys.last() == Some(&key)
&& r.style == style
{
r.text.push(ch);
return;
}
runs.push(TextRun { text: ch.into(), style });
keys.push(key);
}
let mut out: Runs = (vec![], vec![]);
let mut bytes = Vec::with_capacity(cells.len());
let mut off = 0;
let mut next = breaks.iter().peekable();
for (i, c) in cells.iter().enumerate() {
if let Some(b) = next.next_if(|b| b.at == i)
&& let Some(prev) = i.checked_sub(1).map(|p| &cells[p])
{
// The first newline ends the previous line; the others are blank lines `leading` apart.
let key = (prev.fill, prev.stroke);
push(&mut out, '\n', prev.final_style(), key);
let mut blank = prev.final_style();
if (blank.effective_leading() - b.leading).abs() > 0.01 {
blank.leading = Some(b.leading);
}
for _ in 1..b.count {
push(&mut out, '\n', blank.clone(), key);
}
off += b.count;
}
bytes.push(off);
off += c.ch.len_utf8();
push(&mut out, c.ch, c.final_style(), (c.fill, c.stroke));
}
(out, bytes)
}