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:
parent
bc17c1e635
commit
5c58ebcb32
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue