904 lines
37 KiB
Rust
904 lines
37 KiB
Rust
//! usvg tree → document conversion.
|
|
|
|
use std::collections::HashMap;
|
|
use std::str::FromStr;
|
|
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,
|
|
};
|
|
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Vec2};
|
|
|
|
use crate::SvgError;
|
|
|
|
pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|
let (svg, links) = link_ids(svg);
|
|
let svg = svg.as_ref();
|
|
let opt = usvg::Options::default();
|
|
let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| SvgError::Parse(e.to_string()))?;
|
|
let size = tree.size();
|
|
// Document points from CSS pixels: 1 px = 1 pt (as we export), except that a root size in
|
|
// absolute units is that physical size (usvg resolves it to 96 px per inch, i.e. 4/3 px per pt).
|
|
let (kx, ky) = physical_scale(svg);
|
|
let doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky);
|
|
let mut im = Importer { doc, warnings: Vec::new(), mask_flags: mask_flags(svg), links };
|
|
|
|
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
|
let mut top = tree.root();
|
|
let mut base = Affine::scale_non_uniform(kx, ky) * aff(top.transform());
|
|
if let [usvg::Node::Group(g)] = top.children()
|
|
&& g.id().is_empty()
|
|
&& is_plain(g)
|
|
{
|
|
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)));
|
|
if layer_mode {
|
|
im.doc.layers.clear();
|
|
for (i, c) in top.children().iter().enumerate() {
|
|
let usvg::Node::Group(g) = c else { continue };
|
|
let children = 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));
|
|
if let Some(ch) = l.children_mut() {
|
|
*ch = children;
|
|
}
|
|
im.doc.layers.push(Arc::new(l));
|
|
}
|
|
} else {
|
|
let children = im.children(top, base);
|
|
if let Some(l) = im.doc.layers.first_mut()
|
|
&& let Some(ch) = Arc::make_mut(l).children_mut()
|
|
{
|
|
*ch = children;
|
|
}
|
|
}
|
|
|
|
text_fallback(&mut im, svg, size.width() as f64, size.height() as f64, (kx, ky), layer_mode);
|
|
Ok((im.doc, im.warnings))
|
|
}
|
|
|
|
/// Points per CSS pixel along x and y for the root `<svg>`'s `width` / `height`: 0.75 (72 / 96) for
|
|
/// absolute units (in, cm, mm, pt, pc), so that `width="210mm"` is 595.3 pt wide; 1 otherwise
|
|
/// (unitless, px, %, em, or absent).
|
|
fn physical_scale(svg: &str) -> (f64, f64) {
|
|
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
|
return (1.0, 1.0);
|
|
};
|
|
let root = xml.root_element();
|
|
let k = |name: &str| {
|
|
use svgtypes::LengthUnit as U;
|
|
match root.attribute(name).and_then(|v| svgtypes::Length::from_str(v.trim()).ok()).map(|l| l.unit) {
|
|
Some(U::In | U::Cm | U::Mm | U::Pt | U::Pc) => 0.75,
|
|
_ => 1.0,
|
|
}
|
|
};
|
|
(k("width"), k("height"))
|
|
}
|
|
|
|
struct Importer {
|
|
doc: Document,
|
|
warnings: Vec<String>,
|
|
/// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]).
|
|
mask_flags: HashMap<String, (bool, bool)>,
|
|
/// The URL of each `<a href>`'s group, by its id ([`link_ids`]).
|
|
links: HashMap<String, 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)> {
|
|
let attr = crate::export::MASK_FLAGS;
|
|
if !svg.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"))))
|
|
};
|
|
xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect()
|
|
}
|
|
|
|
/// Prefix of the ids [`link_ids`] gives the `<a>` elements that have none.
|
|
const LINK_ID: &str = "vectorcraft-link-";
|
|
|
|
/// The URL an `<a>` element links to.
|
|
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())
|
|
}
|
|
|
|
/// usvg reads `<a href>` as a plain group and drops the URL. So that the group can be found, every
|
|
/// link without an id gets one ([`LINK_ID`]…); returns the SVG with those ids and the URLs by id.
|
|
fn link_ids(svg: &str) -> (std::borrow::Cow<'_, str>, HashMap<String, String>) {
|
|
let mut links = HashMap::new();
|
|
if !svg.contains("<a") {
|
|
return (svg.into(), links);
|
|
}
|
|
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
|
return (svg.into(), links);
|
|
};
|
|
// Where to insert each new id: right after the tag name.
|
|
let mut inserts = vec![];
|
|
for a in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "a") {
|
|
let Some(url) = href(a) else { continue };
|
|
let id = match a.attribute("id") {
|
|
Some(id) => id.to_string(),
|
|
None => {
|
|
let start = a.range().start;
|
|
let end = svg[start + 1..].find(|c: char| c.is_whitespace() || c == '/' || c == '>').map_or(start + 2, |i| start + 1 + i);
|
|
let id = format!("{LINK_ID}{}", inserts.len());
|
|
inserts.push((end, id.clone()));
|
|
id
|
|
}
|
|
};
|
|
links.insert(id, url.to_string());
|
|
}
|
|
if inserts.is_empty() {
|
|
return (svg.into(), links);
|
|
}
|
|
let mut out = String::with_capacity(svg.len() + inserts.len() * 32);
|
|
let mut last = 0;
|
|
for (at, id) in inserts {
|
|
out.push_str(&svg[last..at]);
|
|
out.push_str(&format!(" id=\"{id}\""));
|
|
last = at;
|
|
}
|
|
out.push_str(&svg[last..]);
|
|
(out.into(), links)
|
|
}
|
|
|
|
/// Make `n` link to `url`, unless it links somewhere already (an inner link wins).
|
|
fn link(n: &mut Node, url: &str) {
|
|
if n.url().is_none() {
|
|
n.edit_attrs(|a| a.url = url.to_string());
|
|
}
|
|
}
|
|
|
|
fn aff(t: usvg::Transform) -> Affine {
|
|
Affine::new([t.sx as f64, t.ky as f64, t.kx as f64, t.sy as f64, t.tx as f64, t.ty as f64])
|
|
}
|
|
|
|
fn is_plain(g: &usvg::Group) -> bool {
|
|
g.clip_path().is_none() && g.mask().is_none() && g.filters().is_empty() && g.opacity().get() >= 1.0 && g.blend_mode() == usvg::BlendMode::Normal
|
|
}
|
|
|
|
fn blend(b: usvg::BlendMode) -> BlendMode {
|
|
use usvg::BlendMode as B;
|
|
match b {
|
|
B::Normal => BlendMode::Normal,
|
|
B::Multiply => BlendMode::Multiply,
|
|
B::Screen => BlendMode::Screen,
|
|
B::Overlay => BlendMode::Overlay,
|
|
B::Darken => BlendMode::Darken,
|
|
B::Lighten => BlendMode::Lighten,
|
|
B::ColorDodge => BlendMode::ColorDodge,
|
|
B::ColorBurn => BlendMode::ColorBurn,
|
|
B::HardLight => BlendMode::HardLight,
|
|
B::SoftLight => BlendMode::SoftLight,
|
|
B::Difference => BlendMode::Difference,
|
|
B::Exclusion => BlendMode::Exclusion,
|
|
B::Hue => BlendMode::Hue,
|
|
B::Saturation => BlendMode::Saturation,
|
|
B::Color => BlendMode::Color,
|
|
B::Luminosity => BlendMode::Luminosity,
|
|
}
|
|
}
|
|
|
|
fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath {
|
|
use usvg::tiny_skia_path::PathSegment as S;
|
|
let pt = |p: usvg::tiny_skia_path::Point| m * Point::new(p.x as f64, p.y as f64);
|
|
let mut bp = BezPath::new();
|
|
for s in p.segments() {
|
|
match s {
|
|
S::MoveTo(p) => bp.move_to(pt(p)),
|
|
S::LineTo(p) => bp.line_to(pt(p)),
|
|
S::QuadTo(a, p) => bp.quad_to(pt(a), pt(p)),
|
|
S::CubicTo(a, b, p) => bp.curve_to(pt(a), pt(b), pt(p)),
|
|
S::Close => bp.close_path(),
|
|
}
|
|
}
|
|
bp
|
|
}
|
|
|
|
fn fnv1a(bytes: &[u8]) -> u64 {
|
|
let mut h: u64 = 0xcbf29ce484222325;
|
|
for b in bytes {
|
|
h ^= *b as u64;
|
|
h = h.wrapping_mul(0x100000001b3);
|
|
}
|
|
h
|
|
}
|
|
|
|
fn rule(r: usvg::FillRule) -> FillRule {
|
|
match r {
|
|
usvg::FillRule::NonZero => FillRule::NonZero,
|
|
usvg::FillRule::EvenOdd => FillRule::EvenOdd,
|
|
}
|
|
}
|
|
|
|
impl Importer {
|
|
fn warn(&mut self, s: String) {
|
|
if !self.warnings.contains(&s) {
|
|
self.warnings.push(s);
|
|
}
|
|
}
|
|
|
|
fn children(&mut self, g: &usvg::Group, acc: Affine) -> Vec<Arc<Node>> {
|
|
g.children().iter().filter_map(|c| self.node(c, acc)).map(Arc::new).collect()
|
|
}
|
|
|
|
fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option<Node> {
|
|
match n {
|
|
usvg::Node::Group(g) => self.group(g, acc),
|
|
usvg::Node::Path(p) => self.path(p, acc),
|
|
usvg::Node::Image(i) => self.image(i, acc),
|
|
// Only present when usvg had fonts; text is read from the XML instead.
|
|
usvg::Node::Text(_) => None,
|
|
}
|
|
}
|
|
|
|
fn named(&mut self, id: &str, kind: NodeKind) -> Node {
|
|
let nid = self.doc.alloc_id();
|
|
let mut n = Node::new(nid, kind);
|
|
if !id.is_empty() {
|
|
n.name = Some(id.to_string());
|
|
}
|
|
n
|
|
}
|
|
|
|
fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
|
|
let url = self.links.get(g.id()).cloned();
|
|
let mut n = self.group_node(g, acc)?;
|
|
if let Some(url) = url {
|
|
link(&mut n, &url);
|
|
}
|
|
Some(n)
|
|
}
|
|
|
|
fn group_node(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
|
|
let ts = acc * aff(g.transform());
|
|
// 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 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 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"));
|
|
}
|
|
let clip = self.clip_node(cp, ts)?;
|
|
let mut ch = vec![Arc::new(clip)];
|
|
ch.extend(children);
|
|
self.named(id, NodeKind::Group { children: ch, clip: true })
|
|
} else {
|
|
if children.is_empty() {
|
|
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()
|
|
&& children.len() == 1
|
|
&& (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal)
|
|
&& let Some(only) = children.pop()
|
|
{
|
|
let mut c = Arc::unwrap_or_clone(only);
|
|
c.opacity *= g.opacity().get();
|
|
if g.blend_mode() != usvg::BlendMode::Normal {
|
|
c.blend = blend(g.blend_mode());
|
|
}
|
|
c.isolate |= g.isolate();
|
|
return Some(c);
|
|
}
|
|
self.named(id, NodeKind::Group { children, clip: false })
|
|
};
|
|
n.opacity = g.opacity().get();
|
|
n.blend = blend(g.blend_mode());
|
|
n.isolate = g.isolate();
|
|
n.mask = mask;
|
|
Some(n)
|
|
}
|
|
|
|
/// `<mask>` → opacity mask (luminance; alpha masks are approximated by their luminance). A
|
|
/// mask our export wrote gets its options back ([`mask_flags`]) and its art without what
|
|
/// stands for them: the inverting filter group and the backdrop rectangle.
|
|
fn opacity_mask(&mut self, m: &usvg::Mask, ts: Affine, label: &str) -> Option<Box<vectorcraft_doc::OpacityMask>> {
|
|
if m.kind() == usvg::MaskType::Alpha {
|
|
self.warn(format!("alpha mask on {label} imported as a luminance opacity mask"));
|
|
}
|
|
if m.mask().is_some() {
|
|
self.warn(format!("nested mask on {label} ignored"));
|
|
}
|
|
let (clip, invert) = self.mask_flags.get(m.id()).copied().unwrap_or((true, false));
|
|
let (mut nodes, mut ts) = (m.root().children(), ts);
|
|
if invert
|
|
&& let [usvg::Node::Group(g)] = nodes
|
|
&& !g.filters().is_empty()
|
|
{
|
|
(nodes, ts) = (g.children(), ts * aff(g.transform()));
|
|
}
|
|
if (!clip || invert)
|
|
&& let [usvg::Node::Path(_), rest @ ..] = nodes
|
|
{
|
|
nodes = rest;
|
|
}
|
|
let mut children: Vec<Arc<Node>> = nodes.iter().filter_map(|c| self.node(c, ts)).map(Arc::new).collect();
|
|
let art = match children.len() {
|
|
0 => return None,
|
|
1 => Arc::unwrap_or_clone(children.pop()?),
|
|
_ => self.named("", NodeKind::Group { children, clip: false }),
|
|
};
|
|
let mut mask = vectorcraft_doc::OpacityMask::new(art, clip);
|
|
mask.invert = invert;
|
|
Some(Box::new(mask))
|
|
}
|
|
|
|
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
|
|
fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) {
|
|
for c in g.children() {
|
|
match c {
|
|
usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out),
|
|
usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())),
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
let mut shapes = Vec::new();
|
|
collect(cp.root(), ts * aff(cp.transform()), &mut shapes);
|
|
if shapes.is_empty() {
|
|
return None;
|
|
}
|
|
let r = shapes[0].1;
|
|
let mut subs = Vec::new();
|
|
for (bp, _) in &shapes {
|
|
subs.extend(PathData::from_bezpath(bp).subpaths);
|
|
}
|
|
let id = cp.id().to_string();
|
|
if subs.len() == 1 {
|
|
let path = PathData::new(subs);
|
|
return Some(self.named(&id, NodeKind::Path { path, rule: r, live: None, clipping: true, guide: false }));
|
|
}
|
|
let children = subs
|
|
.into_iter()
|
|
.map(|sp| {
|
|
let nid = self.doc.alloc_id();
|
|
Arc::new(Node::new(nid, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false }))
|
|
})
|
|
.collect();
|
|
Some(self.named(&id, NodeKind::Compound { children, rule: r }))
|
|
}
|
|
|
|
fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint {
|
|
let stops = |g: &usvg::BaseGradient| {
|
|
g.stops()
|
|
.iter()
|
|
.map(|s| {
|
|
let c = s.color();
|
|
GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) }
|
|
})
|
|
.collect::<Vec<_>>()
|
|
};
|
|
let gp = |kind, stops, geom: GradientGeom| {
|
|
Paint::Gradient(Box::new(GradientPaint {
|
|
gradient: Gradient { kind, stops },
|
|
geom: Some(geom),
|
|
angle: geom.angle_deg(),
|
|
swatch: None,
|
|
freeform: None,
|
|
}))
|
|
};
|
|
match p {
|
|
usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)),
|
|
usvg::Paint::LinearGradient(lg) => {
|
|
if lg.spread_method() != usvg::SpreadMethod::Pad {
|
|
self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id()));
|
|
}
|
|
let mut geom = GradientGeom {
|
|
start: Point::new(lg.x1() as f64, lg.y1() as f64),
|
|
end: Point::new(lg.x2() as f64, lg.y2() as f64),
|
|
aspect: 1.0,
|
|
focal: None,
|
|
};
|
|
geom.transform(m * aff(lg.transform()), GradientKind::Linear);
|
|
gp(GradientKind::Linear, stops(lg), geom)
|
|
}
|
|
usvg::Paint::RadialGradient(rg) => {
|
|
if rg.spread_method() != usvg::SpreadMethod::Pad {
|
|
self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id()));
|
|
}
|
|
if rg.fr().get() > 1e-4 {
|
|
self.warn(format!("gradient '{}': focal radius ignored", rg.id()));
|
|
}
|
|
let c = Point::new(rg.cx() as f64, rg.cy() as f64);
|
|
let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0, focal: None };
|
|
// A focal point outside the circle is pulled inside it.
|
|
geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64)));
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) {
|
|
let mut fill = None;
|
|
let mut r = FillRule::NonZero;
|
|
if let Some(f) = p.fill() {
|
|
let paint = self.paint(f.paint(), m);
|
|
let mut fl = FillLayer::new(paint);
|
|
fl.opacity = f.opacity().get();
|
|
r = rule(f.rule());
|
|
fill = Some(AppearanceItem::Fill(fl));
|
|
}
|
|
let mut stroke = None;
|
|
if let Some(s) = p.stroke() {
|
|
let scale = m.determinant().abs().sqrt();
|
|
let paint = self.paint(s.paint(), m);
|
|
let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale);
|
|
sl.opacity = s.opacity().get();
|
|
sl.cap = match s.linecap() {
|
|
usvg::LineCap::Butt => LineCap::Butt,
|
|
usvg::LineCap::Round => LineCap::Round,
|
|
usvg::LineCap::Square => LineCap::Square,
|
|
};
|
|
sl.join = match s.linejoin() {
|
|
usvg::LineJoin::Miter | usvg::LineJoin::MiterClip => LineJoin::Miter,
|
|
usvg::LineJoin::Round => LineJoin::Round,
|
|
usvg::LineJoin::Bevel => LineJoin::Bevel,
|
|
};
|
|
sl.miter_limit = s.miterlimit().get() as f64;
|
|
if let Some(d) = s.dasharray() {
|
|
sl.dash = Some(Dash {
|
|
pattern: d.iter().map(|v| *v as f64 * scale).collect(),
|
|
offset: s.dashoffset() as f64 * scale,
|
|
align_corners: false,
|
|
});
|
|
}
|
|
stroke = Some(AppearanceItem::Stroke(sl));
|
|
}
|
|
let items = match p.paint_order() {
|
|
usvg::PaintOrder::FillAndStroke => [fill, stroke],
|
|
usvg::PaintOrder::StrokeAndFill => [stroke, fill],
|
|
};
|
|
(Appearance { items: items.into_iter().flatten().collect(), ..Default::default() }, r)
|
|
}
|
|
|
|
fn path(&mut self, p: &usvg::Path, acc: Affine) -> Option<Node> {
|
|
if !p.is_visible() {
|
|
return None;
|
|
}
|
|
let path = PathData::from_bezpath(&bezpath(p.data(), acc));
|
|
if path.is_empty() {
|
|
return None;
|
|
}
|
|
let (appearance, r) = self.appearance(p, acc);
|
|
let mut n = if path.subpaths.len() > 1 {
|
|
// Multi-subpath SVG paths are compound paths (as in Illustrator).
|
|
let children = path
|
|
.subpaths
|
|
.into_iter()
|
|
.map(|sp| {
|
|
let id = self.doc.alloc_id();
|
|
let mut c = Node::new(id, NodeKind::Path { path: PathData::single(sp), rule: r, live: None, clipping: false, guide: false });
|
|
c.appearance = appearance.clone();
|
|
Arc::new(c)
|
|
})
|
|
.collect();
|
|
self.named(p.id(), NodeKind::Compound { children, rule: r })
|
|
} else {
|
|
self.named(p.id(), NodeKind::Path { path, rule: r, live: None, clipping: false, guide: false })
|
|
};
|
|
n.appearance = appearance;
|
|
Some(n)
|
|
}
|
|
|
|
fn image(&mut self, i: &usvg::Image, acc: Affine) -> Option<Node> {
|
|
if !i.is_visible() {
|
|
return None;
|
|
}
|
|
let (bytes, mime) = match i.kind() {
|
|
usvg::ImageKind::JPEG(d) => (d, "image/jpeg"),
|
|
usvg::ImageKind::PNG(d) => (d, "image/png"),
|
|
usvg::ImageKind::GIF(d) => (d, "image/gif"),
|
|
usvg::ImageKind::WEBP(d) => (d, "image/webp"),
|
|
usvg::ImageKind::SVG(_) => {
|
|
self.warn("embedded SVG image skipped".into());
|
|
return None;
|
|
}
|
|
};
|
|
let size = i.size();
|
|
let (pw, ph) = image::ImageReader::new(std::io::Cursor::new(&bytes[..]))
|
|
.with_guessed_format()
|
|
.ok()
|
|
.and_then(|r| r.into_dimensions().ok())
|
|
.unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32));
|
|
let key = format!("img-{:016x}", fnv1a(bytes));
|
|
self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob { mime: mime.into(), bytes: Arc::new(bytes.to_vec()) });
|
|
let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64);
|
|
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)>>,
|
|
}
|
|
|
|
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) -> 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 }
|
|
}
|
|
|
|
/// 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"))
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
if let Some(s) = ctx.prop(n, "font-size")
|
|
&& let Ok(l) = svgtypes::Length::from_str(s.trim())
|
|
&& l.number > 0.0
|
|
{
|
|
st.size = match l.unit {
|
|
svgtypes::LengthUnit::Em => l.number * 12.0,
|
|
svgtypes::LengthUnit::Percent => l.number / 100.0 * 12.0,
|
|
_ => l.number,
|
|
};
|
|
}
|
|
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| first_number(Some(&w))).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| v.trim().parse().ok()).collect());
|
|
let offset = ctx.prop(n, "stroke-dashoffset").map_or(0.0, |o| first_number(Some(&o)));
|
|
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 = first_number(Some(&ls));
|
|
if v != 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 lead = char_style(ctx, c).effective_leading().max(1e-6);
|
|
if let Some(y) = c.attribute("y") {
|
|
let y = first_number(Some(y));
|
|
// 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 c.attribute("dy").is_some_and(|dy| first_number(Some(dy)) > 1e-9) {
|
|
rb.last_y += first_number(c.attribute("dy"));
|
|
rb.newline(1);
|
|
}
|
|
}
|
|
collect_runs(ctx, c, rb);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, (kx, ky): (f64, f64), 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);
|
|
let root = xml.root_element();
|
|
let vb = Affine::scale_non_uniform(kx, ky) * 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 y0 = first_number(t.attribute("y"));
|
|
let xf = acc * Affine::translate((first_number(t.attribute("x")), 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 { first_number(Some(o)) / 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));
|
|
}
|
|
}
|
|
}
|