Files
vectorcraft/crates/svg/src/import.rs
T
dotCooCoo 60e1600caa SVG: circles and ellipses import as four-anchor ellipses
usvg turns each <circle> and <ellipse> into arcs and then into a number
of cubics that depends on f32 rounding: an ellipse that export wrote as
<ellipse> could come back with eight anchors instead of the four it was
written from. The renderer flattens the two outlines differently, so a
round trip of a flat filled ellipse redrew 158 edge pixels by up to 43
levels, and prop_svg::plain_art_survives_visually failed on main.

Import now records the ids of <circle> and <ellipse> elements in the
prepass and rebuilds each as the ellipse VectorCraft draws: four smooth
anchors on the axes, starting at the right as SVG defines, with handles
KAPPA of the radii long, then transformed as before.

Tests: prop_svg::filled_ellipse_on_the_artboard_edge_survives_visually
(the failing case) and basic_shapes::circles_and_ellipses_import_as_the_ellipse_drawn
fail without the change.
2026-11-02 02:49:36 -08:00

336 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, ForeignAttribute, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, MAX_SVG_SCRIPTS,
Node, NodeId, NodeKind, PatternDef, SVG_EVENTS, 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 use depth::MAX_ELEMENT_DEPTH;
pub(crate) use depth::xml_too_deep;
pub use files::MAX_NESTED_SVG_IMAGES;
pub(crate) use fx::BLEND;
/// Most objects an imported SVG may expand to: a guard against `<use>` and nested-image
/// expansion bombs (#3564).
pub const MAX_NODES: usize = if cfg!(target_arch = "wasm32") { 500_000 } else { 2_000_000 };
mod convert;
mod importer;
mod layers;
mod prepass;
pub(super) use convert::href;
use convert::{aff, made_up, mask_flags, midpoint_stops, only_clips};
pub(super) use importer::PT_PER_IN;
use importer::SymbolMade;
pub(super) use layers::DEFAULT_FONT_SIZE;
pub(crate) use layers::SUBLAYER;
use layers::{RootUnits, inkscape_pages, promote_sublayers, root_layer_name};
pub(super) use prepass::Edits;
pub use prepass::{MAX_FOREIGN, MAX_FOREIGN_VALUE};
use prepass::{labels, prepass};
mod css;
mod css_transform;
mod depth;
mod files;
mod fx;
mod spread;
mod symbol_units;
mod text;
mod vector_effect;
/// The XML of `svg`.
fn parse(svg: &str) -> Result<roxmltree::Document<'_>, SvgError> {
if crate::entities::expansion_too_large(svg) {
return Err(SvgError::Parse("the entities the document declares expand to too much text".into()));
}
roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() })
.map_err(|e| SvgError::Parse(e.to_string()))
}
const SVG_NS: &str = "http://www.w3.org/2000/svg";
/// Refuse the one `feConvolveMatrix` usvg cannot handle: an `order` whose two sides overflow the
/// `order_x * order_y` u32 multiply its parser makes (`usvg-0.48.1/src/parser/filter.rs:673`, and
/// `tree/filter.rs:458` through `ConvolveMatrixData::new`). usvg parses `order` through f64 and
/// `as i32`, so a large finite value like `1066570220609` saturates to i32::MAX — positive, so it
/// passes the positivity guard — and the multiply overflows: a debug or overflow-checks build
/// panics while opening the file. A release build wraps instead, and with a one-entry
/// `kernelMatrix` the wrapped product passes usvg's length check, leaving a filter that claims a
/// 2147483647×2147483647 kernel (which VectorCraft then drops as an unsupported filter primitive,
/// so nothing reaches the document). Any order usvg can multiply — however large — keeps
/// importing exactly as before: a missing, unparseable or non-positive `order` falls back to 3,
/// and a `kernelMatrix` that fails to parse or match the order leaves the filter empty, which
/// usvg drops. The attribute read here is the one usvg reads: unprefixed (XML keeps unprefixed
/// attributes outside any namespace), on an element in the SVG namespace, with entities resolved
/// by the parse above.
fn convolve_matrices_ok(xml: &roxmltree::Document) -> Result<(), SvgError> {
for fe in xml.descendants() {
if !fe.has_tag_name((SVG_NS, "feConvolveMatrix")) {
continue;
}
if let Some(value) = fe.attribute("order") {
// The parse usvg does: svgtypes numbers, `as i32` (saturation included), the first
// side defaulting the second, and both positive or both back to the 3×3 default.
let side = |n: Option<Result<f64, svgtypes::Error>>, default: i32| n.and_then(|v| v.ok()).map(|v| v as i32).unwrap_or(default);
let mut numbers = svgtypes::NumberListParser::from(value);
let x = side(numbers.next(), 3);
let y = side(numbers.next(), x);
if x > 0 && y > 0 && (x as u32).checked_mul(y as u32).is_none() {
return Err(SvgError::Parse(format!(
"a feConvolveMatrix filter's order is {x}×{y}, which overflows the kernel-size multiply usvg makes"
)));
}
}
}
Ok(())
}
/// The document SVG text `svg` holds, read with `opts`, and the warnings about what it couldn't keep.
pub(crate) fn import(svg: &str, opts: &ImportOptions, max_nodes: usize) -> Result<(Document, Vec<String>), SvgError> {
// Before anything reads the tree: the XML reader, usvg, and what is made of its tree, all by
// recursion.
let too_deep = || SvgError::TooDeep(depth::MAX_ELEMENT_DEPTH);
let scan = depth::prescan(svg);
if scan.too_deep {
return Err(too_deep());
}
let xml = parse(svg)?;
let Some(height) = depth::height(&xml, depth::MAX_ELEMENT_DEPTH) else {
return Err(too_deep());
};
let (svg, found) = prepass(svg, &scan.markers, &xml, opts);
// The prepass's edits are in the text: the XML is read again only when it made any.
let xml = match &svg {
Cow::Borrowed(_) => xml,
Cow::Owned(edited) => parse(edited)?,
};
// Before usvg sees the XML: its parser overflows a hostile feConvolveMatrix's order
// (`convolve_matrices_ok` below), and there is no panic guard to fall back on.
convolve_matrices_ok(&xml)?;
let svg = svg.as_ref();
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(height),
..usvg::Options::default()
};
usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?
};
let size = tree.size();
let k = units.k;
let mut doc = Document::new(size.width() as f64 * k, size.height() as f64 * k);
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, &scan.markers),
files,
non_scaling: found.non_scaling,
data: found.data,
events: found.events,
foreign: found.foreign,
sublayers: found.sublayers,
sublayer_groups: HashSet::new(),
ellipses: found.ellipses,
screen: k,
converted_nodes: 1,
max_nodes,
error: None,
nested_svg_images: 0,
};
im.doc.svg_scripts = found.scripts;
if im.files.nested_text() {
im.warn("text inside an SVG image isn't imported".into());
}
for note in im.files.left_out() {
im.warn(note);
}
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
let mut top = tree.root();
let mut base = Affine::scale(k) * aff(top.transform());
if let Some(g) = im.wrapper(top) {
base *= aff(g.transform());
top = g;
}
if let Some(boards) = inkscape_pages(&xml, base) {
im.doc.artboards = boards;
}
// 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 Some(id) = im.alloc_id() else {
return Err(im.error.unwrap_or(SvgError::TooLarge(im.max_nodes)));
};
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.keep_events(g.id(), &mut l);
im.keep_foreign(g.id(), &mut l);
im.doc.layers.push(Arc::new(l));
}
if let Some(err) = im.error.clone() {
return Err(err);
}
} 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(err) = im.error.clone() {
return Err(err);
}
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 let Some(err) = im.error {
return Err(err);
}
if !im.sublayer_groups.is_empty() {
for l in &mut im.doc.layers {
promote_sublayers(Arc::make_mut(l), &im.sublayer_groups);
}
}
im.doc.limit_depth_noting(&mut im.warnings);
Ok((im.doc, im.warnings))
}
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)>>,
/// The event attributes of elements, by id ([`user_events`]).
events: HashMap<String, Vec<(String, String)>>,
/// The attributes of elements in other applications' namespaces, by id ([`foreign`]).
foreign: HashMap<String, Vec<ForeignAttribute>>,
/// 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>,
/// Ids of the `<circle>` and `<ellipse>` elements ([`prepass`]).
ellipses: HashSet<String>,
/// Points per screen pixel (the root's user unit before its `viewBox`): what a non-scaling
/// stroke's width is measured in.
screen: f64,
/// How many objects were converted so far.
converted_nodes: usize,
/// Most objects the document may expand to.
max_nodes: usize,
/// An error encountered while converting objects (e.g. [`SvgError::TooLarge`]).
error: Option<SvgError>,
/// How many nested SVG images were converted so far.
nested_svg_images: usize,
}