mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Other editors export a document with a single layer without a layer group: the layer's name goes on the root <svg> (id, or data-name) and its art sits directly inside. Such a file opened as "Layer 1"; now its one layer takes the root's name. Files whose top-level groups are the layers are unchanged, and an Inkscape root's generated id (with inkscape:version) isn't taken as a name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1249 lines
54 KiB
Rust
1249 lines
54 KiB
Rust
//! usvg tree → document conversion.
|
|
|
|
use std::borrow::Cow;
|
|
use std::collections::{HashMap, HashSet};
|
|
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, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, PatternDef,
|
|
StrokeLayer, Unit,
|
|
};
|
|
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
|
|
|
|
use crate::export::Reuse;
|
|
use crate::{ImportOptions, SvgError, fnv1a};
|
|
use css::XNode;
|
|
use text::TextSlots;
|
|
|
|
pub(crate) use fx::BLEND;
|
|
|
|
mod css;
|
|
mod files;
|
|
mod fx;
|
|
mod spread;
|
|
mod text;
|
|
mod vector_effect;
|
|
|
|
pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), SvgError> {
|
|
let (svg, found) = prepass(svg, opts);
|
|
let svg = svg.as_ref();
|
|
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 root_name = root_layer_name(xml.root_element());
|
|
let mut warnings = found.warnings;
|
|
let (src, slots) = text::prepare(svg, &xml, units.dpi, &mut warnings);
|
|
let files = found.files;
|
|
let tree = {
|
|
let opt = usvg::Options {
|
|
dpi: units.dpi as f32,
|
|
font_size: DEFAULT_FONT_SIZE as f32,
|
|
image_href_resolver: files.resolver(),
|
|
..usvg::Options::default()
|
|
};
|
|
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,
|
|
mask_flags: mask_flags(&xml),
|
|
links: found.links,
|
|
slots,
|
|
patterns: HashMap::new(),
|
|
midpoints: midpoint_stops(&xml),
|
|
labels: labels(&xml),
|
|
hidden: found.hidden,
|
|
uses: found.uses,
|
|
symbols: HashMap::new(),
|
|
blends: fx::blends(&xml),
|
|
files,
|
|
non_scaling: found.non_scaling,
|
|
data: found.data,
|
|
sublayers: found.sublayers,
|
|
sublayer_groups: HashSet::new(),
|
|
screen: (kx * ky).sqrt(),
|
|
};
|
|
if im.files.nested_text() {
|
|
im.warn("text inside an SVG image isn't imported".into());
|
|
}
|
|
|
|
// 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 Some(g) = im.wrapper(top) {
|
|
base *= aff(g.transform());
|
|
top = g;
|
|
}
|
|
|
|
// Top-level `<g id>` elements become layers (as in the reference app), named from their
|
|
// labels (`data-name`…) else their ids; otherwise all art goes into "Layer 1". A clip path on
|
|
// one (as on an Inkscape layer, or our clipping layers) makes it a clipping layer. 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() && !made_up(g.id()) && !im.links.contains_key(g.id()) && !im.uses.contains_key(g.id()) && only_clips(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 = |im: &Importer, top: &usvg::Group| {
|
|
top.children().iter().any(|c| is_layer(im, c)) && top.children().iter().all(|c| is_layer(im, c) || is_text(im, c))
|
|
};
|
|
// The layers may sit in more wrappers (a transform on all the art, a `<switch>`'s group).
|
|
if !layer_mode(&im, top) {
|
|
let (mut inner, mut ts) = (top, base);
|
|
while let Some(g) = im.wrapper(inner) {
|
|
ts *= aff(g.transform());
|
|
inner = g;
|
|
}
|
|
if layer_mode(&im, inner) {
|
|
(top, base) = (inner, ts);
|
|
}
|
|
}
|
|
if layer_mode(&im, top) {
|
|
im.doc.layers.clear();
|
|
let mut loose = vec![];
|
|
for c in top.children() {
|
|
let usvg::Node::Group(g) = c else { continue };
|
|
if !is_layer(&im, c) {
|
|
let n: Vec<_> = im.node(c, base).map(Arc::new).into_iter().collect();
|
|
match im.doc.layers.pop() {
|
|
Some(mut l) => {
|
|
im.add_unclipped(Arc::make_mut(&mut l), usize::MAX, n);
|
|
im.doc.layers.push(l);
|
|
}
|
|
None => loose.extend(n),
|
|
}
|
|
continue;
|
|
}
|
|
let ts = base * aff(g.transform());
|
|
let name = im.name_of(g.id()).to_string();
|
|
let mut art = im.children(g, ts);
|
|
// A clip path with no shape shows nothing of the layer: it stays, empty.
|
|
let clip = match g.clip_path() {
|
|
Some(cp) => {
|
|
art = im.clipped(cp, ts, art, &format!("layer '{name}'")).unwrap_or_default();
|
|
!art.is_empty()
|
|
}
|
|
None => false,
|
|
};
|
|
let id = im.doc.alloc_id();
|
|
let preset = (im.doc.layers.len() % vectorcraft_doc::LAYER_COLORS.len()) as u8;
|
|
let mut l = Node::layer(id, &name, LayerColor::Preset(preset));
|
|
l.set_clips(clip);
|
|
if let Some(ch) = l.children_mut() {
|
|
*ch = art;
|
|
}
|
|
im.add_unclipped(&mut l, 0, std::mem::take(&mut loose));
|
|
l.visible = !im.hidden.contains(g.id());
|
|
im.keep_data(g.id(), &mut l);
|
|
im.doc.layers.push(Arc::new(l));
|
|
}
|
|
} else {
|
|
// An export of a one-layer document names the layer on the root element, its art (groups
|
|
// among it) loose inside: the one layer takes that name.
|
|
let children = im.children(top, base);
|
|
if let Some(l) = im.doc.layers.first_mut() {
|
|
let l = Arc::make_mut(l);
|
|
if let Some(ch) = l.children_mut() {
|
|
*ch = children;
|
|
}
|
|
if root_name.is_some() {
|
|
l.name = root_name;
|
|
}
|
|
}
|
|
}
|
|
if !im.sublayer_groups.is_empty() {
|
|
for l in &mut im.doc.layers {
|
|
promote_sublayers(Arc::make_mut(l), &im.sublayer_groups);
|
|
}
|
|
}
|
|
Ok((im.doc, im.warnings))
|
|
}
|
|
|
|
/// What marks a nested `<g>` as a sublayer rather than a group: our export writes it on the layers
|
|
/// it writes inside layers. (Inkscape's `inkscape:groupmode="layer"` counts too.)
|
|
pub(crate) const SUBLAYER: &str = "data-vc-layer";
|
|
|
|
/// The Inkscape namespace (its layers and their labels).
|
|
const INKSCAPE: &str = "http://www.inkscape.org/namespaces/inkscape";
|
|
|
|
/// The layer name on root element `root` (`<svg id="Main">`, or its `data-name`), as exports of
|
|
/// one-layer documents write it. An Inkscape root's id (`svg8`) is one Inkscape made up, not a name.
|
|
fn root_layer_name(root: XNode) -> Option<String> {
|
|
let id = root.attribute("id").filter(|_| root.attribute((INKSCAPE, "version")).is_none());
|
|
root.attribute("data-name").or(id).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string)
|
|
}
|
|
|
|
/// Is element `g` a `<g>` marked as a layer ([`SUBLAYER`])? Only one with an id counts: the id (or
|
|
/// the label beside it) is its name.
|
|
fn marked_layer(g: XNode) -> bool {
|
|
g.tag_name().name() == "g"
|
|
&& g.attribute("id").is_some_and(|id| !id.is_empty())
|
|
&& (g.has_attribute(SUBLAYER) || g.attribute((INKSCAPE, "groupmode")) == Some("layer"))
|
|
}
|
|
|
|
/// Turn the groups made of elements marked as layers (`marked`, see [`marked_layer`]) right inside
|
|
/// layer `l` into its sublayers, in its colour, and theirs in turn. A marked group anywhere else
|
|
/// (in a group, a mask, a symbol) stays a group: only layers hold layers.
|
|
fn promote_sublayers(l: &mut Node, marked: &HashSet<NodeId>) {
|
|
let NodeKind::Layer { color, children, .. } = &mut l.kind else { return };
|
|
let color = *color;
|
|
for c in children.iter_mut().filter(|c| marked.contains(&c.id)) {
|
|
let c = Arc::make_mut(c);
|
|
let NodeKind::Group { children, clip } = &mut c.kind else { continue };
|
|
let mut sub = Node::layer(c.id, "", color);
|
|
sub.set_clips(*clip);
|
|
if let Some(ch) = sub.children_mut() {
|
|
*ch = std::mem::take(children);
|
|
}
|
|
c.kind = sub.kind;
|
|
promote_sublayers(c, marked);
|
|
}
|
|
}
|
|
|
|
/// SVG's initial `font-size` (`medium`) in user units.
|
|
pub(super) const DEFAULT_FONT_SIZE: f64 = 12.0;
|
|
|
|
/// How lengths become points, from the root `<svg>`'s `width` / `height`.
|
|
///
|
|
/// A pixel (and a unitless user unit) is a point, as we export, and absolute lengths keep their
|
|
/// physical size at 72 pt per inch: `font-size="12pt"` is 12 pt, `1in` is 72 pt. A root size in
|
|
/// absolute units (`width="210mm"`) is that physical size, and its user units are CSS pixels of it
|
|
/// (96 per inch, 0.75 pt each), so a drawing without a `viewBox` keeps its proportions.
|
|
struct RootUnits {
|
|
/// Pixels per inch usvg (and the text pass) convert absolute lengths at.
|
|
dpi: f64,
|
|
/// Points per user unit of the root along x and y.
|
|
k: (f64, f64),
|
|
/// Document units: those of the root `width` (pixels when it has none).
|
|
unit: Unit,
|
|
}
|
|
|
|
impl RootUnits {
|
|
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"));
|
|
let k = |u| if physical(u) { PT_PER_IN / CSS_PX_PER_IN } else { 1.0 };
|
|
let dpi = if physical(w) || physical(h) { CSS_PX_PER_IN } else { PT_PER_IN };
|
|
let unit = match w {
|
|
Some(U::Mm) => Unit::Millimeters,
|
|
Some(U::Cm) => Unit::Centimeters,
|
|
Some(U::In) => Unit::Inches,
|
|
Some(U::Pt) => Unit::Points,
|
|
Some(U::Pc) => Unit::Picas,
|
|
_ => Unit::Pixels,
|
|
};
|
|
Self { dpi, k: (k(w), k(h)), unit }
|
|
}
|
|
}
|
|
|
|
/// How many clip paths deep (`<clipPath clip-path>`) an object's clip is read.
|
|
const MAX_CLIP_NEST: usize = 16;
|
|
|
|
/// Points per inch.
|
|
pub(super) const PT_PER_IN: f64 = 72.0;
|
|
/// CSS pixels per inch.
|
|
const CSS_PX_PER_IN: f64 = 96.0;
|
|
|
|
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 ([`prepass`]).
|
|
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>,
|
|
/// Midpoint stops by gradient id (see [`midpoint_stops`]).
|
|
midpoints: HashMap<String, Vec<Option<f32>>>,
|
|
/// Object names by element id (see [`labels`]).
|
|
labels: HashMap<String, String>,
|
|
/// Ids of the undisplayed objects shown for usvg ([`prepass`]).
|
|
hidden: HashSet<String>,
|
|
/// The `<symbol>` id each `<use>` of a symbol shows, by the `<use>`'s id ([`prepass`]).
|
|
uses: HashMap<String, String>,
|
|
/// The symbols made so far, by `<symbol>` id.
|
|
symbols: HashMap<String, SymbolMade>,
|
|
/// Blend modes of the shadows and glows our export wrote, by filter id ([`fx::blends`]).
|
|
blends: HashMap<String, String>,
|
|
/// The files `<image>` elements link to.
|
|
files: files::Files,
|
|
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
|
|
non_scaling: HashSet<String>,
|
|
/// The `data-*` attributes of elements, by id ([`user_data`]).
|
|
data: HashMap<String, Vec<(String, String)>>,
|
|
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
|
|
sublayers: HashSet<String>,
|
|
/// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]).
|
|
sublayer_groups: HashSet<NodeId>,
|
|
/// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling
|
|
/// stroke's width is measured in.
|
|
screen: f64,
|
|
}
|
|
|
|
/// A `<symbol>` made a symbol: its name, its art with no object ids (what each `<use>` has to show
|
|
/// to be an instance of it) and the instance transforms the art can stand for.
|
|
struct SymbolMade {
|
|
name: String,
|
|
art: Node,
|
|
reuse: Reuse,
|
|
}
|
|
|
|
/// `n` with every object id (its descendants' and its mask art's too) zeroed, to compare art.
|
|
fn without_ids(n: &Node) -> Node {
|
|
let mut n = n.clone();
|
|
n.id = vectorcraft_doc::NodeId(0);
|
|
if let Some(m) = n.mask.as_deref_mut() {
|
|
m.art = Arc::new(without_ids(&m.art));
|
|
}
|
|
for c in n.children_mut().into_iter().flatten() {
|
|
*c = Arc::new(without_ids(c));
|
|
}
|
|
n
|
|
}
|
|
|
|
/// `base`, or `base 2`, `base 3`… : the first that isn't `taken`.
|
|
fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
|
let mut name = base.to_string();
|
|
let mut k = 2;
|
|
while taken(&name) {
|
|
name = format!("{base} {k}");
|
|
k += 1;
|
|
}
|
|
name
|
|
}
|
|
|
|
/// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop,
|
|
/// the midpoint it stands for.
|
|
fn midpoint_stops(xml: &roxmltree::Document) -> HashMap<String, Vec<Option<f32>>> {
|
|
xml.descendants()
|
|
.filter(|n| matches!(n.tag_name().name(), "linearGradient" | "radialGradient"))
|
|
.filter_map(|g| {
|
|
let id = g.attribute("id")?;
|
|
let marks: Vec<Option<f32>> = g
|
|
.children()
|
|
.filter(|c| c.tag_name().name() == "stop")
|
|
.map(|c| c.attribute("data-vc-midpoint").and_then(|v| v.parse().ok()))
|
|
.collect();
|
|
marks.iter().any(Option::is_some).then(|| (id.to_string(), marks))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A gradient's stops, with the midpoint stops `marks` names folded back into the midpoints of
|
|
/// the stops before them.
|
|
fn gradient_stops(g: &usvg::BaseGradient, marks: Option<&[Option<f32>]>) -> Vec<GradientStop> {
|
|
let marks = marks.filter(|m| m.len() == g.stops().len());
|
|
let mut out: Vec<GradientStop> = Vec::with_capacity(g.stops().len());
|
|
for (i, s) in g.stops().iter().enumerate() {
|
|
if let (Some(mid), Some(prev)) = (marks.and_then(|m| m.get(i).copied().flatten()), out.last_mut()) {
|
|
prev.midpoint = mid.clamp(0.0, 1.0);
|
|
continue;
|
|
}
|
|
let c = s.color();
|
|
out.push(GradientStop { opacity: s.opacity().get(), ..GradientStop::new(s.offset().get(), Color::rgb8(c.red, c.green, c.blue)) });
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The options [`crate::export::MASK_FLAGS`] records on exported `<mask>` elements, by mask id:
|
|
/// (clip, invert).
|
|
fn mask_flags(xml: &roxmltree::Document) -> HashMap<String, (bool, bool)> {
|
|
let attr = crate::export::MASK_FLAGS;
|
|
if !xml.input_text().contains(attr) {
|
|
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 [`prepass`] gives the elements it has to find again that have none.
|
|
const MADE_UP_ID: &str = "vectorcraft-id-";
|
|
|
|
/// Is `id` one [`prepass`] made up (not a name)?
|
|
fn made_up(id: &str) -> bool {
|
|
id.starts_with(MADE_UP_ID)
|
|
}
|
|
|
|
/// The URL an `<a>` element links to.
|
|
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())
|
|
}
|
|
|
|
/// What usvg would lose, found in the XML before it runs ([`prepass`]).
|
|
#[derive(Default)]
|
|
struct Found {
|
|
/// The URL of each `<a href>`'s group, by its id.
|
|
links: HashMap<String, String>,
|
|
/// Ids of the undisplayed objects shown for usvg: they come back hidden.
|
|
hidden: HashSet<String>,
|
|
/// The `<symbol>` id each `<use>` of a symbol shows, by the `<use>`'s id.
|
|
uses: HashMap<String, String>,
|
|
/// The files `<image>` elements link to.
|
|
files: files::Files,
|
|
/// Ids of the shapes whose strokes don't scale ([`vector_effect`]).
|
|
non_scaling: HashSet<String>,
|
|
/// The `data-*` attributes of elements, by id ([`user_data`]).
|
|
data: HashMap<String, Vec<(String, String)>>,
|
|
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
|
|
sublayers: HashSet<String>,
|
|
warnings: Vec<String>,
|
|
}
|
|
|
|
/// Changes to the SVG text: `(range, replacement)`, an empty range inserting.
|
|
#[derive(Default)]
|
|
pub(super) struct Edits {
|
|
list: Vec<(std::ops::Range<usize>, String)>,
|
|
/// The ids made up so far, by element.
|
|
ids: HashMap<roxmltree::NodeId, String>,
|
|
}
|
|
|
|
impl Edits {
|
|
fn insert(&mut self, at: usize, text: String) {
|
|
self.list.push((at..at, text));
|
|
}
|
|
|
|
/// The id of element `n` (in `svg`), made up and written in when it has none.
|
|
fn id(&mut self, svg: &str, n: XNode) -> String {
|
|
if let Some(id) = n.attribute("id").filter(|id| !id.is_empty()) {
|
|
return id.to_string();
|
|
}
|
|
if let Some(id) = self.ids.get(&n.id()) {
|
|
return id.clone();
|
|
}
|
|
let id = format!("{MADE_UP_ID}{}", self.ids.len());
|
|
self.set(svg, n, "id", &id);
|
|
self.ids.insert(n.id(), id.clone());
|
|
id
|
|
}
|
|
|
|
/// Set attribute `name` of element `n` (in `svg`) to `value` (XML-escaped already).
|
|
pub(super) fn set(&mut self, svg: &str, n: XNode, name: &str, value: &str) {
|
|
match n.attributes().find(|a| a.name() == name && a.namespace().is_none()) {
|
|
Some(a) => self.list.push((a.range_value(), value.to_string())),
|
|
None => self.insert(tag_name_end(svg, n), format!(" {name}=\"{value}\"")),
|
|
}
|
|
}
|
|
|
|
/// `svg` with the edits made; overlapping ones and ranges off character boundaries are
|
|
/// skipped.
|
|
fn apply(mut self, svg: &str) -> Cow<'_, str> {
|
|
if self.list.is_empty() {
|
|
return svg.into();
|
|
}
|
|
// Stable: inserts at one place keep their order.
|
|
self.list.sort_by_key(|(r, _)| (r.start, r.end));
|
|
let mut out = String::with_capacity(svg.len() + self.list.iter().map(|(_, t)| t.len()).sum::<usize>());
|
|
let mut last = 0;
|
|
for (r, text) in self.list {
|
|
let (Some(part), Some(_)) = (svg.get(last..r.start), svg.get(r.clone())) else { continue };
|
|
out.push_str(part);
|
|
out.push_str(&text);
|
|
last = r.end;
|
|
}
|
|
out.push_str(svg.get(last..).unwrap_or(""));
|
|
out.into()
|
|
}
|
|
}
|
|
|
|
/// usvg reads `<a href>` and `<use>` as plain groups, drops what isn't displayed and reads linked
|
|
/// files on its own. Before it runs, the SVG is changed so that what we keep can be found:
|
|
/// * every `<a href>` and every `<use>` of a `<symbol>` gets an id when it has none
|
|
/// ([`MADE_UP_ID`]…), recorded with its URL or symbol;
|
|
/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded,
|
|
/// to come back hidden; one something links to (a `<use>` template) stays as it is;
|
|
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
|
|
/// * linked files are read ([`files`]).
|
|
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
|
|
let mut found = Found::default();
|
|
// (`:` for prefixed elements such as `<svg:image>`.)
|
|
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t)) {
|
|
return (svg.into(), found);
|
|
}
|
|
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
|
|
return (svg.into(), found);
|
|
};
|
|
let mut edits = Edits::default();
|
|
let elements = || xml.descendants().filter(|n| n.is_element());
|
|
for a in elements().filter(|n| n.tag_name().name() == "a") {
|
|
if let Some(url) = href(a) {
|
|
found.links.insert(edits.id(svg, a), url.to_string());
|
|
}
|
|
}
|
|
if svg.contains("data-") {
|
|
for e in elements().filter(|e| OBJECT_ELEMENTS.contains(&e.tag_name().name())) {
|
|
let data = user_data(e);
|
|
if !data.is_empty() {
|
|
found.data.insert(edits.id(svg, e), data);
|
|
}
|
|
}
|
|
}
|
|
found.sublayers = elements().filter(|g| marked_layer(*g)).filter_map(|g| g.attribute("id")).map(str::to_string).collect();
|
|
let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect();
|
|
for u in elements().filter(|n| n.tag_name().name() == "use") {
|
|
if let Some(sym) = href(u).and_then(|h| h.strip_prefix('#')).filter(|s| symbols.contains(s)) {
|
|
found.uses.insert(edits.id(svg, u), sym.to_string());
|
|
}
|
|
}
|
|
let (display, non_scaling) = (svg.contains("display"), svg.contains(vector_effect::NON_SCALING));
|
|
if display || non_scaling {
|
|
let css = css::Styles::new(&xml);
|
|
if display {
|
|
hidden_objects(svg, &xml, &css, &mut edits, &mut found.hidden);
|
|
}
|
|
if non_scaling {
|
|
vector_effect::find(svg, &xml, &css, &mut edits, &mut found.non_scaling, &mut found.warnings);
|
|
}
|
|
}
|
|
found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings);
|
|
(edits.apply(svg), found)
|
|
}
|
|
|
|
/// The elements that are objects (what [`hidden_objects`] shows).
|
|
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];
|
|
|
|
/// Show the undisplayed objects outside `<defs>` (and the like) for usvg, recording their ids. The
|
|
/// objects in a `<switch>` count too (some apps wrap all their layers in one).
|
|
fn hidden_objects(svg: &str, xml: &roxmltree::Document, css: &css::Styles, edits: &mut Edits, ids: &mut HashSet<String>) {
|
|
let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect();
|
|
let mut todo: Vec<XNode> = xml.root_element().children().filter(XNode::is_element).collect();
|
|
while let Some(n) = todo.pop() {
|
|
let tag = n.tag_name().name();
|
|
if n.attribute("id").is_some_and(|id| linked.contains(id)) {
|
|
continue;
|
|
}
|
|
if matches!(tag, "g" | "a" | "switch") {
|
|
todo.extend(n.children().filter(XNode::is_element));
|
|
}
|
|
if !OBJECTS.contains(&tag) || css.own(n, "display").as_deref() != Some("none") {
|
|
continue;
|
|
}
|
|
ids.insert(edits.id(svg, n));
|
|
// A `style` declaration wins over the attribute and style sheet rules.
|
|
match n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) {
|
|
Some(a) => edits.insert(a.range_value().end, ";display:inline".to_string()),
|
|
None => edits.insert(tag_name_end(svg, n), " style=\"display:inline\"".to_string()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where the start tag of element `n` ends its name (where an attribute can go).
|
|
fn tag_name_end(svg: &str, n: XNode) -> usize {
|
|
let start = n.range().start;
|
|
svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i)
|
|
}
|
|
|
|
/// The elements that become objects, whose `data-*` attributes are kept.
|
|
const OBJECT_ELEMENTS: &[&str] = &["g", "a", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image"];
|
|
|
|
/// The most `data-*` attributes kept per element (the file is untrusted).
|
|
const MAX_DATA: usize = 64;
|
|
|
|
/// The `data-*` attributes of element `e`, in order, without the `data-` (ours, `data-name` and
|
|
/// `data-vc-*`, aside).
|
|
fn user_data(e: XNode) -> Vec<(String, String)> {
|
|
e.attributes()
|
|
.filter(|a| a.namespace().is_none())
|
|
.filter_map(|a| Some((a.name().strip_prefix("data-")?, a.value())))
|
|
.filter(|(k, _)| !k.is_empty() && *k != "name" && !k.starts_with("vc-"))
|
|
.take(MAX_DATA)
|
|
.map(|(k, v)| (k.to_string(), v.to_string()))
|
|
.collect()
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|
|
|
|
/// The names apps keep beside element ids, by id: `data-name`, `inkscape:label`, `serif:id` or
|
|
/// `aria-label` (the first one present).
|
|
fn labels(xml: &roxmltree::Document) -> HashMap<String, String> {
|
|
const SERIF: &str = "http://www.serif.com/";
|
|
xml.descendants()
|
|
.filter_map(|n| {
|
|
let id = n.attribute("id").filter(|id| !id.is_empty())?;
|
|
let name = n
|
|
.attribute("data-name")
|
|
.or_else(|| n.attribute((INKSCAPE, "label")))
|
|
.or_else(|| n.attribute((SERIF, "id")))
|
|
.or_else(|| n.attribute("aria-label"))?;
|
|
Some((id.to_string(), name.to_string()))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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() && only_clips(g)
|
|
}
|
|
|
|
/// Does group `g` leave its art as it is, but for a clip path (no mask, filter, opacity or blend)?
|
|
fn only_clips(g: &usvg::Group) -> bool {
|
|
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
|
|
}
|
|
|
|
/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document.
|
|
fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> {
|
|
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);
|
|
shapes
|
|
}
|
|
|
|
/// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`,
|
|
/// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.)
|
|
fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool {
|
|
let Some(b) = art.visual_bounds() else { return false };
|
|
if cp.clip_path().is_some() {
|
|
return false;
|
|
}
|
|
// Art touching the clip's edge isn't cut by it.
|
|
let e = 1e-6 * (b.width() + b.height()).max(1e-9);
|
|
let b = b.inflate(-e, -e);
|
|
let shapes = clip_shapes(cp, ts);
|
|
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)];
|
|
corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c)))
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// The child of `g` when it's all `g` holds and only wraps art: an id-less group that leaves
|
|
/// it as it is (but for a transform) and isn't a text.
|
|
fn wrapper<'g>(&self, g: &'g usvg::Group) -> Option<&'g usvg::Group> {
|
|
match g.children() {
|
|
[usvg::Node::Group(w)] if w.id().is_empty() && is_plain(w) && self.text_slot(w).is_none() => Some(w),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
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> {
|
|
let mut out = 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,
|
|
}?;
|
|
if self.hidden.contains(n.id()) {
|
|
out.visible = false;
|
|
}
|
|
self.keep_data(n.id(), &mut out);
|
|
Some(out)
|
|
}
|
|
|
|
/// Give `n` the `data-*` attributes of element `id`, unless it has data already (what an
|
|
/// inner element made of it keeps).
|
|
fn keep_data(&self, id: &str, n: &mut Node) {
|
|
if let Some(data) = self.data.get(id).filter(|_| n.attrs.as_ref().is_none_or(|a| a.data.is_empty())) {
|
|
n.edit_attrs(|a| a.data = data.clone());
|
|
}
|
|
}
|
|
|
|
/// A gradient's stops, midpoints restored.
|
|
fn stops(&self, g: &usvg::BaseGradient) -> Vec<GradientStop> {
|
|
gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice))
|
|
}
|
|
|
|
/// The object name for an element id: its label (see [`labels`]), else the id itself.
|
|
fn name_of<'a>(&'a self, id: &'a str) -> &'a str {
|
|
self.labels.get(id).map_or(id, String::as_str)
|
|
}
|
|
|
|
/// A new object named after element `id` (unless it has none or a made-up one).
|
|
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() && !made_up(id) {
|
|
n.name = Some(self.name_of(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 = match self.instance(g, acc) {
|
|
Some(n) => n,
|
|
None => self.group_node(g, acc)?,
|
|
};
|
|
if let Some(url) = url {
|
|
link(&mut n, &url);
|
|
}
|
|
Some(n)
|
|
}
|
|
|
|
/// The group of a `<use>` of a `<symbol>` → an instance of the symbol made from it (on its
|
|
/// first `<use>`). `None` when the group has to be plain art: it shows the symbol differently
|
|
/// (properties inherited from the `<use>`), or the canvas would paint the instance differently
|
|
/// under its transform (see [`Reuse`]), or its viewport cuts the art, or it filters.
|
|
fn instance(&mut self, g: &usvg::Group, acc: Affine) -> Option<Node> {
|
|
let sym = self.uses.get(g.id())?.clone();
|
|
if !g.filters().is_empty() {
|
|
return None;
|
|
}
|
|
// usvg puts the art in a group moved by the `<use>`'s position and the symbol's viewBox.
|
|
let ts = acc * aff(g.transform());
|
|
let (mut xf, mut inner) = (ts, g);
|
|
while let [usvg::Node::Group(s)] = inner.children()
|
|
&& s.id().is_empty()
|
|
&& is_plain(s)
|
|
&& self.text_slot(s).is_none()
|
|
{
|
|
xf *= aff(s.transform());
|
|
inner = s;
|
|
}
|
|
let mut kids = self.children(inner, Affine::IDENTITY);
|
|
let art = match kids.len() {
|
|
0 => return None,
|
|
1 => Arc::unwrap_or_clone(kids.pop()?),
|
|
_ => self.named("", NodeKind::Group { children: kids, clip: false }),
|
|
};
|
|
// usvg clips a `<use>` of a symbol to its viewport: that only counts when it cuts the art.
|
|
let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf));
|
|
let name = match self.symbols.get(&sym) {
|
|
Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(),
|
|
Some(_) => return None,
|
|
None => {
|
|
let name = unique_name(self.name_of(&sym), |n| self.doc.symbols.iter().any(|s| s.name == n));
|
|
let reuse = Reuse::of(&self.doc, &art);
|
|
let made = SymbolMade { name: name.clone(), art: without_ids(&art), reuse };
|
|
self.doc.symbols.push(vectorcraft_doc::Symbol { name: name.clone(), art: Arc::new(art) });
|
|
self.symbols.insert(sym, made);
|
|
if !whole || !reuse.allows(xf) {
|
|
return None;
|
|
}
|
|
name
|
|
}
|
|
};
|
|
let label = if made_up(g.id()) { "a symbol instance".to_string() } else { format!("'{}'", g.id()) };
|
|
let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label));
|
|
let mut n = self.named(g.id(), NodeKind::SymbolInstance { symbol: name, xf });
|
|
n.opacity = g.opacity().get();
|
|
n.blend = blend(g.blend_mode());
|
|
n.isolate = g.isolate();
|
|
n.mask = mask;
|
|
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, non_scaling, under, .. } = self.slots.texts.get(i)?.clone();
|
|
// The scale the type draws at becomes its size (last, below): text space grows by `grow`.
|
|
let xf = ts * obj.xf;
|
|
let grow = text::size_scale(&obj, xf);
|
|
// 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 = Affine::scale(grow) * obj.xf.inverse();
|
|
let slot_paint = |im: &mut Self, k: usize, m: Affine| match kids.get(main + 1 + k) {
|
|
Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| {
|
|
let area = kurbo::Shape::bounding_box(&bezpath(p.data(), m));
|
|
im.paint(f.paint(), m, Some(area))
|
|
}),
|
|
_ => Paint::None,
|
|
};
|
|
let resolved: Vec<Paint> = (0..paints).map(|k| slot_paint(self, k, to_text)).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 = xf;
|
|
if non_scaling && !vector_effect::unscale_text(&mut obj, self.screen) {
|
|
let label = if name.is_empty() || made_up(&name) { "a text".to_string() } else { format!("text '{name}'") };
|
|
self.warn(format!("non-scaling stroke on {label} approximated: the text is stretched or skewed"));
|
|
}
|
|
// A stroke under the characters is the object's own: it paints in the document.
|
|
let under = under.map(|(mut sl, k)| {
|
|
if let Some(k) = k {
|
|
sl.paint = slot_paint(self, k, ts);
|
|
}
|
|
let mut ap = Appearance { items: vec![AppearanceItem::Stroke(sl)], ..Default::default() };
|
|
ap.scale_strokes(if non_scaling { self.screen } else { obj.xf.determinant().abs().sqrt() });
|
|
ap.set_contents_at(1);
|
|
ap
|
|
});
|
|
text::fold_scale(&mut obj, grow);
|
|
let mut n = self.named(&name, NodeKind::Text(Box::new(obj)));
|
|
if let Some(ap) = under {
|
|
n.appearance = ap;
|
|
}
|
|
Some(vec![Arc::new(n)])
|
|
}
|
|
|
|
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();
|
|
// Made-up ids aren't names.
|
|
let id = if is_text || made_up(g.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));
|
|
// Blurs, shadows and glows become live effects (see [`fx`]).
|
|
let effects = match g.filters() {
|
|
[] => vec![],
|
|
filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| {
|
|
self.warn(format!("filter on {label} ignored"));
|
|
vec![]
|
|
}),
|
|
};
|
|
let mut children = match text {
|
|
Some(t) => t,
|
|
None => self.children(g, ts),
|
|
};
|
|
let mut n = if let Some(cp) = g.clip_path() {
|
|
// SVG filters before it clips: the effects go on the art inside the clip.
|
|
if !effects.is_empty() {
|
|
let mut inner = self.named("", NodeKind::Group { children, clip: false });
|
|
inner.appearance.effects = effects.clone();
|
|
children = vec![Arc::new(inner)];
|
|
}
|
|
let children = self.clipped(cp, ts, children, &label)?;
|
|
self.named(id, NodeKind::Group { children, 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, blend and effects into the object (unless the object
|
|
// blends or masks before the effects). 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)
|
|
&& (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].mask.is_none())
|
|
&& 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();
|
|
if mask.is_some() {
|
|
c.mask = mask;
|
|
}
|
|
c.appearance.effects.extend(effects);
|
|
return Some(c);
|
|
}
|
|
let mut n = self.named(id, NodeKind::Group { children, clip: false });
|
|
n.appearance.effects = effects;
|
|
n
|
|
};
|
|
n.opacity = g.opacity().get();
|
|
n.blend = blend(g.blend_mode());
|
|
n.isolate = g.isolate();
|
|
n.mask = mask;
|
|
if !is_text && self.sublayers.contains(g.id()) {
|
|
self.sublayer_groups.insert(n.id);
|
|
}
|
|
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))
|
|
}
|
|
|
|
/// `children` clipped by `cp` (on an element whose user space `ts` maps to the document): the
|
|
/// children of a clip group or clipping layer, its clipping path first. A clip path clipped in
|
|
/// turn (`<clipPath clip-path>`) nests one clip group in another, so the clips intersect.
|
|
/// `None` when the clip path has no shape (nothing shows).
|
|
fn clipped(&mut self, cp: &usvg::ClipPath, ts: Affine, mut children: Vec<Arc<Node>>, label: &str) -> Option<Vec<Arc<Node>>> {
|
|
let mut clips = vec![self.clip_node(cp, ts)?];
|
|
let mut next = cp.clip_path();
|
|
while let Some(c) = next {
|
|
if clips.len() > MAX_CLIP_NEST {
|
|
self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored"));
|
|
break;
|
|
}
|
|
clips.push(self.clip_node(c, ts)?);
|
|
next = c.clip_path();
|
|
}
|
|
let outer = clips.pop()?;
|
|
for clip in clips {
|
|
let mut ch = vec![Arc::new(clip)];
|
|
ch.extend(children);
|
|
children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))];
|
|
}
|
|
let mut ch = vec![Arc::new(outer)];
|
|
ch.extend(children);
|
|
Some(ch)
|
|
}
|
|
|
|
/// Put `art` in layer `l` at `index` (at most its end) unclipped: a clipping mask on the layer
|
|
/// becomes a clip group in it, holding the art it clipped.
|
|
fn add_unclipped(&mut self, l: &mut Node, index: usize, art: Vec<Arc<Node>>) {
|
|
if art.is_empty() {
|
|
return;
|
|
}
|
|
if let NodeKind::Layer { children, clip: clip @ true, .. } = &mut l.kind {
|
|
*clip = false;
|
|
let clipped = std::mem::take(children);
|
|
children.push(Arc::new(self.named("", NodeKind::Group { children: clipped, clip: true })));
|
|
}
|
|
if let Some(ch) = l.children_mut() {
|
|
let i = index.min(ch.len());
|
|
ch.splice(i..i, art);
|
|
}
|
|
}
|
|
|
|
fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option<Node> {
|
|
let shapes = clip_shapes(cp, ts);
|
|
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 }))
|
|
}
|
|
|
|
/// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or
|
|
/// repeated gradient has to reach).
|
|
fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option<Rect>) -> Paint {
|
|
let (kind, base, mut geom) = match p {
|
|
usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)),
|
|
usvg::Paint::Pattern(pt) => return self.pattern(pt, m),
|
|
usvg::Paint::LinearGradient(lg) => {
|
|
let 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,
|
|
};
|
|
let base: &usvg::BaseGradient = lg;
|
|
(GradientKind::Linear, base, geom)
|
|
}
|
|
usvg::Paint::RadialGradient(rg) => {
|
|
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)));
|
|
let base: &usvg::BaseGradient = rg;
|
|
(GradientKind::Radial, base, geom)
|
|
}
|
|
};
|
|
geom.transform(m * aff(base.transform()), kind);
|
|
let mut stops = self.stops(base);
|
|
let reflect = match base.spread_method() {
|
|
usvg::SpreadMethod::Pad => None,
|
|
usvg::SpreadMethod::Reflect => Some(true),
|
|
usvg::SpreadMethod::Repeat => Some(false),
|
|
};
|
|
if let Some(reflect) = reflect {
|
|
match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) {
|
|
Some(spread::Expanded::Whole) => {}
|
|
Some(spread::Expanded::Capped) => {
|
|
self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS))
|
|
}
|
|
None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())),
|
|
}
|
|
}
|
|
Paint::Gradient(Box::new(GradientPaint {
|
|
gradient: Gradient { kind, stops },
|
|
geom: Some(geom),
|
|
angle: geom.angle_deg(),
|
|
swatch: None,
|
|
freeform: None,
|
|
}))
|
|
}
|
|
|
|
/// `<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 name = unique_name(base, |n| self.doc.pattern(n).is_some());
|
|
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 }
|
|
}
|
|
|
|
/// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`.
|
|
fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option<Rect>) -> (Appearance, FillRule) {
|
|
let mut fill = None;
|
|
let mut r = FillRule::NonZero;
|
|
if let Some(f) = p.fill() {
|
|
let paint = self.paint(f.paint(), m, area);
|
|
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() {
|
|
// A non-scaling stroke is as wide on screen whatever `m` does: the path takes `m`, so a
|
|
// stroke of that width in the document is exact.
|
|
let scale = if self.non_scaling.contains(p.id()) { self.screen } else { m.determinant().abs().sqrt() };
|
|
// The stroke reaches past the path (miters further: a generous margin).
|
|
let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0);
|
|
let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach)));
|
|
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, path.bounds());
|
|
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(tree) => return self.svg_image(tree, acc),
|
|
};
|
|
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));
|
|
// A linked file keeps a preview; a placeholder is all a missing one has (its preview).
|
|
let linked = self.files.linked(i.kind());
|
|
let missing = linked.is_some_and(|l| l.missing);
|
|
let link = linked.map(|l| l.link.clone());
|
|
self.doc.images.entry(key.clone()).or_insert_with(|| {
|
|
let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) };
|
|
if link.is_some() && !missing { blob.with_proxy() } else { blob }
|
|
});
|
|
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, placement: Default::default() })))
|
|
}
|
|
|
|
/// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size.
|
|
/// Its ids are its own: they mean nothing in the SVG around it.
|
|
fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option<Node> {
|
|
let outer = (
|
|
std::mem::take(&mut self.links),
|
|
std::mem::take(&mut self.hidden),
|
|
std::mem::take(&mut self.uses),
|
|
std::mem::take(&mut self.labels),
|
|
std::mem::take(&mut self.non_scaling),
|
|
std::mem::take(&mut self.data),
|
|
);
|
|
let art = self.group_node(tree.root(), acc);
|
|
(self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data) = outer;
|
|
art
|
|
}
|
|
}
|