M4.19: SVG text import fidelity — text keeps its place, group, clip and mask; textPath, per-character positions, weights, spacing, paints and CSS selectors survive

Each <text> is swapped for a placeholder group before usvg runs, so the text keeps its z-order,
parent group, clip, mask, opacity, link and <use> instances, and usvg resolves its gradient and
pattern paints (in text space). <textPath> becomes type on a path (startOffset, side, anchor),
vertical writing-mode type on a vertical path. Per-character x/y/dx/dy/rotate lists become line
breaks (bounded), kerning (absolute x measured against the layout), baseline shift and rotation;
baseline-shift, numeric and relative weights (style names), letter/word spacing, kerning and the
character stroke's cap, join, miter and dashes are kept. The style cascade supports type, #id,
multiple-class, attribute, descendant and child selectors with !important and specificity.
<pattern> paints become pattern swatches. The import fuzz covers the new attributes and CSS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 07:20:10 +07:00
parent bc17c1e635
commit 5c58ebcb32
7 changed files with 1717 additions and 450 deletions

View File

@ -117,6 +117,14 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
"mask-type",
"data-vectorcraft-mask",
"href",
"baseline-shift",
"word-spacing",
"kerning",
"writing-mode",
"font-weight",
"text-anchor",
"side",
"path",
]);
let value = prop_oneof![
arb_num(),
@ -203,6 +211,19 @@ proptest! {
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
}
#[test]
fn svg_hostile_css_never_panics(
sheet in r#"[a-z#.>*\[\]=:;{}@ !"'/,-]{0,160}"#,
decls in r#"[a-z0-9.%: ;!-]{0,60}"#,
body in prop::collection::vec(arb_svg_element(1), 0..3),
) {
let svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>{sheet}</style><g class=\"a b\"><text id=\"t\" class=\"a\" x=\"1 2 3\" y=\"9\" rotate=\"5\" style=\"{decls}\">ab<tspan dy=\"3\">c</tspan></text></g>{}</svg>",
body.concat()
);
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
}
#[test]
fn svg_mutated_export_never_panics(
cut in 0usize..20_000,

View File

@ -7,24 +7,33 @@ use std::sync::Arc;
use usvg::roxmltree;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind,
StrokeLayer, TextKind, TextObject, TextRun, Unit,
Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeKind, PatternDef,
StrokeLayer, Unit,
};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2};
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
use crate::SvgError;
use css::XNode;
use text::TextSlots;
mod css;
mod text;
pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
let (svg, links) = link_ids(svg);
let svg = svg.as_ref();
let units = RootUnits::of(svg);
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
.map_err(|e| SvgError::Parse(e.to_string()))?;
let units = RootUnits::of(xml.root_element());
let mut warnings = Vec::new();
let (src, slots) = text::prepare(svg, &xml, units.dpi, &mut warnings);
let opt = usvg::Options { dpi: units.dpi as f32, font_size: DEFAULT_FONT_SIZE as f32, ..usvg::Options::default() };
let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?;
let tree = usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?;
let size = tree.size();
let (kx, ky) = units.k;
let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
doc.units = units.unit;
let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg), links };
let mut im = Importer { doc, warnings, mask_flags: mask_flags(&xml), links, slots, patterns: HashMap::new() };
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
let mut top = tree.root();
@ -32,22 +41,35 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
if let [usvg::Node::Group(g)] = top.children()
&& g.id().is_empty()
&& is_plain(g)
&& im.text_slot(g).is_none()
{
base *= aff(g.transform());
top = g;
}
// Top-level `<g id>` elements become layers (Illustrator does the same); otherwise all art goes
// into "Layer 1".
let layer_mode = !top.children().is_empty()
&& top.children().iter().all(|c| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !im.links.contains_key(g.id()) && is_plain(g)));
// Top-level `<g id>` elements become layers (as in the reference app); otherwise all art goes
// into "Layer 1". Top-level text joins the layer below it (the first layer if none is).
let is_layer = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if !g.id().is_empty() && !im.links.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none());
let is_text = |im: &Importer, c: &usvg::Node| matches!(c, usvg::Node::Group(g) if im.text_slot(g).is_some());
let layer_mode = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c));
if layer_mode {
im.doc.layers.clear();
for (i, c) in top.children().iter().enumerate() {
let mut loose = vec![];
for c in top.children() {
let usvg::Node::Group(g) = c else { continue };
let children = im.children(g, base * aff(g.transform()));
if !is_layer(&im, c) {
let n = im.node(c, base).map(Arc::new);
match im.doc.layers.last_mut().and_then(|l| Arc::make_mut(l).children_mut()) {
Some(ch) => ch.extend(n),
None => loose.extend(n),
}
continue;
}
let mut children = std::mem::take(&mut loose);
children.extend(im.children(g, base * aff(g.transform())));
let id = im.doc.alloc_id();
let mut l = Node::layer(id, g.id(), LayerColor::Preset((i % vectorcraft_doc::LAYER_COLORS.len()) as u8));
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
let mut l = Node::layer(id, g.id(), LayerColor::Preset(preset));
if let Some(ch) = l.children_mut() {
*ch = children;
}
@ -61,13 +83,11 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
*ch = children;
}
}
text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, &units, layer_mode);
Ok((im.doc, im.warnings))
}
/// SVG's initial `font-size` (`medium`) in user units.
const DEFAULT_FONT_SIZE: f64 = 12.0;
pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
/// How lengths become points, from the root `<svg>`'s `width` / `height`.
///
@ -85,10 +105,8 @@ struct RootUnits {
}
impl RootUnits {
fn of(svg: &str) -> Self {
let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() });
let root = xml.as_ref().ok().map(|x| x.root_element());
let unit = |name: &str| root.and_then(|r| r.attribute(name)).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit);
fn of(root: XNode) -> Self {
let unit = |name: &str| root.attribute(name).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit);
use svgtypes::LengthUnit as U;
let physical = |u: Option<U>| matches!(u, Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc));
let (w, h) = (unit("width"), unit("height"));
@ -107,7 +125,7 @@ impl RootUnits {
}
/// Points per inch.
const PT_PER_IN: f64 = 72.0;
pub(super) const PT_PER_IN: f64 = 72.0;
/// CSS pixels per inch.
const CSS_PX_PER_IN: f64 = 96.0;
@ -118,18 +136,19 @@ struct Importer {
mask_flags: HashMap<String, (bool, bool)>,
/// The URL of each `<a href>`'s group, by its id ([`link_ids`]).
links: HashMap<String, String>,
/// Texts read from the XML, by placeholder ([`text`]).
slots: TextSlots,
/// Pattern swatch made for each usvg pattern.
patterns: HashMap<usize, String>,
}
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
/// (clip, invert).
fn mask_flags(svg: &str) -> HashMap<String, (bool, bool)> {
fn mask_flags(xml: &roxmltree::Document) -> HashMap<String, (bool, bool)> {
let attr = crate::export::MASK_FLAGS;
if !svg.contains(attr) {
if !xml.input_text().contains(attr) {
return HashMap::new();
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
return HashMap::new();
};
let flags = |n: roxmltree::Node| {
let has = |f: &str| n.attribute(attr).is_some_and(|v| v.split_whitespace().any(|x| x == f));
Some((n.attribute("id")?.to_string(), (!has("noclip"), has("invert"))))
@ -141,7 +160,7 @@ fn mask_flags(svg: &str) -> HashMap<String, (bool, bool)> {
const LINK_ID: &str = "vectorcraft-link-";
/// The URL an `<a>` element links to.
fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> {
pub(super) fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> {
a.attribute("href").or_else(|| a.attribute(("http://www.w3.org/1999/xlink", "href"))).filter(|h| !h.is_empty())
}
@ -293,16 +312,67 @@ impl Importer {
Some(n)
}
/// The text a placeholder group stands for (see [`text`]) and the index of its main path.
fn text_slot(&self, g: &usvg::Group) -> Option<(usize, usize)> {
g.children().iter().enumerate().find_map(|(k, c)| match c {
usvg::Node::Path(p) => self.slots.find(p).map(|i| (i, k)),
_ => None,
})
}
/// A text placeholder group → its text object (nothing when the text is hidden).
fn text(&mut self, g: &usvg::Group, ts: Affine) -> Option<Vec<Arc<Node>>> {
let (i, main) = self.text_slot(g)?;
let kids = g.children();
if !matches!(kids.get(main), Some(usvg::Node::Path(p)) if p.is_visible()) {
return Some(vec![]);
}
let text::PendingText { name, mut obj, servers, paints, .. } = self.slots.texts.get(i)?.clone();
// usvg resolved the `url(#…)` paints (the paths after the main one) in the element's user
// space; runs paint in text space.
let to_text = obj.xf.inverse();
let resolved: Vec<Paint> = (0..paints)
.map(|k| match kids.get(main + 1 + k) {
Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| self.paint(f.paint(), to_text)),
_ => Paint::None,
})
.collect();
let server = |k: usize| resolved.get(k).cloned().unwrap_or(Paint::None);
for (run, (fill, stroke)) in obj.runs.iter_mut().zip(servers) {
if let Some(k) = fill {
run.style.fill = server(k);
}
if let Some(k) = stroke {
run.style.stroke = server(k);
}
}
obj.xf = ts * obj.xf;
Some(vec![Arc::new(self.named(&name, NodeKind::Text(Box::new(obj))))])
}
fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
let ts = acc * aff(g.transform());
// A text placeholder becomes its text, named after the element (the group stays unnamed).
let text = self.text(g, ts);
if text.as_ref().is_some_and(Vec::is_empty) {
return None;
}
let is_text = text.is_some();
// The made-up ids of links aren't names.
let id = if g.id().starts_with(LINK_ID) { "" } else { g.id() };
let label = if id.is_empty() { "a group".to_string() } else { format!("'{id}'") };
let id = if is_text || g.id().starts_with(LINK_ID) { "" } else { g.id() };
let label = match (&text, id) {
(Some(t), _) => t.first().and_then(|n| n.name.as_deref()).map_or_else(|| "a text".to_string(), |n| format!("text '{n}'")),
(None, "") => "a group".to_string(),
(None, id) => format!("'{id}'"),
};
let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label));
if !g.filters().is_empty() {
self.warn(format!("filter on {label} ignored"));
}
let mut children = self.children(g, ts);
let mut children = match text {
Some(t) => t,
None => self.children(g, ts),
};
let mut n = if let Some(cp) = g.clip_path() {
if cp.clip_path().is_some() {
self.warn(format!("nested clip path on {label} approximated by its outer clip"));
@ -316,9 +386,8 @@ impl Importer {
return None;
}
// An id-less wrapper around a single object (usvg adds these for opacity/transform on
// shapes): fold its opacity and blend into the object.
if id.is_empty()
&& mask.is_none()
// shapes): fold its opacity and blend into the object. A text takes its own mask too.
if (is_text || id.is_empty() && mask.is_none())
&& children.len() == 1
&& (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal)
&& let Some(only) = children.pop()
@ -329,6 +398,9 @@ impl Importer {
c.blend = blend(g.blend_mode());
}
c.isolate |= g.isolate();
if mask.is_some() {
c.mask = mask;
}
return Some(c);
}
self.named(id, NodeKind::Group { children, clip: false })
@ -457,13 +529,43 @@ impl Importer {
geom.transform(m * aff(rg.transform()), GradientKind::Radial);
gp(GradientKind::Radial, stops(rg), geom)
}
usvg::Paint::Pattern(pt) => {
self.warn(format!("pattern '{}' not supported; painted as none", pt.id()));
Paint::None
}
usvg::Paint::Pattern(pt) => self.pattern(pt, m),
}
}
/// `<pattern>` → a pattern swatch painted with the pattern's tile placement. The swatch art is
/// the pattern content clipped to the tile, as SVG draws it.
fn pattern(&mut self, pt: &Arc<usvg::Pattern>, m: Affine) -> Paint {
let r = pt.rect();
let xf = m * aff(pt.transform()) * Affine::translate((r.x() as f64, r.y() as f64));
let key = Arc::as_ptr(pt) as usize;
if let Some(name) = self.patterns.get(&key) {
return Paint::Pattern { pattern: name.clone(), xf };
}
let tile = Rect::new(0.0, 0.0, r.width() as f64, r.height() as f64);
let content = self.children(pt.root(), aff(pt.root().transform()));
if content.is_empty() {
return Paint::None;
}
let clip =
self.named("", NodeKind::Path { path: shapes::rectangle(tile), rule: FillRule::NonZero, live: None, clipping: true, guide: false });
let mut children = vec![Arc::new(clip)];
children.extend(content);
let art = self.named("", NodeKind::Group { children, clip: true });
let base = if pt.id().is_empty() { "Pattern" } else { pt.id() };
let mut name = base.to_string();
let mut k = 2;
while self.doc.pattern(&name).is_some() {
name = format!("{base} {k}");
k += 1;
}
let mut def = PatternDef::new(&name, vec![Arc::new(art)]);
def.tile = tile;
self.doc.patterns.push(def);
self.patterns.insert(key, name.clone());
Paint::Pattern { pattern: name, xf }
}
fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) {
let mut fill = None;
let mut r = FillRule::NonZero;
@ -562,411 +664,3 @@ impl Importer {
Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None })))
}
}
// ---------------------------------------------------------------------------------------------
// <text> fallback: read text elements straight from the XML as live point type.
type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
struct TextCtx {
/// `.class` → declarations from `<style>` elements.
classes: HashMap<String, Vec<(String, String)>>,
/// Pixels per inch absolute lengths convert at ([`RootUnits::dpi`]).
dpi: f64,
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
Some((k.trim().to_string(), v.trim().trim_end_matches("!important").trim().to_string()))
})
.collect()
}
impl TextCtx {
fn new(doc: &roxmltree::Document, dpi: f64) -> Self {
let mut classes: HashMap<String, Vec<(String, String)>> = HashMap::new();
for st in doc.descendants().filter(|n| n.has_tag_name("style") || n.tag_name().name() == "style") {
let css: String = st.children().filter_map(|c| c.text()).collect();
for rule in css.split('}') {
let Some((sel, body)) = rule.split_once('{') else { continue };
let decls = parse_decls(body);
for s in sel.split(',') {
if let Some(c) = s.trim().strip_prefix('.')
&& c.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
classes.entry(c.to_string()).or_default().extend(decls.clone());
}
}
}
}
Self { classes, dpi }
}
/// A property on this element only: style attribute > class rule > presentation attribute.
fn own(&self, n: XNode, name: &str) -> Option<String> {
if let Some(st) = n.attribute("style")
&& let Some((_, v)) = parse_decls(st).into_iter().rev().find(|(k, _)| k == name)
{
return Some(v);
}
if let Some(cls) = n.attribute("class") {
for c in cls.split_whitespace().rev() {
if let Some((_, v)) = self.classes.get(c).and_then(|d| d.iter().rev().find(|(k, _)| k == name)) {
return Some(v.clone());
}
}
}
n.attribute(name).map(str::to_string)
}
/// An inherited property.
fn prop(&self, n: XNode, name: &str) -> Option<String> {
n.ancestors().filter(|a| a.is_element()).find_map(|a| self.own(a, name).filter(|v| v != "inherit"))
}
/// A length in user units: absolute units convert at [`Self::dpi`]; `em`, `ex` and percentages
/// are relative to `em` (the font size).
fn length(&self, v: &str, em: f64) -> Option<f64> {
use svgtypes::LengthUnit as L;
let l = svgtypes::Length::from_str(v.trim()).ok()?;
let n = l.number;
Some(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 * em,
})
}
/// The first length of a list attribute (`x="10 20"` → 10) in user units.
fn first_length(&self, s: Option<&str>, em: f64) -> f64 {
s.and_then(|s| s.split(|c: char| c.is_whitespace() || c == ',').find(|t| !t.is_empty())).and_then(|t| self.length(t, em)).unwrap_or(0.0)
}
/// Computed font size of an element in user units.
fn font_size(&self, n: XNode) -> f64 {
let parent = n.parent_element().map_or(DEFAULT_FONT_SIZE, |p| self.font_size(p));
self.own(n, "font-size").and_then(|v| self.parse_font_size(&v, parent)).unwrap_or(parent)
}
/// 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).filter(|s| *s > 0.0),
};
Some(DEFAULT_FONT_SIZE * k)
}
}
fn first_number(s: Option<&str>) -> f64 {
s.and_then(|s| s.split(|c: char| c.is_whitespace() || c == ',').find(|t| !t.is_empty()).map(str::to_string))
.and_then(|t| svgtypes::Length::from_str(&t).ok())
.map(|l| l.number)
.unwrap_or(0.0)
}
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)
}
fn parse_color_paint(v: &str) -> Paint {
let v = v.trim();
if v == "none" {
return Paint::None;
}
if let Ok(c) = svgtypes::Color::from_str(v) {
return Paint::solid(Color::rgb8(c.red, c.green, c.blue));
}
// url(#…) and other paints: fall back to black (or the fallback colour after the url).
if let Some(rest) = v.strip_prefix("url(").and_then(|r| r.split_once(')')).map(|(_, r)| r.trim())
&& let Ok(c) = svgtypes::Color::from_str(rest)
{
return Paint::solid(Color::rgb8(c.red, c.green, c.blue));
}
Paint::solid(Color::BLACK)
}
fn char_style(ctx: &TextCtx, n: XNode) -> CharStyle {
let mut st = CharStyle::default();
if let Some(f) = ctx.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;
}
}
st.size = ctx.font_size(n);
let bold = ctx.prop(n, "font-weight").is_some_and(|w| w == "bold" || w == "bolder" || w.parse::<u32>().is_ok_and(|v| v >= 600));
let italic = ctx.prop(n, "font-style").is_some_and(|s| s == "italic" || s == "oblique");
st.font_style = match (bold, italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
_ => "Regular",
}
.into();
if let Some(f) = ctx.prop(n, "fill") {
st.fill = parse_color_paint(&f);
}
if let Some(s) = ctx.prop(n, "stroke") {
st.stroke = parse_color_paint(&s);
st.stroke_width = ctx.prop(n, "stroke-width").map(|w| ctx.first_length(Some(&w), st.size)).unwrap_or(1.0);
st.stroke_cap = match ctx.prop(n, "stroke-linecap").as_deref() {
Some("round") => LineCap::Round,
Some("square") => LineCap::Square,
_ => LineCap::Butt,
};
st.stroke_join = match ctx.prop(n, "stroke-linejoin").as_deref() {
Some("round") => LineJoin::Round,
Some("bevel") => LineJoin::Bevel,
_ => LineJoin::Miter,
};
st.stroke_miter_limit = ctx.prop(n, "stroke-miterlimit").map_or(4.0, |m| first_number(Some(&m)).max(1.0));
let pattern: Vec<f64> =
ctx.prop(n, "stroke-dasharray").map_or(vec![], |d| d.split([' ', ',']).filter_map(|v| ctx.length(v, st.size)).collect());
let offset = ctx.prop(n, "stroke-dashoffset").map_or(0.0, |o| ctx.first_length(Some(&o), st.size));
st.stroke_dash = Some(Dash { pattern, offset, align_corners: false }).filter(Dash::is_dashed);
}
if let Some(ls) = ctx.prop(n, "letter-spacing") {
let v = ctx.first_length(Some(&ls), st.size);
if v != 0.0 && st.size > 0.0 {
st.tracking = v / st.size * 1000.0;
}
}
if let Some(d) = ctx.prop(n, "text-decoration") {
st.underline = d.contains("underline");
st.strikethrough = d.contains("line-through");
}
st
}
fn view_box_transform(root: XNode, w: f64, h: f64) -> Affine {
let Some(vb) = root.attribute("viewBox").and_then(|v| svgtypes::ViewBox::from_str(v).ok()) else { return Affine::IDENTITY };
if vb.w <= 0.0 || vb.h <= 0.0 {
return Affine::IDENTITY;
}
let ar = root.attribute("preserveAspectRatio").and_then(|v| svgtypes::AspectRatio::from_str(v).ok()).unwrap_or_default();
let (sx, sy) = (w / vb.w, h / vb.h);
use svgtypes::Align as A;
if ar.align == A::None {
return Affine::scale_non_uniform(sx, sy) * Affine::translate((-vb.x, -vb.y));
}
let s = if ar.slice { sx.max(sy) } else { sx.min(sy) };
let (fx, fy) = match ar.align {
A::XMinYMin => (0.0, 0.0),
A::XMidYMin => (0.5, 0.0),
A::XMaxYMin => (1.0, 0.0),
A::XMinYMid => (0.0, 0.5),
A::XMinYMax => (0.0, 1.0),
A::XMidYMax => (0.5, 1.0),
A::XMaxYMid => (1.0, 0.5),
A::XMaxYMax => (1.0, 1.0),
A::XMidYMid | A::None => (0.5, 0.5),
};
Affine::translate(((w - vb.w * s) * fx, (h - vb.h * s) * fy)) * Affine::scale(s) * Affine::translate((-vb.x, -vb.y))
}
/// Most line breaks one absolutely positioned `<tspan>` adds before itself.
const MAX_TSPAN_LINE_GAP: f64 = 10_000.0;
struct RunBuilder {
runs: Vec<TextRun>,
last_y: f64,
preserve: bool,
}
impl RunBuilder {
fn push(&mut self, text: &str, style: CharStyle) {
if text.is_empty() {
return;
}
if let Some(l) = self.runs.last_mut()
&& l.style == style
{
l.text.push_str(text);
return;
}
self.runs.push(TextRun { text: text.into(), style });
}
fn newline(&mut self, n: usize) {
if let Some(l) = self.runs.last_mut() {
for _ in 0..n {
l.text.push('\n');
}
}
}
}
fn collect_runs(ctx: &TextCtx, el: XNode, rb: &mut RunBuilder) {
for c in el.children() {
if c.is_text() {
let raw = c.text().unwrap_or("");
let t = if rb.preserve {
raw.replace(['\n', '\r', '\t'], " ")
} else {
let s: String = raw.chars().filter(|c| *c != '\n' && *c != '\r').map(|c| if c == '\t' { ' ' } else { c }).collect();
let mut out = String::new();
for ch in s.chars() {
if ch == ' '
&& (out.ends_with(' ') || (out.is_empty() && rb.runs.last().is_none_or(|r| r.text.ends_with(' ') || r.text.ends_with('\n'))))
{
continue;
}
out.push(ch);
}
out
};
rb.push(&t, char_style(ctx, el));
} else if c.is_element() && matches!(c.tag_name().name(), "tspan" | "textPath" | "a") {
if ctx.own(c, "display").as_deref() == Some("none") {
continue;
}
if !rb.runs.is_empty() {
let st = char_style(ctx, c);
let lead = st.effective_leading().max(1e-6);
if let Some(y) = c.attribute("y") {
let y = ctx.first_length(Some(y), st.size);
// One line break per line of baseline gap: none for a tspan on the same baseline
// (a styled or kerned run of the same line), at least one for a tspan placed
// higher up, and capped so that a far-off (or non-finite) y can't ask for billions.
let gap = ((y - rb.last_y) / lead).round();
let n = if !gap.is_finite() {
1
} else if (y - rb.last_y).abs() < lead * 0.5 {
0
} else {
gap.clamp(1.0, MAX_TSPAN_LINE_GAP) as usize
};
rb.newline(n);
rb.last_y = y;
} else if let Some(dy) = Some(ctx.first_length(c.attribute("dy"), st.size)).filter(|dy| *dy > 1e-9) {
rb.last_y += dy;
rb.newline(1);
}
}
collect_runs(ctx, c, rb);
}
}
}
fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, units: &RootUnits, layer_mode: bool) {
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { return };
let ctx = TextCtx::new(&xml, units.dpi);
let root = xml.root_element();
let vb = Affine::scale_non_uniform(units.k.0, units.k.1) * view_box_transform(root, w, h);
const SKIP: [&str; 8] = ["defs", "clipPath", "mask", "pattern", "symbol", "marker", "title", "desc"];
for t in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "text") {
if t.ancestors().skip(1).any(|a| a.is_element() && SKIP.contains(&a.tag_name().name())) {
continue;
}
if t.ancestors().filter(|a| a.is_element()).any(|a| ctx.own(a, "display").as_deref() == Some("none")) {
continue;
}
if ctx.prop(t, "visibility").is_some_and(|v| v == "hidden" || v == "collapse") {
continue;
}
// Transform: viewBox, then each ancestor's transform from the root down.
let mut acc = vb;
let chain: Vec<XNode> = t.ancestors().filter(|a| a.is_element()).collect();
for a in chain.iter().rev() {
if *a != root {
acc *= parse_transform(a.attribute("transform"));
}
}
let em = ctx.font_size(t);
let y0 = ctx.first_length(t.attribute("y"), em);
let xf = acc * Affine::translate((ctx.first_length(t.attribute("x"), em), y0));
let preserve = t.ancestors().filter(|a| a.is_element()).find_map(|a| a.attribute((roxmltree::NS_XML_URI, "space"))) == Some("preserve");
// tspan y values are absolute in the text's user space, like the text's own y.
let mut rb = RunBuilder { runs: Vec::new(), last_y: y0, preserve };
collect_runs(&ctx, t, &mut rb);
if !preserve {
if let Some(f) = rb.runs.first_mut() {
f.text = f.text.trim_start().to_string();
}
if let Some(l) = rb.runs.last_mut() {
l.text = l.text.trim_end().to_string();
}
rb.runs.retain(|r| !r.text.is_empty());
}
if rb.runs.is_empty() {
continue;
}
// Type on a path: a <textPath> naming a <path> (in the text's user space) by href.
let on_path = t.children().find(|c| c.is_element() && c.tag_name().name() == "textPath").and_then(|tp| {
let href = tp.attribute("href").or_else(|| tp.attribute(("http://www.w3.org/1999/xlink", "href")))?;
let id = href.strip_prefix('#')?;
let el = xml.descendants().find(|n| n.is_element() && n.tag_name().name() == "path" && n.attribute("id") == Some(id))?;
let mut bp = BezPath::from_svg(el.attribute("d")?).ok()?;
bp.apply_affine(acc * parse_transform(el.attribute("transform")));
let start = match tp.attribute("startOffset").map(str::trim) {
Some(o) if o.ends_with('%') => first_number(o.strip_suffix('%')) / 100.0,
Some(o) => {
let len: f64 = bp.segments().map(|s| kurbo::ParamCurveArclen::arclen(&s, 1e-3)).sum();
if len > 0.0 { ctx.first_length(Some(o), em) / len } else { 0.0 }
}
None => 0.0,
};
Some((PathData::from_bezpath(&bp), start.clamp(0.0, 1.0)))
});
let justify = match ctx.prop(t, "text-anchor").as_deref() {
Some("middle") => Justify::Center,
Some("end") => Justify::Right,
_ => Justify::Left,
};
let (kind, xf) = match on_path {
Some((path, start)) => (TextKind::OnPath { path, start }, Affine::IDENTITY),
None => (TextKind::Point, xf),
};
let mut obj = TextObject {
kind,
xf,
runs: rb.runs,
para: Default::default(),
area: Default::default(),
path_effect: Default::default(),
wrap: Vec::new(),
cached_bounds: None,
};
obj.para.justify = justify;
let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj)));
if let Some(url) = chain.iter().filter(|a| a.tag_name().name() == "a").find_map(|a| href(*a)) {
link(&mut node, url);
}
if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::<f32>().ok()) {
node.opacity = o.clamp(0.0, 1.0);
}
// Place on the layer made from the top-level <g> containing the text, if any.
let top_id = if layer_mode { chain.iter().rev().nth(1).filter(|g| g.tag_name().name() == "g").and_then(|g| g.attribute("id")) } else { None };
let li = top_id.and_then(|id| im.doc.layers.iter().position(|l| l.name.as_deref() == Some(id)));
let li = match (li, top_id) {
(Some(i), _) => i,
(None, Some(id)) => {
im.doc.add_layer(Some(id));
im.doc.layers.len() - 1
}
(None, None) => im.doc.layers.len().saturating_sub(1),
};
if im.doc.layers.is_empty() {
im.doc.add_layer(Some("Layer 1"));
}
if let Some(ch) = Arc::make_mut(&mut im.doc.layers[li]).children_mut() {
ch.push(Arc::new(node));
}
}
}

View File

@ -0,0 +1,356 @@
//! The style cascade for the `<text>` pre-pass: `<style>` rules, the `style` attribute and
//! presentation attributes.
//!
//! Selectors: type, `*`, `#id`, any number of `.class`es and `[attr]` / `[attr=value]`, combined by
//! descendant (space) and child (`>`) combinators. Rules with other selectors (pseudo-classes,
//! sibling combinators, namespaces) never match, and at-rule blocks (`@media`, `@font-face`…) are
//! skipped. Precedence: `!important`, then the `style` attribute, then specificity, then source
//! order; a presentation attribute applies only when no rule sets the property.
use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;
use usvg::roxmltree;
pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag.
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
let v = v.trim();
let (v, important) = match v.strip_suffix("important").map(str::trim_end).and_then(|v| v.strip_suffix('!')) {
Some(v) => (v.trim_end(), true),
None => (v, false),
};
Some((k.trim().to_ascii_lowercase(), v.to_string(), important))
})
.collect()
}
/// One compound selector (`text.a.b#c[x=y]`).
#[derive(Debug, Default)]
struct Compound {
tag: Option<String>,
id: Option<String>,
classes: Vec<String>,
attrs: Vec<(String, Option<String>)>,
}
impl Compound {
fn matches(&self, n: XNode) -> bool {
self.tag.as_deref().is_none_or(|t| n.tag_name().name() == t)
&& self.id.as_deref().is_none_or(|id| n.attribute("id") == Some(id))
&& (self.classes.is_empty() || n.attribute("class").is_some_and(|c| self.classes.iter().all(|k| c.split_whitespace().any(|x| x == k))))
&& self.attrs.iter().all(|(a, v)| n.attribute(a.as_str()).is_some_and(|x| v.as_deref().is_none_or(|v| x == v)))
}
}
/// A complex selector, rightmost compound first; each compound's flag says whether it must be the
/// child (true) or a descendant (false) of the next one.
#[derive(Debug)]
struct Selector {
parts: Vec<(Compound, bool)>,
/// (ids, classes and attributes, types).
specificity: (u32, u32, u32),
}
fn is_ident(c: char) -> bool {
c.is_alphanumeric() || c == '-' || c == '_'
}
impl Selector {
/// `None` for selectors this cascade doesn't support (they never match).
fn parse(s: &str) -> Option<Self> {
fn ident(chars: &mut std::iter::Peekable<std::str::Chars>) -> Option<String> {
let mut out = String::new();
while let Some(&c) = chars.peek().filter(|c| is_ident(**c)) {
out.push(c);
chars.next();
}
(!out.is_empty()).then_some(out)
}
// Compounds left to right; `links[i]` joins compounds i and i + 1 (true = child).
let mut comps: Vec<Compound> = vec![];
let mut links: Vec<bool> = vec![];
let mut cur: Option<Compound> = None;
let mut link: Option<bool> = None;
let mut chars = s.trim().chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '>' {
chars.next();
comps.extend(cur.take());
if comps.is_empty() {
return None;
}
link = Some(c == '>' || link == Some(true));
continue;
}
let comp = match &mut cur {
Some(comp) => comp,
None => {
if !comps.is_empty() {
links.push(link.take()?);
}
cur.insert(Compound::default())
}
};
match c {
'*' => {
chars.next();
}
'#' => {
chars.next();
comp.id = Some(ident(&mut chars)?);
}
'.' => {
chars.next();
comp.classes.push(ident(&mut chars)?);
}
'[' => {
chars.next();
let body: String = chars.by_ref().take_while(|c| *c != ']').collect();
let (name, value) = match body.split_once('=') {
Some((n, v)) => (n.trim(), Some(v.trim().trim_matches(|c| c == '"' || c == '\'').to_string())),
None => (body.trim(), None),
};
if name.is_empty() || !name.chars().all(is_ident) {
return None;
}
comp.attrs.push((name.to_string(), value));
}
c if is_ident(c) => comp.tag = Some(ident(&mut chars)?),
_ => return None,
}
}
// A trailing `>` has nothing to apply to.
if cur.is_none() && link == Some(true) {
return None;
}
comps.extend(cur);
if comps.is_empty() {
return None;
}
let mut specificity = (0, 0, 0);
for c in &comps {
specificity.0 += c.id.is_some() as u32;
specificity.1 += (c.classes.len() + c.attrs.len()) as u32;
specificity.2 += c.tag.is_some() as u32;
}
// Rightmost first, each with its link to the compound on its left.
let parts = comps.into_iter().enumerate().rev().map(|(i, c)| (c, i.checked_sub(1).and_then(|k| links.get(k)).is_some_and(|l| *l))).collect();
Some(Self { parts, specificity })
}
fn matches(&self, n: XNode) -> bool {
fn go(parts: &[(Compound, bool)], n: XNode) -> bool {
let Some(((c, child), rest)) = parts.split_first() else { return true };
if !c.matches(n) {
return false;
}
if rest.is_empty() {
return true;
}
if *child {
n.parent_element().is_some_and(|p| go(rest, p))
} else {
n.ancestors().skip(1).filter(|a| a.is_element()).any(|a| go(rest, a))
}
}
go(&self.parts, n)
}
}
struct Rule {
selector: Selector,
decls: Rc<Vec<(String, String, bool)>>,
}
/// Rules from every `<style>` element, in source order.
fn parse_sheet(css: &str, out: &mut Vec<Rule>) {
// Drop comments.
let mut text = String::with_capacity(css.len());
let mut rest = css;
while let Some(i) = rest.find("/*") {
text.push_str(&rest[..i]);
rest = rest[i + 2..].find("*/").map_or("", |j| &rest[i + 2 + j + 2..]);
}
text.push_str(rest);
let mut s = text.as_str();
loop {
s = s.trim_start();
if s.is_empty() {
break;
}
let Some(open) = s.find('{') else { break };
let head = s[..open].trim();
if head.starts_with('@') {
// Statement at-rules end at `;` before any block.
if let Some(semi) = s.find(';').filter(|&semi| semi < open) {
s = &s[semi + 1..];
continue;
}
// Skip the (possibly nested) block.
let mut depth = 0usize;
let mut end = s.len();
for (i, c) in s[open..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = open + i + 1;
break;
}
}
_ => {}
}
}
s = &s[end..];
continue;
}
let close = s[open..].find('}').map_or(s.len(), |i| open + i);
let decls = Rc::new(parse_decls(&s[open + 1..close]));
for sel in head.split(',') {
if let Some(selector) = Selector::parse(sel) {
out.push(Rule { selector, decls: decls.clone() });
}
}
s = s.get(close + 1..).unwrap_or("");
}
}
/// The cascade over one parsed document.
pub(super) struct Styles {
rules: Vec<Rule>,
/// Declared properties of each element queried so far.
cache: RefCell<HashMap<roxmltree::NodeId, Rc<HashMap<String, String>>>>,
}
impl Styles {
pub(super) fn new(doc: &roxmltree::Document) -> Self {
let mut rules = vec![];
for st in doc.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
if st.attribute("type").is_some_and(|t| !t.trim().is_empty() && t.trim() != "text/css") {
continue;
}
let css: String = st.children().filter_map(|c| c.text()).collect();
parse_sheet(&css, &mut rules);
}
Self { rules, cache: RefCell::default() }
}
/// Properties set on `n` by rules and its `style` attribute, after the cascade.
fn declared(&self, n: XNode) -> Rc<HashMap<String, String>> {
if let Some(m) = self.cache.borrow().get(&n.id()) {
return m.clone();
}
// (important, inline, specificity, order) → highest wins.
type Key = (bool, bool, (u32, u32, u32), usize);
let mut best: HashMap<String, (Key, String)> = HashMap::new();
let mut put = |name: &str, value: &str, key: Key| {
if best.get(name).is_none_or(|(k, _)| key >= *k) {
best.insert(name.to_string(), (key, value.to_string()));
}
};
let mut order = 0;
for r in self.rules.iter().filter(|r| r.selector.matches(n)) {
for (k, v, imp) in r.decls.iter() {
order += 1;
put(k, v, (*imp, false, r.selector.specificity, order));
}
}
if let Some(st) = n.attribute("style") {
for (k, v, imp) in parse_decls(st) {
order += 1;
put(&k, &v, (imp, true, (0, 0, 0), order));
}
}
let m: Rc<HashMap<String, String>> = Rc::new(best.into_iter().map(|(k, (_, v))| (k, v)).collect());
self.cache.borrow_mut().insert(n.id(), m.clone());
m
}
/// A property specified on this element (rules and `style` beat the presentation attribute).
pub(super) fn own(&self, n: XNode, name: &str) -> Option<String> {
self.declared(n).get(name).cloned().or_else(|| n.attribute(name).map(|v| v.trim().to_string()))
}
/// An inherited property: the nearest specified value other than `inherit`.
pub(super) fn prop(&self, n: XNode, name: &str) -> Option<String> {
n.ancestors().filter(|a| a.is_element()).find_map(|a| self.own(a, name).filter(|v| v != "inherit"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_doc(svg: &str, f: impl FnOnce(&roxmltree::Document, &Styles)) {
let doc = roxmltree::Document::parse(svg).unwrap();
let st = Styles::new(&doc);
f(&doc, &st);
}
fn by_id<'a, 'i>(doc: &'a roxmltree::Document<'i>, id: &str) -> XNode<'a, 'i> {
doc.descendants().find(|n| n.attribute("id") == Some(id)).unwrap()
}
#[test]
fn selectors_and_precedence() {
with_doc(
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>
/* comment { fill: red } */
@media print { text { fill: red } }
@import url(x.css);
text { fill: #111; font-size: 9px }
g text { fill: #222 }
g > text.a { fill: #333 }
.a.b { stroke: blue }
#t2 { fill: #444 }
[data-k=v] { font-weight: bold }
text:first-child, text ~ text { fill: red }
.imp { fill: #555 !important }
</style>
<text id="t0">x</text>
<g><text id="t1" class="a b" fill="green">x</text>
<g><text id="t2" class="a" style="fill:#666">x</text></g>
<text id="t3" class="a imp" style="fill:#777" data-k="v">x</text></g>
</svg>"#,
|doc, st| {
assert_eq!(st.own(by_id(doc, "t0"), "fill").as_deref(), Some("#111"));
assert_eq!(st.own(by_id(doc, "t1"), "fill").as_deref(), Some("#333"), "child + class beats descendant");
assert_eq!(st.own(by_id(doc, "t1"), "stroke").as_deref(), Some("blue"), "multiple classes");
assert_eq!(st.own(by_id(doc, "t2"), "fill").as_deref(), Some("#666"), "style attribute beats #id");
assert_eq!(st.own(by_id(doc, "t3"), "fill").as_deref(), Some("#555"), "!important beats style");
assert_eq!(st.own(by_id(doc, "t3"), "font-weight").as_deref(), Some("bold"));
assert_eq!(st.own(by_id(doc, "t2"), "stroke"), None, "`.a.b` needs both classes");
assert_eq!(st.prop(by_id(doc, "t2"), "font-size").as_deref(), Some("9px"));
},
);
}
#[test]
fn presentation_attributes_lose_to_rules() {
with_doc(
r#"<svg xmlns="http://www.w3.org/2000/svg"><style>#a { fill: blue }</style><text id="a" fill="red" stroke="red">x</text></svg>"#,
|doc, st| {
assert_eq!(st.own(by_id(doc, "a"), "fill").as_deref(), Some("blue"));
assert_eq!(st.own(by_id(doc, "a"), "stroke").as_deref(), Some("red"));
},
);
}
#[test]
fn unsupported_selectors_are_dropped() {
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {
assert!(Selector::parse(s).is_none(), "{s}");
}
let s = Selector::parse("g > text.x#y").unwrap();
assert_eq!(s.specificity, (1, 1, 2));
assert_eq!(s.parts.len(), 2);
assert!(s.parts[0].1, "text must be a child of g");
}
}

View File

@ -0,0 +1,799 @@
//! `<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};
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,
/// 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();
Some((PendingText { name, obj, servers, paints: paints.len(), 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)
}

View File

@ -33,9 +33,22 @@
//! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and
//! not inverted). Filters are ignored (reported as warnings); nested clip paths use the outer
//! clip only.
//! * usvg only keeps `<text>` when fonts are loaded; we don't load a font database (too expensive and
//! unavailable on wasm), so `<text>` elements are read directly from the XML as live point-type
//! [`TextObject`]s (type on a path for a `<textPath>`) and placed on top of their layer.
//! * `<pattern>` becomes a pattern swatch (its content clipped to the tile) painted with the
//! pattern's placement.
//! * Text lines after the first start at the first line's x; text in a clip path is ignored;
//! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways.
//!
//! ## Import text
//!
//! usvg only keeps `<text>` when fonts are loaded; we don't load a font database (too expensive and
//! unavailable on wasm), so `<text>` elements are read from the XML as live [`TextObject`]s. Each
//! one is swapped for a placeholder before usvg runs, so the text keeps its z-order, parent group,
//! clip, mask, opacity, link, `<use>` instances and gradient or pattern paints. The style cascade
//! supports type, `#id`, `.class` (several), attribute, descendant and child selectors.
//! `<textPath>` becomes type on a path (`startOffset`, `side`), vertical `writing-mode` type on a
//! vertical path; per-character `x`/`y`/`dx`/`dy`/`rotate`, `baseline-shift`, numeric weights,
//! letter and word spacing become character attributes (line breaks, kerning, baseline shift,
//! rotation and style names).
#![forbid(unsafe_code)]
mod export;

View File

@ -0,0 +1,377 @@
//! SVG `<text>` import fidelity: placement in the tree, type on a path, per-character positions,
//! paints and the style cascade.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{GradientKind, Paint};
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeKind, TextKind, TextObject};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_svg::{import, import_with_report};
use vectorcraft_text::{FontDb, layout};
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">{body}</svg>"#)
}
fn open(body: &str) -> Document {
import(&svg(body)).unwrap_or_else(|e| panic!("{e}"))
}
/// All non-layer nodes in paint order.
fn art(d: &Document) -> Vec<&Node> {
let mut v = Vec::new();
d.walk(|n| {
if !n.is_layer() {
v.push(n)
}
});
v
}
fn texts(d: &Document) -> Vec<&TextObject> {
art(d)
.into_iter()
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(&**t),
_ => None,
})
.collect()
}
fn only_text(d: &Document) -> &TextObject {
let t = texts(d);
assert_eq!(t.len(), 1, "{:?}", art(d).iter().map(|n| &n.kind).collect::<Vec<_>>());
t[0]
}
/// Document-space pen position of each glyph.
fn glyph_origins(t: &TextObject) -> Vec<Point> {
layout(FontDb::global(), t).glyphs.iter().map(|g| t.xf * g.origin).collect()
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn text_keeps_its_z_order_between_shapes() {
let d = open(r#"<rect width="10" height="10"/><text x="5" y="20">Hi</text><rect y="30" width="10" height="10"/>"#);
let kinds: Vec<&str> = art(&d)
.iter()
.map(|n| match n.kind {
NodeKind::Text(_) => "text",
NodeKind::Path { .. } => "path",
_ => "other",
})
.collect();
assert_eq!(kinds, ["path", "text", "path"]);
let t = only_text(&d);
assert_eq!(t.plain_text(), "Hi");
assert!(t.xf.translation().to_point().distance(Point::new(5.0, 20.0)) < 1e-9);
}
#[test]
fn text_keeps_its_parent_group_clip_and_mask() {
let d = open(
r##"<defs><clipPath id="c"><rect width="50" height="50"/></clipPath>
<mask id="m"><rect width="100" height="100" fill="white"/></mask></defs>
<g id="outer" transform="translate(10 20)"><rect width="5" height="5"/>
<g clip-path="url(#c)"><text id="clipped" y="30">In clip</text></g>
<text id="own" y="60" clip-path="url(#c)" opacity="0.5">Own clip</text>
<text id="masked" y="90" mask="url(#m)">Masked</text>
</g><rect width="5" height="5"/>"##,
);
// Two top-level children (a group and a rect): no layers, everything stays on Layer 1.
assert_eq!(d.layers.len(), 1);
let top = d.layers[0].children().unwrap();
assert_eq!(top.len(), 2);
assert_eq!(top[0].name.as_deref(), Some("outer"));
let inner = top[0].children().unwrap();
assert_eq!(inner.len(), 4, "rect, clip group, clip group, masked text");
// `<g clip-path>` around the text → a clip group holding the clip path and the text.
let NodeKind::Group { children, clip: true } = &inner[1].kind else { panic!("{:?}", inner[1].kind) };
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
let NodeKind::Text(t) = &children[1].kind else { panic!() };
assert_eq!(children[1].name.as_deref(), Some("clipped"));
assert!(t.xf.translation().to_point().distance(Point::new(10.0, 50.0)) < 1e-9, "{:?}", t.xf);
// The text's own clip-path and opacity.
let NodeKind::Group { children, clip: true } = &inner[2].kind else { panic!("{:?}", inner[2].kind) };
assert!((inner[2].opacity - 0.5).abs() < 1e-6);
assert!(matches!(children[1].kind, NodeKind::Text(_)) && children[1].name.as_deref() == Some("own"));
// The text's own mask.
assert!(matches!(inner[3].kind, NodeKind::Text(_)));
assert!(inner[3].mask.is_some());
assert_eq!(inner[3].name.as_deref(), Some("masked"));
}
#[test]
fn top_level_text_joins_the_layer_below_it() {
let d = open(r#"<g id="Back"><rect width="5" height="5"/></g><text y="20">Note</text><g id="Front"><rect width="5" height="5"/></g>"#);
assert_eq!(d.layers.iter().map(|l| l.name.as_deref().unwrap()).collect::<Vec<_>>(), ["Back", "Front"]);
let back = d.layers[0].children().unwrap();
assert_eq!(back.len(), 2);
assert!(matches!(back[1].kind, NodeKind::Text(_)));
// Text before the first layer goes to its bottom.
let d = open(r#"<text y="20">First</text><g id="Only"><rect width="5" height="5"/></g>"#);
let only = d.layers[0].children().unwrap();
assert!(matches!(only[0].kind, NodeKind::Text(_)) && matches!(only[1].kind, NodeKind::Path { .. }));
}
#[test]
fn hidden_text_is_skipped_and_use_instances_are_kept() {
let d = open(
r##"<text y="10" display="none">gone</text><g visibility="hidden"><text y="20">hidden</text></g>
<defs><text id="t" y="30">Used</text></defs><use href="#t" x="10"/><use xlink:href="#t" x="50"/>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 2);
assert!(t.iter().all(|t| t.plain_text() == "Used"));
assert!(t[0].xf.translation().to_point().distance(Point::new(10.0, 30.0)) < 1e-9);
assert!(t[1].xf.translation().to_point().distance(Point::new(50.0, 30.0)) < 1e-9);
}
#[test]
fn text_path_becomes_type_on_a_path() {
let d = open(
r##"<path id="p" d="M10 100 L210 100" fill="none" stroke="black"/>
<text font-size="10"><textPath href="#p" startOffset="25%">Curve</textPath></text>
<text font-size="10"><textPath xlink:href="#p" startOffset="20">Twenty</textPath></text>
<text font-size="10" text-anchor="middle"><textPath href="#p" startOffset="50%">Mid</textPath></text>"##,
);
let t = texts(&d);
assert_eq!(t.len(), 3);
let TextKind::OnPath { path, start } = &t[0].kind else { panic!("{:?}", t[0].kind) };
assert!(close(*start, 0.25, 1e-9));
let b = t[0].xf.transform_rect_bbox(path.bounds().unwrap());
assert!(close(b.x0, 10.0, 1e-6) && close(b.x1, 210.0, 1e-6) && close(b.y0, 100.0, 1e-6), "{b:?}");
assert_eq!(t[0].plain_text(), "Curve");
let TextKind::OnPath { start, .. } = &t[1].kind else { panic!() };
assert!(close(*start, 0.1, 1e-6), "{start}");
// Anchored in the middle: the text starts half its width before the offset.
let TextKind::OnPath { start, .. } = &t[2].kind else { panic!() };
let first = glyph_origins(t[2])[0];
let width: f64 = layout(FontDb::global(), t[2]).glyphs.iter().map(|g| g.advance).sum();
assert!(*start < 0.5 && close(first.x + width / 2.0, 110.0, 0.5), "{start} {first:?} {width}");
assert_eq!(t[2].para.justify, Justify::Left);
}
#[test]
fn per_character_positions_rotation_and_shifts() {
let d = open(r#"<text x="10 30 60" y="50" rotate="15 -30" dy="0 -4" font-size="12">abcd</text>"#);
let t = only_text(&d);
assert_eq!(t.plain_text(), "abcd");
let o = glyph_origins(t);
assert!(close(o[0].x, 10.0, 0.02) && close(o[1].x, 30.0, 0.02) && close(o[2].x, 60.0, 0.02), "{o:?}");
// `d` follows `c` at its natural advance.
assert!(o[3].x > 60.0 && o[3].x < 70.0, "{o:?}");
let chars: Vec<CharStyle> = t.runs.iter().flat_map(|r| r.text.chars().map(|_| r.style.clone())).collect();
let rot: Vec<f64> = chars.iter().map(|s| s.rotation).collect();
assert_eq!(rot, [-15.0, 30.0, 30.0, 30.0], "the last rotate value repeats");
let shift: Vec<f64> = chars.iter().map(|s| s.baseline_shift).collect();
assert_eq!(shift, [0.0, 4.0, 4.0, 4.0], "dy accumulates as baseline shift");
}
#[test]
fn per_character_positions_with_middle_anchor() {
// SVG anchors each absolutely placed chunk on its own: `a` is centred on 50, `b` on 100.
let d = open(r#"<text x="50 100" y="40" text-anchor="middle" font-size="20">ab</text>"#);
let t = only_text(&d);
let lay = layout(FontDb::global(), t);
// Natural advance: the kerning that moves `b` onto its x is added after `a`.
let natural = |i: usize| lay.glyphs[i].advance - t.runs[i].style.kerning.unwrap_or(0.0) / 1000.0 * t.runs[i].style.size;
let mid = |i: usize| (t.xf * lay.glyphs[i].origin).x + natural(i) / 2.0;
assert_eq!(t.runs.len(), 2);
assert!(close(mid(0), 50.0, 0.05) && close(mid(1), 100.0, 0.05), "{} {}", mid(0), mid(1));
}
#[test]
fn tspan_lines_dx_and_baseline_shift() {
let d = open(
r#"<text x="10" y="20" font-size="10">One<tspan x="10" dy="20">Two</tspan><tspan x="10" dy="12">Three</tspan>
<tspan dx="5">four</tspan><tspan baseline-shift="super">2</tspan><tspan baseline-shift="-3">x</tspan></text>"#,
);
let t = only_text(&d);
assert_eq!(t.plain_text(), "One\nTwo\nThree four2x");
let lay = layout(FontDb::global(), t);
let base: Vec<f64> = lay.lines.iter().map(|l| (t.xf * Point::new(0.0, l.baseline)).y).collect();
assert!(close(base[0], 20.0, 1e-6) && close(base[1], 40.0, 1e-6) && close(base[2], 52.0, 1e-6), "{base:?}");
let run = |s: &str| t.runs.iter().find(|r| r.text.contains(s)).unwrap().style.clone();
assert_eq!(run("Two").leading, Some(20.0));
assert_eq!(run("Three").leading, None, "12 pt is the auto leading of 10 pt type");
assert!(close(run("2").baseline_shift, 4.0, 1e-9) && close(run("x").baseline_shift, -3.0, 1e-9));
// dx on "four": the space before it gets 5 pt of kerning.
let sp = t.runs.iter().find(|r| r.text.ends_with(' ')).unwrap();
assert!(close(sp.style.kerning.unwrap(), 500.0, 1e-6), "{:?}", t.runs);
}
#[test]
fn weights_spacing_and_decoration() {
let d = open(
r#"<text y="20" font-size="10" font-weight="300" letter-spacing="1" word-spacing="2">a b<tspan font-weight="600">c</tspan><tspan font-weight="bolder">d</tspan><tspan font-weight="900" font-style="italic">e</tspan><tspan font-style="italic">f</tspan><tspan text-decoration="underline" font-weight="normal">g</tspan></text>"#,
);
let t = only_text(&d);
let style = |c: char| t.runs.iter().find(|r| r.text.contains(c)).unwrap().style.clone();
assert_eq!(style('a').font_style, "Light");
assert_eq!(style('c').font_style, "SemiBold");
assert_eq!(style('d').font_style, "Regular", "bolder than 300 is 400");
assert_eq!(style('e').font_style, "Black Italic");
assert_eq!(style('f').font_style, "Light Italic");
assert!(style('g').underline && style('g').font_style == "Regular");
assert!(close(style('a').tracking, 100.0, 1e-9));
assert!(close(style(' ').kerning.unwrap(), 200.0, 1e-9), "word spacing kerns spaces");
assert_eq!(style('a').kerning, None);
}
#[test]
fn css_selectors_reach_text() {
let d = open(
r#"<style>
text { font-family: Inter }
g text { fill: #ff0000 }
#big { font-size: 30px }
.a.b { fill: #00ff00 }
g > text.only-child { fill: #0000ff }
</style>
<text id="plain" y="10">p</text>
<g><text id="big" y="40">g</text><text class="a b" y="80">ab</text><text class="a" y="90">a</text><text class="only-child" y="100">c</text></g>"#,
);
let t = texts(&d);
let fill = |i: usize| t[i].first_style().fill.color().map(|c| c.to_hex());
assert_eq!(t[0].first_style().font_family, "Inter");
assert_eq!(fill(0).as_deref(), Some("#000000"));
assert_eq!(fill(1).as_deref(), Some("#ff0000"), "descendant selector");
assert!(close(t[1].first_style().size, 30.0, 1e-9), "#id selector");
assert_eq!(fill(2).as_deref(), Some("#00ff00"), "multiple classes");
assert_eq!(fill(3).as_deref(), Some("#ff0000"), "one of two classes doesn't match .a.b");
assert_eq!(fill(4).as_deref(), Some("#0000ff"), "child combinator");
}
#[test]
fn gradient_fill_resolves_in_text_space() {
let d = open(
r##"<defs>
<linearGradient id="user" gradientUnits="userSpaceOnUse" x1="20" y1="0" x2="120" y2="0"><stop offset="0" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient>
<linearGradient id="box"><stop offset="0" stop-color="#0f0"/><stop offset="1" stop-color="#000"/></linearGradient>
<radialGradient id="r"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#000"/></radialGradient>
</defs>
<text x="20" y="50" font-size="20" fill="url(#user)" stroke="url(#r)">Gradient<tspan fill="url(#box)">Box</tspan></text>"##,
);
let t = only_text(&d);
let Paint::Gradient(g) = &t.runs[0].style.fill else { panic!("{:?}", t.runs[0].style.fill) };
assert_eq!(g.gradient.kind, GradientKind::Linear);
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
// Text space has its origin at the text's x/y.
let geom = g.geom.unwrap();
assert!(geom.start.distance(Point::new(0.0, -50.0)) < 1e-6 && geom.end.distance(Point::new(100.0, -50.0)) < 1e-6, "{geom:?}");
let Paint::Gradient(s) = &t.runs[0].style.stroke else { panic!("{:?}", t.runs[0].style.stroke) };
assert_eq!(s.gradient.kind, GradientKind::Radial);
// objectBoundingBox: the gradient spans the laid-out text.
let Paint::Gradient(b) = &t.runs[1].style.fill else { panic!("{:?}", t.runs[1].style.fill) };
let bounds = layout(FontDb::global(), t).bounds;
let geom = b.geom.unwrap();
assert!(close(geom.start.x, bounds.x0, 1e-3) && close(geom.end.x, bounds.x1, 1e-3), "{geom:?} {bounds:?}");
assert_eq!(t.runs[1].text, "Box");
}
#[test]
fn pattern_fill_on_text_and_shapes() {
let d = open(
r##"<defs><pattern id="dots" width="10" height="10" patternUnits="userSpaceOnUse" x="2"><circle cx="5" cy="5" r="3" fill="red"/></pattern></defs>
<rect width="100" height="100" fill="url(#dots)"/><text x="10" y="50" fill="url(#dots)">P</text>"##,
);
assert_eq!(d.patterns.len(), 1);
let p = &d.patterns[0];
assert_eq!(p.name, "dots");
assert_eq!(p.tile, Rect::new(0.0, 0.0, 10.0, 10.0));
let NodeKind::Group { clip: true, children } = &p.art[0].kind else { panic!("content is clipped to the tile") };
assert_eq!(children.len(), 2);
let a = art(&d);
let Paint::Pattern { pattern, xf } = a[0].appearance.fill_paint() else { panic!() };
assert_eq!(pattern, "dots");
assert!(xf.translation().to_point().distance(Point::new(2.0, 0.0)) < 1e-9);
let t = only_text(&d);
let Paint::Pattern { pattern, xf } = &t.runs[0].style.fill else { panic!("{:?}", t.runs[0].style.fill) };
assert_eq!(pattern, "dots");
// In text space (origin at 10, 50) the tiles stay where they are in the document.
assert!(xf.translation().to_point().distance(Point::new(-8.0, -50.0)) < 1e-9, "{xf:?}");
}
#[test]
fn vertical_writing_mode_runs_down_a_path() {
let d = open(r#"<text x="100" y="20" writing-mode="tb" font-size="10">Down</text>"#);
let t = only_text(&d);
let TextKind::OnPath { path, start } = &t.kind else { panic!("{:?}", t.kind) };
assert_eq!(*start, 0.0);
let b = path.bounds().unwrap();
assert!(close(b.x0, b.x1, 1e-9) && close(b.y0, 20.0, 1e-9) && b.height() > 20.0, "{b:?}");
let o = glyph_origins(t);
assert!(o.windows(2).all(|w| w[1].y > w[0].y), "glyphs step down: {o:?}");
// The em box is centred on the column.
let lay = layout(FontDb::global(), t);
assert!(lay.bounds.x0 < 100.0 && lay.bounds.x1 > 100.0, "{:?}", lay.bounds);
}
#[test]
fn unusable_text_path_falls_back_to_point_type_with_a_warning() {
let (d, w) = import_with_report(&svg(r##"<text x="5" y="10"><textPath href="#missing">Lost</textPath></text>"##)).unwrap();
let t = only_text(&d);
assert!(matches!(t.kind, TextKind::Point));
assert_eq!(t.plain_text(), "Lost");
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
}
#[test]
fn white_space_handling() {
let d = open("<text y=\"10\"> Hello\n big\tworld </text><text y=\"30\" xml:space=\"preserve\"> a b </text>");
let t = texts(&d);
assert_eq!(t[0].plain_text(), "Hello big world");
assert_eq!(t[1].plain_text(), " a b ");
}
#[test]
fn placeholder_ids_never_clash_with_the_files() {
let d = open(r#"<rect id="vectorcraft-text-0" width="5" height="5"/><text y="20">Safe</text>"#);
assert_eq!(only_text(&d).plain_text(), "Safe");
assert_eq!(art(&d)[0].name.as_deref(), Some("vectorcraft-text-0"));
}
#[test]
fn linked_text_keeps_its_url_and_stroke_style() {
let d = open(
r#"<rect width="5" height="5"/><a href="https://example.com/t"><text y="20" stroke="red" stroke-width="0.1em" stroke-linejoin="round" stroke-dasharray="2 1">Go</text></a>"#,
);
let n = art(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Text(_))).unwrap();
assert_eq!(n.url(), Some("https://example.com/t"));
let st = only_text(&d).first_style();
assert!(close(st.stroke_width, 1.2, 1e-9), "{}", st.stroke_width);
assert_eq!(st.stroke_join, vectorcraft_doc::LineJoin::Round);
assert_eq!(st.stroke_dash.as_ref().map(|d| d.pattern.clone()), Some(vec![2.0, 1.0]));
}
#[test]
fn plain_texts_among_siblings_stay_texts() {
let d = open(
r#"<g><rect width="5" height="5"/><text y="20">A</text><g><text y="30">B</text></g><rect y="40" width="5" height="5"/></g><g><text y="50">C</text></g>"#,
);
let kinds: Vec<&str> = art(&d)
.iter()
.map(|n| match n.kind {
NodeKind::Text(_) => "text",
NodeKind::Path { .. } => "path",
NodeKind::Group { .. } => "group",
_ => "other",
})
.collect();
assert_eq!(texts(&d).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["A", "B", "C"], "{kinds:?}");
assert_eq!(kinds.iter().filter(|k| **k == "path").count(), 2, "{kinds:?}");
}
#[test]
fn far_off_positions_stay_finite() {
let d = open(r#"<text x="1e308 -1e308 1e308" y="1e308" dx="1e308 1e308" dy="1e308 1e308" rotate="inf 5" text-anchor="middle">abc</text>"#);
let t = only_text(&d);
assert!(t.xf.as_coeffs().iter().all(|v| v.is_finite()), "{:?}", t.xf);
for r in &t.runs {
assert!(r.style.baseline_shift.is_finite() && r.style.kerning.is_none_or(f64::is_finite) && r.style.rotation.is_finite(), "{r:?}");
}
}

View File

@ -608,7 +608,9 @@ fn import_clip_mask_and_warnings() {
assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(25.0, 25.0, 75.0, 75.0), 0.01));
assert!(a.iter().any(|n| n.mask.is_some()), "<mask> imports as an opacity mask");
assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}");
assert!(w.iter().any(|w| w.contains("pattern")), "{w:?}");
// <pattern> becomes a pattern swatch.
assert_eq!(d.patterns.len(), 1, "{w:?}");
assert!(a.iter().any(|n| matches!(n.appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "p")));
}
#[test]
@ -700,7 +702,12 @@ fn import_tspan_on_the_same_baseline_stays_on_the_line() {
let a = art(&d);
let NodeKind::Text(t) = &a[0].kind else { panic!() };
assert_eq!(t.plain_text(), "Hello world\nnext");
assert_eq!(t.runs.len(), 3);
// The styled runs stay (a kerned space may split the first one: "world" keeps its x).
let red: Vec<&str> = t.runs.iter().filter(|r| r.style.fill.color().is_some_and(|c| c.to_hex() == "#ff0000")).map(|r| r.text.trim()).collect();
assert_eq!(red, ["world"], "{:?}", t.runs);
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let w = lay.glyphs.iter().find(|g| t.plain_text()[g.byte..].starts_with('w')).unwrap();
assert!(((t.xf * w.origin).x - 65.6).abs() < 0.05, "{:?}", t.xf * w.origin);
}
#[test]