Files
vectorcraft/crates/svg/src/import.rs
T
SusieeTheFemboyandGLM-5.3-Flash c7dc443ee1 SVG import: a feConvolveMatrix order that overflows usvg's multiply is refused
usvg 0.48 casts the filter's order attribute through i32, so a value like
1066570220609 saturates and the u32 order_x * order_y multiply overflows.
Only debug and overflow-checks builds panic on that; release builds already
import the file — the multiply wraps, and VectorCraft drops the filter as
an unsupported primitive with a warning, so nothing reaches the document,
render or export (observed on main in a --release build: both repro files
import without error; with a one-entry kernelMatrix the wrapped product
2147483647² mod 2³² = 1 even passes usvg's length check and leaves a filter
claiming a 2147483647×2147483647 kernel, still dropped downstream).

The import now parses order the way usvg does (svgtypes numbers, the same
as-i32 cast with its saturation, the first side defaulting the second, both
positive or both back to the 3×3 default) and refuses exactly the one case:
when the product of the two sides overflows u32 (checked_mul). Any order
usvg can multiply keeps importing as before, however large on its own, and
a missing, unparseable or non-positive order still falls back to 3.

Regression tests cover the overflow repros, the release wrap case, large
but fitting orders (100, 80 80, 65535 65535, 2147483647 1), the
pass-through shapes, and sane kernels, in debug and in release.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
2026-10-31 19:43:17 +07:00

333 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(),
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>,
/// 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,
}