From 5c58ebcb32772a728e7da73a2ca42471bc2157e9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 07:20:10 +0700 Subject: [PATCH] =?UTF-8?q?M4.19:=20SVG=20text=20import=20fidelity=20?= =?UTF-8?q?=E2=80=94=20text=20keeps=20its=20place,=20group,=20clip=20and?= =?UTF-8?q?=20mask;=20textPath,=20per-character=20positions,=20weights,=20?= =?UTF-8?q?spacing,=20paints=20and=20CSS=20selectors=20survive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each is swapped for a placeholder group before usvg runs, so the text keeps its z-order, parent group, clip, mask, opacity, link and instances, and usvg resolves its gradient and pattern paints (in text space). 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. paints become pattern swatches. The import fuzz covers the new attributes and CSS. Co-Authored-By: Claude Opus 5.5 --- crates/engine/tests/import_fuzz.rs | 21 + crates/svg/src/import.rs | 584 +++++---------------- crates/svg/src/import/css.rs | 356 +++++++++++++ crates/svg/src/import/text.rs | 799 +++++++++++++++++++++++++++++ crates/svg/src/lib.rs | 19 +- crates/svg/tests/import_text.rs | 377 ++++++++++++++ crates/svg/tests/roundtrip.rs | 11 +- 7 files changed, 1717 insertions(+), 450 deletions(-) create mode 100644 crates/svg/src/import/css.rs create mode 100644 crates/svg/src/import/text.rs create mode 100644 crates/svg/tests/import_text.rs diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4c93ceb2..aea0aa4c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -117,6 +117,14 @@ fn arb_svg_attr() -> impl Strategy { "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!( + "abc{}", + body.concat() + ); + survive(&svg, || vectorcraft_svg::import(&svg).ok())?; + } + #[test] fn svg_mutated_export_never_panics( cut in 0usize..20_000, diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 3a210df7..7a2f1b2b 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -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), 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), 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 `` 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 `` 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), 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 ``'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| 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, /// The URL of each ``'s group, by its id ([`link_ids`]). links: HashMap, + /// Texts read from the XML, by placeholder ([`text`]). + slots: TextSlots, + /// Pattern swatch made for each usvg pattern. + patterns: HashMap, } /// The options [`crate::export::MASK_FLAGS`] records on exported `` elements, by mask id: /// (clip, invert). -fn mask_flags(svg: &str) -> HashMap { +fn mask_flags(xml: &roxmltree::Document) -> HashMap { 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 { const LINK_ID: &str = "vectorcraft-link-"; /// The URL an `` 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>> { + 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 = (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 { 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), } } + /// `` → 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, 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 }))) } } - -// --------------------------------------------------------------------------------------------- -// 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 ` + x + x + x + x + "#, + |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#"x"#, + |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"); + } +} diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs new file mode 100644 index 00000000..18f282f3 --- /dev/null +++ b/crates/svg/src/import/text.rs @@ -0,0 +1,799 @@ +//! `` 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 +//! `` element with a placeholder `` 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 `` 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 `` 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. +//! `` 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 { + 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, + stroke: Option, + /// 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, + stroke: Option, + /// 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 `` 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)>, + /// The first ``. + path: Option>, +} + +/// A line break before character `at`: `count` newlines, `leading` apart. +struct Break { + at: usize, + count: usize, + leading: f64, +} + +/// A `` 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, Option)>, + /// 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, +} + +impl TextSlots { + /// The text a placeholder's main path stands for: by its id, or inside `` instances + /// (where usvg drops ids) by its colour (the index) and its rectangle. + pub fn find(&self, p: &usvg::Path) -> Option { + 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 `` 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) -> (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 ``. + 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, ""); +} + +/// 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>, + weights: RefCell>, + elems: RefCell>>, +} + +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 { + 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 { + 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::().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) { + 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 `` (cached). + fn elem(&self, n: XNode) -> Rc { + 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::().ok()).filter(|m| m.is_finite()).map_or(4.0, |m| m.max(1.0)); + let pattern: Vec = 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 ``, `` and `` 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 `` follows, in the text's user space. + fn text_path(&self, xml: &roxmltree::Document, tp: XNode) -> Option { + 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 ``: the pending text and its `url(#…)` paints. + fn build(&self, xml: &roxmltree::Document, t: XNode, label: &str, warnings: &mut Vec) -> Option<(PendingText, Vec)> { + 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 = + 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 = vec![]; + let mut paint_index = |url: &Option| { + 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 = 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 = 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], + y: &[Option], + dx: &[f64], + dy: &[f64], + anchor: f64, + x0: f64, + breaks: &mut Vec, +) -> (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 = 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 = 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) -> 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>, Vec>) { + let mut at: HashMap = 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, Option); +type Runs = (Vec, Vec); + +/// 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) { + 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) +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 83c0a314..17be8e70 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -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 `` when fonts are loaded; we don't load a font database (too expensive and -//! unavailable on wasm), so `` elements are read directly from the XML as live point-type -//! [`TextObject`]s (type on a path for a ``) and placed on top of their layer. +//! * `` 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 `` when fonts are loaded; we don't load a font database (too expensive and +//! unavailable on wasm), so `` 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, `` instances and gradient or pattern paints. The style cascade +//! supports type, `#id`, `.class` (several), attribute, descendant and child selectors. +//! `` 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; diff --git a/crates/svg/tests/import_text.rs b/crates/svg/tests/import_text.rs new file mode 100644 index 00000000..c7fdb9f6 --- /dev/null +++ b/crates/svg/tests/import_text.rs @@ -0,0 +1,377 @@ +//! SVG `` 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#"{body}"#) +} + +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::>()); + t[0] +} + +/// Document-space pen position of each glyph. +fn glyph_origins(t: &TextObject) -> Vec { + 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#"Hi"#); + 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##" + + + In clip + Own clip + Masked + "##, + ); + // 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"); + // `` 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#"Note"#); + assert_eq!(d.layers.iter().map(|l| l.name.as_deref().unwrap()).collect::>(), ["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#"First"#); + 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##"gonehidden + Used"##, + ); + 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##" + Curve + Twenty + Mid"##, + ); + 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#"abcd"#); + 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 = t.runs.iter().flat_map(|r| r.text.chars().map(|_| r.style.clone())).collect(); + let rot: Vec = 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 = 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#"ab"#); + 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#"OneTwoThree + four2x"#, + ); + let t = only_text(&d); + assert_eq!(t.plain_text(), "One\nTwo\nThree four2x"); + let lay = layout(FontDb::global(), t); + let base: Vec = 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#"a bcdefg"#, + ); + 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#" + p + gabac"#, + ); + 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##" + + + + + GradientBox"##, + ); + 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##" + P"##, + ); + 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#"Down"#); + 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##"Lost"##)).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(" Hello\n big\tworld a b "); + 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#"Safe"#); + 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#"Go"#, + ); + 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#"ABC"#, + ); + 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::>(), ["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#"abc"#); + 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:?}"); + } +} diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index c865203a..0f09e9c0 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -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()), " imports as an opacity mask"); assert!(!w.iter().any(|w| w.contains("mask")), "{w:?}"); - assert!(w.iter().any(|w| w.contains("pattern")), "{w:?}"); + // 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]