diff --git a/Cargo.lock b/Cargo.lock index 13b4f60f..f80573c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1162,6 +1162,15 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "craft-svg-paths" +version = "0.1.0" +dependencies = [ + "kurbo 0.13.1", + "roxmltree 0.21.2", + "thiserror 2.0.21", +] + [[package]] name = "craft-text" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index fdfcf3b6..8974cbd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,6 +51,7 @@ craft-print = { path = "crates/print" } craft-proof = { path = "crates/proof" } craft-quit = { path = "crates/quit" } craft-settings = { path = "crates/settings" } +craft-svg-paths = { path = "crates/svg-paths" } craft-text = { path = "crates/text" } craft-tiff = { path = "crates/tiff" } craft-ui = { path = "crates/ui" } diff --git a/README.md b/README.md index 460b45be..180e3ff5 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "`/`` instances and the CSS cascade as Bézier paths in millimetres, each + tagged by the caller. Import only; not the shared SVG model. - [`craft-text`](crates/text/README.md): text processing below the composer. Its `hyphen` module finds hyphenation points, with a bundled US English dictionary and patterns and optional hypher patterns for 47 more languages (one cargo feature each). diff --git a/crates/svg-paths/Cargo.toml b/crates/svg-paths/Cargo.toml new file mode 100644 index 00000000..7567f23f --- /dev/null +++ b/crates/svg-paths/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "craft-svg-paths" +description = "SVG geometry reader: shapes, transforms, use/symbol instances and the CSS cascade, as Bézier paths in millimetres" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +roxmltree = "0.21.2" +thiserror = { workspace = true } diff --git a/crates/svg-paths/src/lib.rs b/crates/svg-paths/src/lib.rs new file mode 100644 index 00000000..605853fa --- /dev/null +++ b/crates/svg-paths/src/lib.rs @@ -0,0 +1,1984 @@ +//! Reads the geometry of an SVG document as Bézier paths in millimetres. +//! +//! For tools that need the shapes of a drawing and not its paint: cutters, plotters, CAD import. +//! [`import`] walks the document, applies transforms, viewport units, ``/`` +//! instances and the CSS cascade, and returns each drawable element as a [`kurbo::BezPath`] in +//! millimetres, with its stroke paint and the clip regions that apply to it. The caller tags +//! shapes through [`Kind`] (cut or engrave, layer, pen…) from each element's computed style and +//! class. Input is treated as hostile: size, node, depth and instance limits, and cyclic +//! references, are errors. +//! +//! Import only, and geometry only: this is not the shared SVG model of craft-libs#789. Boolean +//! operations (applying [`Clip`]s) and colour parsing are left to the caller, so the crate +//! depends on no other Craft crate. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +use kurbo::BezPath; + +/// Why an SVG could not be read; the message starts with the name given to [`import`]. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub struct Error(pub String); + +/// An element's computed properties, by name: the ones [`Kind::PROPERTIES`] lists and a few +/// presentation properties. +pub type StyleMap = std::collections::HashMap; + +/// The caller's tag on a shape, inherited down the element tree. +pub trait Kind: Clone + Default { + /// Property names of the caller's own (`data-kind`, say) to follow through attributes, inline + /// styles and stylesheets, as well as the presentation properties the reader tracks itself. + const PROPERTIES: &'static [&'static str] = &[]; + + /// The tag an element sets, from its computed style and `class` attribute; `None` inherits + /// its parent's. + fn of(style: &StyleMap, class: &str) -> Option; + + /// Whether an element of this kind with `stroke: none` and `fill: none` is left out, with + /// its children. + fn skips_unpainted(&self) -> bool { + true + } +} + +/// For callers that only want the paths. +impl Kind for () { + fn of(_: &StyleMap, _: &str) -> Option { + None + } +} + +/// How a clip piece's interior is decided (`clip-rule`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FillRule { + NonZero, + EvenOdd, +} + +/// One `clip-path` region in document space: the union of its pieces. A clip with no pieces +/// clips everything away (CSS Masking §6.1). +#[derive(Debug, Clone, PartialEq)] +pub struct Clip { + pub pieces: Vec<(BezPath, FillRule)>, +} + +/// One drawable element, transformed into the document's millimetre space and not yet clipped. +#[derive(Debug, Clone, PartialEq)] +pub struct Shape { + pub path: BezPath, + pub kind: K, + /// ``, for the caller's own warnings about this element. + pub label: String, + /// The stroke colour as written (`currentColor` and paint-server fallbacks resolved), or + /// `None` without a stroke. + pub stroke: Option, + /// The regions the shape is clipped by, as indices into [`Imported::clips`], outermost first. + pub clips: Vec, +} + +/// What [`import`] read. Shapes may lie outside the `width_mm` × `height_mm` viewport. +#[derive(Debug, Clone, PartialEq)] +pub struct Imported { + pub width_mm: f64, + pub height_mm: f64, + pub shapes: Vec>, + pub clips: Vec, + /// What was skipped or approximated. A caller adding its own goes through [`push_warning`] + /// with `dropped_warnings`, to keep the list bounded. + pub warnings: Vec, + /// Warnings past the bound, counted and not kept. + pub dropped_warnings: usize, +} + +pub const MAX_SVG_BYTES: usize = 16 * 1024 * 1024; +pub const MAX_SVG_NODES: usize = 100_000; +pub const MAX_SVG_DEPTH: usize = 128; +const MAX_SVG_INSTANCES: usize = 10_000; + +#[derive(Clone)] +struct ImportState { + transform: kurbo::Affine, + kind: K, + display_none: bool, + visibility_hidden: bool, + inherited_style: std::collections::HashMap, + /// Active `clip-path` regions (indices into the context's list), outermost first. + clips: Vec, +} + +impl Default for ImportState { + fn default() -> Self { + Self { + transform: kurbo::Affine::IDENTITY, + kind: K::default(), + display_none: false, + visibility_hidden: false, + inherited_style: std::collections::HashMap::new(), + clips: Vec::new(), + } + } +} + +/// Most warnings one import keeps; the rest are counted in one summary line. +pub const MAX_SVG_WARNINGS: usize = 64; +/// Most nested `clip-path` regions one shape is clipped by. +const MAX_SVG_CLIPS: usize = 16; +/// Most clip-path pieces one import keeps, over all its clip regions. +const MAX_SVG_CLIP_PIECES: usize = 100_000; +/// Path segments kept across the whole document: `` can repeat one large path many times. +const MAX_SVG_SEGMENTS: usize = 4_000_000; +/// Selector tests across the whole document (rules × elements styled). +const MAX_CSS_SELECTOR_CHECKS: usize = 5_000_000; + +/// Reads the drawable geometry of an SVG document, in document order. +/// +/// Supported geometry: ``, `` (including rounded corners), ``, ``, +/// ``, `` and ``. Nested groups, transforms (`matrix`, `translate`, +/// `scale`, `rotate`, `skewX/Y`) and inherited presentation and inline styles (`display`, +/// `visibility`, CSS `style`, `class`) are applied. Embedded stylesheets support type, class, ID +/// and attribute selectors (`=`, `~=`, `^=`, `$=`, `*=`, `|=`), descendant, child and sibling +/// combinators, `:not()`, `:first-child`, `:last-child`, `:root`, selector lists, and a bounded +/// presentation-property cascade. Curves stay curves: nothing is flattened. +/// +/// Each shape carries its stroke paint as written and the `clip-path` regions that apply to it; +/// parsing the colour and cutting the shape down to its clips are the caller's. +/// +/// What cannot be represented is skipped with a line in [`Imported::warnings`]: `` (no +/// fonts here), masks and filters (the art comes unmasked and unfiltered), ignored CSS at-rules +/// and selectors, and clip paths that cannot be applied. A caller that must lose nothing +/// refuses an import with warnings. +/// +/// Physical root dimensions accept unitless SVG px, `px`, `mm`, `cm`, `in`, `pt` and `pc`; a +/// `viewBox` maps its user coordinates onto that physical viewport, following +/// `preserveAspectRatio`. Without physical dimensions, `viewBox` units are read as millimetres, +/// and so is a fragment without an SVG root. Local `` references instantiate supported +/// shapes, groups and `` templates with viewBox sizing; external references and +/// percentage lengths are not expanded. +/// +/// `name` (a file name, say) prefixes every error message. +pub fn import(svg: &str, name: &str) -> Result, Error> { + let path_name = name; + if svg.len() > MAX_SVG_BYTES { + return Err(svg_error(path_name, "SVG exceeds the 16 MiB import limit")); + } + let xml = roxmltree::Document::parse(svg).map_err(|e| svg_error(path_name, &format!("invalid XML: {e}")))?; + let root = xml.root_element(); + let (width_mm, height_mm, viewport) = viewport_transform(root, path_name)?; + + let mut id_index = std::collections::HashMap::new(); + let mut xml_node_count = 0usize; + for node in root.descendants() { + xml_node_count += 1; + if xml_node_count > MAX_SVG_NODES { + return Err(svg_error(path_name, "SVG exceeds the 100,000-node import limit")); + } + if let Some(id) = node.attribute("id") + && id_index.insert(id.to_string(), node).is_some() + { + return Err(svg_error(path_name, &format!("duplicate SVG id {id:?}"))); + } + } + let mut warnings = Vec::new(); + let css_rules = parse_stylesheets(root, K::PROPERTIES, path_name, &mut warnings)?; + let mut context = ImportContext { + visited: 0, + path_name, + id_index, + selectors: css_rules.iter().map(|rule| rule.selectors.len()).sum(), + css_rules, + reference_stack: Vec::new(), + instance_count: 0, + shapes: Vec::new(), + clips: Vec::new(), + clip_pieces: 0, + segments: 0, + selector_checks: 0, + warnings, + dropped_warnings: 0, + }; + let base = ImportState { transform: viewport, ..ImportState::default() }; + import_node(root, base, 0, &mut context)?; + Ok(Imported { + width_mm, + height_mm, + shapes: context.shapes, + clips: context.clips, + warnings: context.warnings, + dropped_warnings: context.dropped_warnings, + }) +} + +struct ImportContext<'a, 'input, K> { + visited: usize, + path_name: &'a str, + id_index: std::collections::HashMap>, + css_rules: Vec, + reference_stack: Vec, + instance_count: usize, + shapes: Vec>, + clips: Vec, + clip_pieces: usize, + segments: usize, + selectors: usize, + selector_checks: usize, + warnings: Vec, + dropped_warnings: usize, +} + +impl ImportContext<'_, '_, K> { + /// Counts a path about to be copied into the result against the document-wide limit. + fn charge_segments(&mut self, path: &BezPath) -> Result<(), Error> { + self.segments = self.segments.saturating_add(path.elements().len()); + if self.segments > MAX_SVG_SEGMENTS { + return Err(svg_error(self.path_name, "SVG exceeds the 4,000,000-segment import limit")); + } + Ok(()) + } + + /// Counts one element's pass over every stylesheet selector. + fn charge_cascade(&mut self) -> Result<(), Error> { + self.selector_checks = self.selector_checks.saturating_add(self.selectors); + if self.selector_checks > MAX_CSS_SELECTOR_CHECKS { + return Err(svg_error(self.path_name, "SVG stylesheet is too large for this many elements")); + } + Ok(()) + } +} + +/// Records a warning once, up to a fixed number; the rest are only counted in `dropped`. +pub fn push_warning(warnings: &mut Vec, dropped: &mut usize, message: String) { + if warnings.contains(&message) { + return; + } + if warnings.len() < MAX_SVG_WARNINGS { + warnings.push(message); + } else { + *dropped = dropped.saturating_add(1); + } +} + +impl ImportContext<'_, '_, K> { + fn warn(&mut self, message: String) { + push_warning(&mut self.warnings, &mut self.dropped_warnings, message); + } +} + +fn import_node<'a, 'input, K: Kind>( + node: roxmltree::Node<'a, 'input>, + inherited: ImportState, + depth: usize, + context: &mut ImportContext<'a, 'input, K>, +) -> Result<(), Error> { + context.visited = context.visited.checked_add(1).ok_or_else(|| svg_error(context.path_name, "SVG node counter overflow"))?; + if context.visited > MAX_SVG_NODES { + return Err(svg_error(context.path_name, "SVG exceeds the 100,000-node import limit")); + } + if depth > MAX_SVG_DEPTH { + return Err(svg_error(context.path_name, "SVG nesting exceeds 128 levels")); + } + let tag = node.tag_name().name(); + // Definitions are not drawable content unless instantiated via ``; + // references resolve directly into supported targets inside these containers. + // A marker's content is drawn only at path vertices, never where it is declared. + if matches!(tag, "defs" | "symbol" | "clipPath" | "mask" | "pattern" | "marker") { + return Ok(()); + } + if tag == "svg" && depth > 0 && ["x", "y", "width", "height", "viewBox", "preserveAspectRatio"].iter().any(|a| node.has_attribute(*a)) { + return Err(svg_error(context.path_name, "a nested with its own viewport is not supported yet")); + } + let mut state = inherited.clone(); + // SVG presentation attributes and inline declarations inherit. `display` + // is different: a descendant cannot override an ancestor's display:none. + if node.attribute("display") == Some("none") { + state.display_none = true; + } + if let Some(transform) = node.attribute("transform") { + let local = parse_transform(transform).map_err(|e| svg_error(context.path_name, &format!("<{}> transform: {e}", tag)))?; + state.transform = compose(inherited.transform, local); + } + context.charge_cascade()?; + let style = computed_style(node, &state.inherited_style, &context.css_rules, K::PROPERTIES); + state.inherited_style = inheritable(&style); + if let Some(kind) = K::of(&style, node.attribute("class").unwrap_or("")) { + state.kind = kind; + } + if style.get("display").is_some_and(|v| v == "none") { + state.display_none = true; + } + if let Some(visibility) = style.get("visibility") { + state.visibility_hidden = visibility == "hidden" || visibility == "collapse"; + } + let color_property = style.get("stroke").or_else(|| style.get("fill")); + if state.kind.skips_unpainted() && color_property.is_some_and(|v| *v == "none") { + // A line with fill:none can still be a cut path; stroke:none plus + // fill:none is empty and can safely be ignored. + if style.get("stroke").is_some_and(|v| v == "none") && style.get("fill").is_some_and(|v| v == "none") { + state.display_none = true; + } + } + if !state.display_none && !state.visibility_hidden { + for (property, effect) in [("mask", "the art is imported unmasked"), ("filter", "the art is imported unfiltered")] { + if style.get(property).is_some_and(|v| v != "none") { + context.warn(format!("{}: {property} ignored; {effect}", label(node))); + } + } + } + if tag == "text" { + // Hidden text is skipped quietly; its s never reach the geometry readers. + if !state.display_none && !state.visibility_hidden { + let content: String = node + .descendants() + .filter(|n| n.is_text()) + .filter_map(|n| n.text()) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(40) + .collect(); + context.warn(format!("{} {content:?} skipped: SVG text needs fonts; convert text to paths before importing", label(node))); + } + return Ok(()); + } + if let Some(value) = style.get("clip-path").filter(|v| v.as_str() != "none") + && let Some(region) = clip_region(node, value, state.transform, context)? + { + if state.clips.len() >= MAX_SVG_CLIPS { + return Err(svg_error(context.path_name, "clip-path nesting exceeds 16 levels")); + } + state.clips.push(region); + } + if tag == "use" { + if !state.display_none { + let href = node + .attribute("href") + .or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href"))) + .ok_or_else(|| svg_error(context.path_name, " has no href reference"))?; + let id = href + .strip_prefix('#') + .filter(|id| !id.is_empty()) + .ok_or_else(|| svg_error(context.path_name, " supports only local fragment references"))?; + if context.reference_stack.iter().any(|active| active == id) { + return Err(svg_error(context.path_name, &format!("cyclic reference to #{id}"))); + } + let target = + context.id_index.get(id).copied().ok_or_else(|| svg_error(context.path_name, &format!(" references missing id #{id}")))?; + if !matches!(target.tag_name().name(), "g" | "symbol" | "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon") { + return Err(svg_error(context.path_name, &format!(" reference #{id} targets unsupported <{}>", target.tag_name().name()))); + } + let x = use_viewport_length(node, "x", 0.0, context.path_name)?; + let y = use_viewport_length(node, "y", 0.0, context.path_name)?; + if x > 1.0e9 || y > 1.0e9 { + return Err(svg_error(context.path_name, " coordinates exceed ±1e9")); + } + context.instance_count = + context.instance_count.checked_add(1).ok_or_else(|| svg_error(context.path_name, "SVG use-instance counter overflow"))?; + if context.instance_count > MAX_SVG_INSTANCES { + return Err(svg_error(context.path_name, "SVG exceeds the 10,000 use-instance limit")); + } + if target.tag_name().name() == "symbol" { + import_symbol(target, node, state, x, y, depth + 1, context)?; + return Ok(()); + } + state.transform = compose(state.transform, kurbo::Affine::translate((x, y))); + context.reference_stack.push(id.to_string()); + let result = import_node(target, state, depth + 1, context); + context.reference_stack.pop(); + return result; + } + return Ok(()); + } + if !state.display_none + && !state.visibility_hidden + && let Some(path) = geometry_for(node, context.path_name)? + { + context.charge_segments(&path)?; + let transformed = transform_path(&path, state.transform); + validate_import_path(&transformed, context.path_name)?; + context.shapes.push(Shape { + path: transformed, + kind: state.kind.clone(), + label: label(node), + stroke: stroke_paint(&style), + clips: state.clips.clone(), + }); + } + for child in node.children().filter(|n| n.is_element()) { + import_node(child, state.clone(), depth + 1, context)?; + } + Ok(()) +} + +fn import_symbol<'a, 'input, K: Kind>( + symbol: roxmltree::Node<'a, 'input>, + use_node: roxmltree::Node<'a, 'input>, + mut state: ImportState, + x: f64, + y: f64, + depth: usize, + context: &mut ImportContext<'a, 'input, K>, +) -> Result<(), Error> { + if depth > MAX_SVG_DEPTH { + return Err(svg_error(context.path_name, "SVG nesting exceeds 128 levels")); + } + let id = symbol.attribute("id").ok_or_else(|| svg_error(context.path_name, " symbol target is missing its id"))?; + if symbol.attribute("display") == Some("none") { + return Ok(()); + } + if let Some(transform) = symbol.attribute("transform") { + let local = parse_transform(transform).map_err(|e| svg_error(context.path_name, &format!(" transform: {e}")))?; + state.transform = compose(state.transform, local); + } + context.charge_cascade()?; + let style = computed_style(symbol, &state.inherited_style, &context.css_rules, K::PROPERTIES); + if style.get("display").is_some_and(|value| value == "none") { + return Ok(()); + } + if let Some(visibility) = style.get("visibility") { + state.visibility_hidden = visibility == "hidden" || visibility == "collapse"; + } + if let Some(kind) = K::of(&style, symbol.attribute("class").unwrap_or("")) { + state.kind = kind; + } + state.inherited_style = inheritable(&style); + + if let Some(raw_view_box) = symbol.attribute("viewBox") { + let values = parse_number_list(raw_view_box) + .filter(|values| values.len() == 4) + .ok_or_else(|| svg_error(context.path_name, " viewBox must contain four finite numbers"))?; + let (min_x, min_y, vb_w, vb_h) = (values[0], values[1], values[2], values[3]); + if values.iter().any(|value| value.abs() > 1.0e9) { + return Err(svg_error(context.path_name, " viewBox exceeds ±1e9")); + } + if vb_w <= 0.0 || vb_h <= 0.0 { + return Err(svg_error(context.path_name, " viewBox dimensions must be positive")); + } + let width = use_viewport_length(use_node, "width", vb_w, context.path_name)?; + let height = use_viewport_length(use_node, "height", vb_h, context.path_name)?; + if width == 0.0 || height == 0.0 { + return Ok(()); + } + let preserve = symbol.attribute("preserveAspectRatio").unwrap_or("xMidYMid meet"); + let viewport = preserve_viewbox_transform(min_x, min_y, vb_w, vb_h, width, height, preserve) + .map_err(|message| svg_error(context.path_name, &format!(": {message}")))?; + if !viewport.as_coeffs().iter().all(|value| value.is_finite()) { + return Err(svg_error(context.path_name, " viewport transform is not finite")); + } + state.transform = compose(state.transform, kurbo::Affine::translate((x, y))); + state.transform = compose(state.transform, viewport); + } else { + state.transform = compose(state.transform, kurbo::Affine::translate((x, y))); + } + + context.reference_stack.push(id.to_string()); + let result = import_children(symbol, state, depth, context); + context.reference_stack.pop(); + result +} + +fn use_viewport_length(use_node: roxmltree::Node<'_, '_>, attribute: &str, fallback: f64, path_name: &str) -> Result { + let Some(raw) = use_node.attribute(attribute) else { + return Ok(fallback); + }; + let value = parse_number(raw).ok_or_else(|| svg_error(path_name, &format!(" {attribute} must be a unitless finite SVG length")))?; + if value.abs() > 1.0e9 || (attribute != "x" && attribute != "y" && value < 0.0) { + return Err(svg_error(path_name, &format!(" {attribute} is outside its supported range"))); + } + Ok(value) +} + +fn import_children<'a, 'input, K: Kind>( + parent: roxmltree::Node<'a, 'input>, + state: ImportState, + depth: usize, + context: &mut ImportContext<'a, 'input, K>, +) -> Result<(), Error> { + if depth > MAX_SVG_DEPTH { + return Err(svg_error(context.path_name, "SVG nesting exceeds 128 levels")); + } + for child in parent.children().filter(|node| node.is_element()) { + import_node(child, state.clone(), depth + 1, context)?; + } + Ok(()) +} + +/// The properties children inherit: `display`, `clip-path`, `mask` and `filter` are +/// not inherited (SVG 1.1 property index; CSS Masking §5, Filter Effects §5). +fn inheritable(style: &StyleMap) -> StyleMap { + style + .iter() + .filter(|(key, _)| !matches!(key.as_str(), "display" | "clip-path" | "mask" | "filter")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() +} + +/// `` for warnings, so a user can find the element in the file. +fn label(node: roxmltree::Node<'_, '_>) -> String { + let tag = node.tag_name().name(); + match node.attribute("id") { + Some(id) => format!("<{tag} id={:?}>", id.chars().take(64).collect::()), + None => format!("<{tag}>"), + } +} + +/// The colour a shape is stroked in, as written, or `None` for no stroke or a paint server +/// without a colour fallback (SVG 2 §13.2 ``). +fn stroke_paint(style: &StyleMap) -> Option { + let raw = style.get("stroke")?.trim(); + // `url(#gradient) red`: the colour after the reference is the fallback. + let paint = match raw.strip_prefix("url(") { + Some(rest) => rest.split_once(')')?.1.trim(), + None => raw, + }; + let paint = if paint.eq_ignore_ascii_case("currentcolor") { + // `color`'s initial value is the user agent's; black is what every browser uses. + style.get("color").map_or("black", String::as_str) + } else { + paint + }; + if paint.is_empty() || paint == "none" || paint.eq_ignore_ascii_case("transparent") { + return None; + } + Some(paint.to_string()) +} + +/// The document-space region of `clip-path: url(#id)` on `node` (CSS Masking §6) as an index +/// into the context's clips, or `None` with a warning when it cannot be applied. +fn clip_region( + node: roxmltree::Node<'_, '_>, + value: &str, + transform: kurbo::Affine, + context: &mut ImportContext<'_, '_, K>, +) -> Result, Error> { + let id = value + .trim() + .strip_prefix("url(") + .and_then(|rest| rest.strip_suffix(')')) + .map(|inner| inner.trim().trim_matches(['"', '\''])) + .and_then(|inner| inner.strip_prefix('#')); + let Some(target) = id.and_then(|id| context.id_index.get(id).copied()) else { + context.warn(format!( + "{}: clip-path {:?} is not a local reference; imported unclipped", + label(node), + value.chars().take(64).collect::() + )); + return Ok(None); + }; + if target.tag_name().name() != "clipPath" { + context.warn(format!("{}: clip-path targets {}, not a ; imported unclipped", label(node), label(target))); + return Ok(None); + } + if target.attribute("clipPathUnits") == Some("objectBoundingBox") { + context.warn(format!("{}: objectBoundingBox clip units are not supported; imported unclipped", label(target))); + return Ok(None); + } + if target.attribute("clip-path").is_some() { + context.warn(format!("{}: a clip-path on a is not applied", label(target))); + } + let mut space = transform; + if let Some(raw) = target.attribute("transform") { + let local = parse_transform(raw).map_err(|e| svg_error(context.path_name, &format!(" transform: {e}")))?; + space = compose(space, local); + } + let mut pieces = Vec::new(); + for child in target.children().filter(|n| n.is_element()) { + let tag = child.tag_name().name(); + if child.attribute("display") == Some("none") { + continue; + } + if !matches!(tag, "path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon") { + if !matches!(tag, "title" | "desc" | "metadata") { + context.warn(format!("{}: {} inside a clip path is not supported; it adds nothing to the clip", label(target), label(child))); + } + continue; + } + let Some(path) = geometry_for(child, context.path_name)? else { + continue; + }; + let mut child_space = space; + if let Some(raw) = child.attribute("transform") { + let local = parse_transform(raw).map_err(|e| svg_error(context.path_name, &format!("<{tag}> transform: {e}")))?; + child_space = compose(child_space, local); + } + context.charge_segments(&path)?; + let path = transform_path(&path, child_space); + validate_import_path(&path, context.path_name)?; + let evenodd = child.attribute("clip-rule").or(target.attribute("clip-rule")); + let rule = if evenodd == Some("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero }; + context.clip_pieces += 1; + if context.clip_pieces > MAX_SVG_CLIP_PIECES { + return Err(svg_error(context.path_name, "SVG clip paths exceed the 100,000-piece limit")); + } + pieces.push((path, rule)); + } + context.clips.push(Clip { pieces }); + Ok(Some(context.clips.len() - 1)) +} + +fn geometry_for(node: roxmltree::Node<'_, '_>, path_name: &str) -> Result, Error> { + let tag = node.tag_name().name(); + let number = |name: &str, default: f64| -> Result { + match node.attribute(name) { + Some(value) => parse_number(value).ok_or_else(|| svg_error(path_name, &format!("<{tag}> has invalid {name}={value:?}"))), + None => Ok(default), + } + }; + let path = match tag { + "svg" | "g" | "defs" | "symbol" | "clipPath" | "mask" | "pattern" | "title" | "desc" | "metadata" | "style" | "stop" | "namedview" + | "guide" | "perspective" => return Ok(None), + "path" => { + let d = node.attribute("d").ok_or_else(|| svg_error(path_name, " has no d attribute"))?; + BezPath::from_svg(d).map_err(|e| svg_error(path_name, &format!("bad path data: {e}")))? + } + "rect" => { + let x = number("x", 0.0)?; + let y = number("y", 0.0)?; + let w = number("width", 0.0)?; + let h = number("height", 0.0)?; + if w < 0.0 || h < 0.0 { + return Err(svg_error(path_name, " width/height cannot be negative")); + } + let rx = number("rx", number("ry", 0.0)?)?; + let ry = number("ry", rx)?; + if rx < 0.0 || ry < 0.0 { + return Err(svg_error(path_name, " corner radii cannot be negative")); + } + rounded_rect(x, y, w, h, rx.min(w / 2.0), ry.min(h / 2.0)) + } + "circle" => { + let cx = number("cx", 0.0)?; + let cy = number("cy", 0.0)?; + let r = number("r", 0.0)?; + if r < 0.0 { + return Err(svg_error(path_name, " radius cannot be negative")); + } + ellipse_path(cx, cy, r, r) + } + "ellipse" => { + let cx = number("cx", 0.0)?; + let cy = number("cy", 0.0)?; + let rx = number("rx", 0.0)?; + let ry = number("ry", 0.0)?; + if rx < 0.0 || ry < 0.0 { + return Err(svg_error(path_name, " radii cannot be negative")); + } + ellipse_path(cx, cy, rx, ry) + } + "line" => { + let mut p = BezPath::new(); + p.move_to(kurbo::Point::new(number("x1", 0.0)?, number("y1", 0.0)?)); + p.line_to(kurbo::Point::new(number("x2", 0.0)?, number("y2", 0.0)?)); + p + } + "polyline" | "polygon" => { + let points = node.attribute("points").ok_or_else(|| svg_error(path_name, &format!("<{tag}> has no points attribute")))?; + points_path(points, tag == "polygon", path_name)? + } + "text" | "tspan" | "image" | "foreignObject" => { + return Err(svg_error(path_name, &format!("<{tag}> is not supported yet"))); + } + name if is_supported_non_drawable(name) => return Ok(None), + _ => { + return Err(svg_error(path_name, &format!("unsupported SVG element <{tag}>; import stopped to avoid silently dropping art"))); + } + }; + Ok(Some(path)) +} + +fn is_supported_non_drawable(tag: &str) -> bool { + matches!( + tag, + "a" | "switch" + | "marker" + | "linearGradient" + | "radialGradient" + | "filter" + | "feGaussianBlur" + | "feOffset" + | "feBlend" + | "feColorMatrix" + | "feComponentTransfer" + | "feComposite" + | "feConvolveMatrix" + | "feDiffuseLighting" + | "feDisplacementMap" + | "feDistantLight" + | "feDropShadow" + | "feFlood" + | "feFuncA" + | "feFuncB" + | "feFuncG" + | "feFuncR" + | "feImage" + | "feMerge" + | "feMergeNode" + | "feMorphology" + | "fePointLight" + | "feSpecularLighting" + | "feSpotLight" + | "feTile" + | "feTurbulence" + | "animate" + | "animateMotion" + | "animateTransform" + | "set" + | "view" + | "cursor" + | "color-profile" + | "font" + | "font-face" + | "glyph" + | "missing-glyph" + ) +} + +fn points_path(points: &str, closed: bool, path_name: &str) -> Result { + let values = parse_number_list(points).ok_or_else(|| svg_error(path_name, "invalid points list"))?; + if values.len() % 2 != 0 { + return Err(svg_error(path_name, "points list must contain coordinate pairs")); + } + if values.len() / 2 > 500_000 { + return Err(svg_error(path_name, "a polygon exceeds the 500,000-point limit")); + } + let mut path = BezPath::new(); + let mut pairs = values.as_chunks::<2>().0.iter(); + if let Some(first) = pairs.next() { + path.move_to(kurbo::Point::new(first[0], first[1])); + for pair in pairs { + path.line_to(kurbo::Point::new(pair[0], pair[1])); + } + if closed { + path.close_path(); + } + } + Ok(path) +} + +fn rounded_rect(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64) -> BezPath { + if rx <= 0.0 || ry <= 0.0 { + let mut p = BezPath::new(); + p.move_to(kurbo::Point::new(x, y)); + p.line_to(kurbo::Point::new(x + w, y)); + p.line_to(kurbo::Point::new(x + w, y + h)); + p.line_to(kurbo::Point::new(x, y + h)); + p.close_path(); + return p; + } + let k = 0.552_284_749_830_793_6; + let mut p = BezPath::new(); + p.move_to(kurbo::Point::new(x + rx, y)); + p.line_to(kurbo::Point::new(x + w - rx, y)); + p.curve_to(kurbo::Point::new(x + w - rx + k * rx, y), kurbo::Point::new(x + w, y + ry - k * ry), kurbo::Point::new(x + w, y + ry)); + p.line_to(kurbo::Point::new(x + w, y + h - ry)); + p.curve_to(kurbo::Point::new(x + w, y + h - ry + k * ry), kurbo::Point::new(x + w - rx + k * rx, y + h), kurbo::Point::new(x + w - rx, y + h)); + p.line_to(kurbo::Point::new(x + rx, y + h)); + p.curve_to(kurbo::Point::new(x + rx - k * rx, y + h), kurbo::Point::new(x, y + h - ry + k * ry), kurbo::Point::new(x, y + h - ry)); + p.line_to(kurbo::Point::new(x, y + ry)); + p.curve_to(kurbo::Point::new(x, y + ry - k * ry), kurbo::Point::new(x + rx - k * rx, y), kurbo::Point::new(x + rx, y)); + p.close_path(); + p +} + +fn ellipse_path(cx: f64, cy: f64, rx: f64, ry: f64) -> BezPath { + let k = 0.552_284_749_830_793_6; + let mut p = BezPath::new(); + p.move_to(kurbo::Point::new(cx + rx, cy)); + p.curve_to(kurbo::Point::new(cx + rx, cy + k * ry), kurbo::Point::new(cx + k * rx, cy + ry), kurbo::Point::new(cx, cy + ry)); + p.curve_to(kurbo::Point::new(cx - k * rx, cy + ry), kurbo::Point::new(cx - rx, cy + k * ry), kurbo::Point::new(cx - rx, cy)); + p.curve_to(kurbo::Point::new(cx - rx, cy - k * ry), kurbo::Point::new(cx - k * rx, cy - ry), kurbo::Point::new(cx, cy - ry)); + p.curve_to(kurbo::Point::new(cx + k * rx, cy - ry), kurbo::Point::new(cx + rx, cy - k * ry), kurbo::Point::new(cx + rx, cy)); + p.close_path(); + p +} + +pub const MAX_CSS_BYTES: usize = 1024 * 1024; +const MAX_CSS_RULES: usize = 10_000; +const MAX_CSS_PROPERTIES: usize = 32; +const SUPPORTED_STYLE_PROPERTIES: &[&str] = &["display", "visibility", "fill", "stroke", "stroke-width", "color", "clip-path", "mask", "filter"]; + +/// Whether the cascade tracks `property`: the properties above and [`Kind::PROPERTIES`]. +fn supported(extra: &[&str], property: &str) -> bool { + SUPPORTED_STYLE_PROPERTIES.contains(&property) || extra.contains(&property) +} + +type CssPriority = (bool, u32, u32, u32, usize); +type CascadeWinners = std::collections::HashMap; + +#[derive(Clone)] +struct CssRule { + selectors: Vec, + declarations: StyleMap, + important: std::collections::HashSet, + order: usize, +} + +#[derive(Clone)] +struct SimpleSelector { + compounds: Vec, + /// Between consecutive compounds: ' ' descendant, '>' child, '+' next sibling, + /// '~' later sibling (Selectors Level 3 §8). + combinators: Vec, + specificity: (u32, u32, u32), +} + +/// `[name]` or `[name op value]`, op one of `=`, `~`, `^`, `$`, `*`, `|`. +type AttributeSelector = (String, Option<(char, String)>); + +#[derive(Clone, Default)] +struct CompoundSelector { + tag: Option, + id: Option, + classes: Vec, + attributes: Vec, + first_child: bool, + last_child: bool, + root: bool, + /// `:not(…)` arguments; each is a compound without its own `:not`. + nots: Vec, +} + +impl CompoundSelector { + fn matches(&self, node: roxmltree::Node<'_, '_>) -> bool { + if self.tag.as_deref().is_some_and(|tag| tag != "*" && tag != node.tag_name().name()) { + return false; + } + if self.id.as_deref().is_some_and(|id| node.attribute("id") != Some(id)) { + return false; + } + let classes: Vec<&str> = node.attribute("class").unwrap_or("").split_ascii_whitespace().collect(); + if self.classes.iter().any(|class| !classes.contains(&class.as_str())) { + return false; + } + if (self.first_child && node.prev_sibling_element().is_some()) + || (self.last_child && node.next_sibling_element().is_some()) + || (self.root && node.parent_element().is_some()) + || self.nots.iter().any(|not| not.matches(node)) + { + return false; + } + self.attributes.iter().all(|(name, test)| { + let Some(actual) = node.attribute(name.as_str()) else { + return false; + }; + let Some((op, value)) = test else { + return true; + }; + let value = value.as_str(); + match op { + '=' => actual == value, + '~' => actual.split_ascii_whitespace().any(|word| word == value), + '^' => !value.is_empty() && actual.starts_with(value), + '$' => !value.is_empty() && actual.ends_with(value), + '*' => !value.is_empty() && actual.contains(value), + '|' => actual == value || actual.strip_prefix(value).is_some_and(|rest| rest.starts_with('-')), + _ => false, + } + }) + } + + fn specificity(&self) -> (u32, u32, u32) { + let mut total = ( + u32::from(self.id.is_some()), + (self.classes.len() + self.attributes.len()) as u32 + u32::from(self.first_child) + u32::from(self.last_child) + u32::from(self.root), + u32::from(self.tag.as_deref().is_some_and(|tag| tag != "*")), + ); + // `:not()` counts as its argument (Selectors Level 3 §16). + for not in &self.nots { + let (a, b, c) = not.specificity(); + total = (total.0 + a, total.1 + b, total.2 + c); + } + total + } +} + +impl SimpleSelector { + fn matches(&self, node: roxmltree::Node<'_, '_>) -> bool { + let Some(last) = self.compounds.last() else { + return false; + }; + if !last.matches(node) { + return false; + } + let mut current = node; + for index in (0..self.combinators.len()).rev() { + let (Some(previous), Some(combinator)) = (self.compounds.get(index), self.combinators.get(index)) else { + return false; + }; + // shortcut: descendant and later-sibling steps take the nearest match without + // backtracking, which misses a few long selector chains; fine for SVG styling. + let found = match combinator { + '>' => current.parent_element().filter(|n| previous.matches(*n)), + '+' => current.prev_sibling_element().filter(|n| previous.matches(*n)), + ' ' => current.ancestors().skip(1).find(|n| n.is_element() && previous.matches(*n)), + '~' => { + let mut sibling = current.prev_sibling_element(); + while let Some(candidate) = sibling { + if previous.matches(candidate) { + break; + } + sibling = candidate.prev_sibling_element(); + } + sibling + } + _ => None, + }; + let Some(next) = found else { + return false; + }; + current = next; + } + true + } +} + +/// CSS block at-rules whose content never styles geometry; skipped without a warning. +const QUIET_AT_RULES: &[&str] = &["font-face", "keyframes", "-webkit-keyframes", "page", "counter-style", "charset", "namespace"]; + +fn parse_stylesheets(root: roxmltree::Node<'_, '_>, extra: &[&str], path_name: &str, warnings: &mut Vec) -> Result, Error> { + let mut rules = Vec::new(); + let mut dropped = 0usize; + let mut warn = |message: String| push_warning(warnings, &mut dropped, message); + for node in root.descendants().filter(|node| node.is_element() && node.tag_name().name() == "style") { + if let Some(media) = node.attribute("media") + && !matches!(media.trim(), "" | "all" | "screen") + { + continue; + } + let stylesheet = node.text().unwrap_or(""); + if stylesheet.len() > MAX_CSS_BYTES { + return Err(svg_error(path_name, "CSS stylesheet exceeds the 1 MiB limit")); + } + let stylesheet = strip_css_comments(stylesheet); + let mut rest = stylesheet.as_str(); + loop { + rest = rest.trim_start(); + if rest.is_empty() { + break; + } + if let Some(at_rule) = rest.strip_prefix('@') { + let name: String = + at_rule.chars().take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '-').take(64).collect::().to_ascii_lowercase(); + let quiet = QUIET_AT_RULES.contains(&name.as_str()); + match (rest.find('{'), rest.find(';')) { + (Some(open), semi) if semi.is_none_or(|semi| open < semi) => { + let close = matching_brace(rest, open).ok_or_else(|| svg_error(path_name, "malformed CSS at-rule"))?; + if !quiet { + warn(format!("CSS @{name} block ignored; its rules are not applied")); + } + rest = rest.get(close + 1..).unwrap_or(""); + } + (_, Some(semi)) => { + if !quiet { + warn(format!("CSS @{name} ignored (external stylesheets are not loaded)")); + } + rest = rest.get(semi + 1..).unwrap_or(""); + } + _ => return Err(svg_error(path_name, "malformed CSS at-rule")), + } + continue; + } + let (Some(open), Some(close)) = (rest.find('{'), rest.find('}')) else { + return Err(svg_error(path_name, "malformed CSS rule")); + }; + if close < open { + return Err(svg_error(path_name, "malformed CSS rule")); + } + let selector_text = rest.get(..open).unwrap_or("").trim(); + let raw_declarations = rest.get(open + 1..close).unwrap_or(""); + rest = rest.get(close + 1..).unwrap_or(""); + if selector_text.is_empty() { + continue; + } + let (declarations, important) = parse_declarations(raw_declarations, extra, path_name)?; + for selector in split_selector_list(selector_text) { + let Some(parsed) = parse_selector(selector.trim()) else { + warn(format!("unsupported CSS selector {:?}; its rule is not applied", selector.chars().take(64).collect::())); + continue; + }; + rules.push(CssRule { selectors: vec![parsed], declarations: declarations.clone(), important: important.clone(), order: rules.len() }); + if rules.len() > MAX_CSS_RULES { + return Err(svg_error(path_name, "CSS exceeds the 10,000-rule limit")); + } + } + } + } + if dropped > 0 { + warnings.push(format!("…and {dropped} more CSS warnings")); + } + Ok(rules) +} + +/// Byte index of the `}` closing the `{` at `open`, counting nested blocks. +fn matching_brace(text: &str, open: usize) -> Option { + let mut depth = 0usize; + for (index, byte) in text.bytes().enumerate().skip(open) { + match byte { + b'{' => depth += 1, + b'}' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(index); + } + } + _ => {} + } + } + None +} + +fn strip_css_comments(raw: &str) -> String { + let mut output = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(start) = rest.find("/*") { + output.push_str(&rest[..start]); + let Some(end) = rest[start + 2..].find("*/") else { + return output; + }; + rest = &rest[start + end + 4..]; + } + output.push_str(rest); + output +} + +fn split_selector_list(raw: &str) -> Vec<&str> { + let mut selectors = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + for (index, ch) in raw.char_indices() { + match ch { + '[' | '(' => depth += 1, + ']' | ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + selectors.push(raw[start..index].trim()); + start = index + 1; + } + _ => {} + } + } + selectors.push(raw[start..].trim()); + selectors +} + +fn parse_selector(raw: &str) -> Option { + let mut compounds = Vec::new(); + let mut combinators = Vec::new(); + let mut compound = String::new(); + let mut depth = 0usize; + let mut pending_space = false; + for ch in raw.chars() { + if matches!(ch, '[' | '(') { + depth += 1; + compound.push(ch); + } else if matches!(ch, ']' | ')') { + depth = depth.saturating_sub(1); + compound.push(ch); + } else if depth == 0 && ch.is_ascii_whitespace() { + if !compound.is_empty() { + compounds.push(parse_compound(&compound)?); + compound.clear(); + pending_space = true; + } + } else if depth == 0 && matches!(ch, '>' | '+' | '~') { + if !compound.is_empty() { + compounds.push(parse_compound(&compound)?); + compound.clear(); + } else if compounds.is_empty() || combinators.len() == compounds.len() { + return None; + } + pending_space = false; + combinators.push(ch); + } else { + if pending_space { + combinators.push(' '); + pending_space = false; + } + compound.push(ch); + } + } + if !compound.is_empty() { + compounds.push(parse_compound(&compound)?); + } + if compounds.is_empty() || combinators.len() + 1 != compounds.len() { + return None; + } + let specificity = compounds.iter().fold((0, 0, 0), |total, part| { + let (a, b, c) = part.specificity(); + (total.0 + a, total.1 + b, total.2 + c) + }); + Some(SimpleSelector { compounds, combinators, specificity }) +} + +fn parse_compound(raw: &str) -> Option { + let mut selector = CompoundSelector::default(); + let chars: Vec = raw.chars().collect(); + let mut index = 0usize; + let name_char = |ch: &char| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'); + if chars.first().is_some_and(|ch| *ch == '*' || ch.is_ascii_alphabetic() || *ch == '_') { + let start = index; + index += 1; + while chars.get(index).is_some_and(|ch| name_char(ch) || *ch == '*') { + index += 1; + } + selector.tag = Some(chars[start..index].iter().collect()); + } + while let Some(&marker) = chars.get(index) { + match marker { + '#' | '.' => { + index += 1; + let start = index; + while chars.get(index).is_some_and(name_char) { + index += 1; + } + if start == index { + return None; + } + let value: String = chars[start..index].iter().collect(); + if marker == '#' { + if selector.id.replace(value).is_some() { + return None; + } + } else { + selector.classes.push(value); + } + } + '[' => { + index += 1; + let start = index; + while chars.get(index).is_some_and(|ch| *ch != ']') { + index += 1; + } + if index == chars.len() { + return None; + } + let content: String = chars[start..index].iter().collect(); + index += 1; + selector.attributes.push(parse_attribute_selector(&content)?); + } + ':' => { + index += 1; + let start = index; + while chars.get(index).is_some_and(name_char) { + index += 1; + } + let name: String = chars[start..index].iter().collect::().to_ascii_lowercase(); + match name.as_str() { + "first-child" => selector.first_child = true, + "last-child" => selector.last_child = true, + "root" => selector.root = true, + "not" => { + if chars.get(index) != Some(&'(') { + return None; + } + let start = index + 1; + let end = start + chars.get(start..)?.iter().position(|ch| *ch == ')')?; + let inner: String = chars.get(start..end)?.iter().collect(); + // One level only (Selectors Level 3): no recursion on hostile input. + if inner.contains(':') && inner.to_ascii_lowercase().contains(":not") { + return None; + } + selector.nots.push(parse_compound(inner.trim())?); + index = end + 1; + } + _ => return None, + } + } + _ => return None, + } + } + let empty = selector.tag.is_none() + && selector.id.is_none() + && selector.classes.is_empty() + && selector.attributes.is_empty() + && !selector.first_child + && !selector.last_child + && !selector.root + && selector.nots.is_empty(); + (!empty).then_some(selector) +} + +/// The inside of `[…]`: `name`, or `name` `op=` `value` with optional quotes. +fn parse_attribute_selector(content: &str) -> Option { + let Some((left, value)) = content.split_once('=') else { + let name = content.trim(); + return (!name.is_empty()).then(|| (name.to_string(), None)); + }; + let left = left.trim_end(); + let (name, op) = match left.chars().last() { + Some(op @ ('~' | '^' | '$' | '*' | '|')) => (&left[..left.len() - 1], op), + _ => (left, '='), + }; + let name = name.trim(); + if name.is_empty() { + return None; + } + let value = value.trim().trim_matches(['"', '\'']); + Some((name.to_string(), Some((op, value.to_string())))) +} + +fn parse_declarations(raw: &str, extra: &[&str], path_name: &str) -> Result<(StyleMap, std::collections::HashSet), Error> { + let mut declarations = StyleMap::new(); + let mut important = std::collections::HashSet::new(); + for declaration in strip_css_comments(raw).split(';') { + let Some((property, value)) = declaration.split_once(':') else { + if !declaration.trim().is_empty() { + return Err(svg_error(path_name, "malformed CSS declaration")); + } + continue; + }; + let property = property.trim().to_ascii_lowercase(); + if !supported(extra, property.as_str()) { + continue; + } + if declarations.len() >= MAX_CSS_PROPERTIES { + return Err(svg_error(path_name, "CSS rule has too many declarations")); + } + let (value, is_important) = strip_important(value.trim()); + if is_important { + important.insert(property.clone()); + } + declarations.insert(property, value.trim().to_string()); + } + Ok((declarations, important)) +} + +fn computed_style(node: roxmltree::Node<'_, '_>, inherited: &StyleMap, rules: &[CssRule], extra: &[&str]) -> StyleMap { + let mut result = inherited.clone(); + result.remove("display"); + let mut winners = CascadeWinners::new(); + // SVG presentation attributes participate below every author stylesheet rule. + for (property, value) in style_properties(node, extra) { + result.insert(property, value); + } + for rule in rules { + for selector in &rule.selectors { + if selector.matches(node) { + let (ids, classes, tags) = selector.specificity; + for (property, value) in &rule.declarations { + let priority = (rule.important.contains(property), ids, classes, tags, rule.order); + if winners.get(property).is_none_or(|(old_priority, _)| priority >= *old_priority) { + winners.insert(property.clone(), (priority, value.clone())); + } + } + } + } + } + let mut important_winners = std::collections::HashSet::new(); + for (property, (priority, value)) in &winners { + result.insert(property.clone(), value.clone()); + if priority.0 { + important_winners.insert(property.clone()); + } + } + let mut inline_winners = std::collections::HashMap::::new(); + if let Some(inline) = node.attribute("style") { + for declaration in inline.split(';') { + let Some((property, value)) = declaration.split_once(':') else { + continue; + }; + let property = property.trim().to_ascii_lowercase(); + if !supported(extra, property.as_str()) { + continue; + } + let (value, important) = strip_important(value.trim()); + if inline_winners.get(&property).is_none_or(|(old_important, _)| important || !old_important) { + inline_winners.insert(property, (important, value.to_string())); + } + } + } + for (property, (important, value)) in inline_winners { + if important || !important_winners.contains(&property) { + result.insert(property, value); + } + } + result +} + +fn strip_important(value: &str) -> (&str, bool) { + let trimmed = value.trim(); + let lower = trimmed.to_ascii_lowercase(); + if lower.ends_with("!important") { (&trimmed[..trimmed.len() - "!important".len()], true) } else { (trimmed, false) } +} + +fn style_properties(node: roxmltree::Node<'_, '_>, extra: &[&str]) -> StyleMap { + let mut properties = StyleMap::new(); + for key in SUPPORTED_STYLE_PROPERTIES.iter().chain(extra) { + if let Some(value) = node.attribute(*key) { + properties.insert((*key).to_string(), value.trim().to_string()); + } + } + if let Some(inline) = node.attribute("style") { + for declaration in inline.split(';') { + if let Some((property, value)) = declaration.split_once(':') { + let property = property.trim().to_ascii_lowercase(); + if supported(extra, property.as_str()) { + let (value, _) = strip_important(value.trim()); + properties.insert(property, value.trim().to_string()); + } + } + } + } + properties +} + +fn viewport_transform(root: roxmltree::Node<'_, '_>, path_name: &str) -> Result<(f64, f64, kurbo::Affine), Error> { + if root.tag_name().name() == "symbol" { + return Err(svg_error(path_name, "a standalone cannot be an SVG root")); + } + let view_box = match root.attribute("viewBox") { + Some(raw) => { + let values = + parse_number_list(raw).filter(|v| v.len() == 4).ok_or_else(|| svg_error(path_name, "viewBox must contain four finite numbers"))?; + if values[2] <= 0.0 || values[3] <= 0.0 { + return Err(svg_error(path_name, "viewBox width and height must be positive")); + } + Some((values[0], values[1], values[2], values[3])) + } + None => None, + }; + let width_attr = root.attribute("width"); + let height_attr = root.attribute("height"); + let explicit_viewport = width_attr.is_some() || height_attr.is_some(); + let fallback = view_box.map(|(_, _, w, h)| (w, h)).unwrap_or((100.0, 150.0)); + let width = width_attr.map(svg_length_mm).transpose().map_err(|e| svg_error(path_name, &format!("root width: {e}")))?.unwrap_or(fallback.0); + let height = height_attr.map(svg_length_mm).transpose().map_err(|e| svg_error(path_name, &format!("root height: {e}")))?.unwrap_or(fallback.1); + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || width > 1.0e9 || height > 1.0e9 { + return Err(svg_error(path_name, "viewport dimensions must be positive, finite, and no larger than 1e9 mm")); + } + let Some((min_x, min_y, vb_w, vb_h)) = view_box else { + // Without viewBox, physical SVG user units are CSS px (96/in). For a + // bare SVG fragment there is no viewport and paths remain mm-based. + let sx = if explicit_viewport { 25.4 / 96.0 } else { 1.0 }; + let sy = sx; + return Ok((width, height, kurbo::Affine::scale_non_uniform(sx, sy))); + }; + let preserve = root.attribute("preserveAspectRatio").unwrap_or("xMidYMid meet"); + let transform = + preserve_viewbox_transform(min_x, min_y, vb_w, vb_h, width, height, preserve).map_err(|message| svg_error(path_name, &message))?; + if !transform.as_coeffs().iter().all(|value| value.is_finite()) { + return Err(svg_error(path_name, "viewBox transform is not finite")); + } + Ok((width, height, transform)) +} + +fn preserve_viewbox_transform( + min_x: f64, + min_y: f64, + vb_w: f64, + vb_h: f64, + width: f64, + height: f64, + preserve: &str, +) -> Result { + let tokens: Vec<&str> = preserve.split_ascii_whitespace().collect(); + let align = tokens.first().copied().unwrap_or("xMidYMid"); + let mode = tokens.get(1).copied().unwrap_or("meet"); + if tokens.len() > 2 { + return Err("preserveAspectRatio has unexpected trailing tokens".to_string()); + } + if align == "none" { + if tokens.len() > 1 { + return Err("preserveAspectRatio `none` does not accept a meet/slice mode".to_string()); + } + return Ok(kurbo::Affine::new([width / vb_w, 0.0, 0.0, height / vb_h, -min_x * width / vb_w, -min_y * height / vb_h])); + } + if !matches!(mode, "meet" | "slice") { + return Err("preserveAspectRatio supports only meet, slice, or none".to_string()); + } + let (x_align, y_align) = match align { + "xMinYMin" => (0.0, 0.0), + "xMidYMin" => (0.5, 0.0), + "xMaxYMin" => (1.0, 0.0), + "xMinYMid" => (0.0, 0.5), + "xMidYMid" => (0.5, 0.5), + "xMaxYMid" => (1.0, 0.5), + "xMinYMax" => (0.0, 1.0), + "xMidYMax" => (0.5, 1.0), + "xMaxYMax" => (1.0, 1.0), + _ => return Err("unsupported preserveAspectRatio alignment".to_string()), + }; + let sx = width / vb_w; + let sy = height / vb_h; + let scale = if mode == "meet" { sx.min(sy) } else { sx.max(sy) }; + let tx = (width - vb_w * scale) * x_align - min_x * scale; + let ty = (height - vb_h * scale) * y_align - min_y * scale; + let transform = kurbo::Affine::new([scale, 0.0, 0.0, scale, tx, ty]); + if !transform.as_coeffs().iter().all(|value| value.is_finite()) { + return Err("preserveAspectRatio produces a non-finite transform".to_string()); + } + Ok(transform) +} + +fn svg_length_mm(raw: &str) -> Result { + let raw = raw.trim(); + let split = raw.find(|c: char| !(c.is_ascii_digit() || matches!(c, '+' | '-' | '.' | 'e' | 'E'))).unwrap_or(raw.len()); + let value = raw[..split].parse::().map_err(|_| format!("invalid length {raw:?}"))?; + if !value.is_finite() || value <= 0.0 { + return Err(format!("length {raw:?} must be positive and finite")); + } + let unit = raw[split..].trim(); + let scale = match unit { + "" | "px" => 25.4 / 96.0, + "mm" => 1.0, + "cm" => 10.0, + "in" => 25.4, + "pt" => 25.4 / 72.0, + "pc" => 25.4 / 6.0, + _ => return Err(format!("unsupported length unit {unit:?}")), + }; + Ok(value * scale) +} + +fn parse_number(raw: &str) -> Option { + let value = raw.trim().parse::().ok()?; + value.is_finite().then_some(value) +} + +fn parse_number_list(raw: &str) -> Option> { + let mut values = Vec::new(); + let mut token = String::new(); + let mut comma_pending = false; + let mut saw_any = false; + for ch in raw.chars() { + if ch.is_ascii_whitespace() { + if !token.is_empty() { + values.push(parse_number(&token)?); + token.clear(); + saw_any = true; + } + } else if ch == ',' { + if !token.is_empty() { + values.push(parse_number(&token)?); + token.clear(); + saw_any = true; + comma_pending = true; + } else if !comma_pending && saw_any { + // Whitespace before a comma is valid; consecutive commas or + // a leading comma are not part of SVG's number-list grammar. + comma_pending = true; + } else { + return None; + } + } else if (ch == '+' || ch == '-') && !token.is_empty() && !token.ends_with('e') && !token.ends_with('E') { + values.push(parse_number(&token)?); + token.clear(); + token.push(ch); + saw_any = true; + comma_pending = false; + } else if ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.' | 'e' | 'E') { + token.push(ch); + comma_pending = false; + } else { + return None; + } + if values.len() > 200_000 { + return None; + } + } + if comma_pending { + return None; + } + if !token.is_empty() { + values.push(parse_number(&token)?); + } + Some(values) +} + +fn parse_transform(raw: &str) -> Result { + let mut rest = raw.trim(); + let mut result = kurbo::Affine::IDENTITY; + while !rest.is_empty() { + rest = rest.trim_start_matches(|c: char| c.is_ascii_whitespace() || c == ','); + if rest.is_empty() { + break; + } + let open = rest.find('(').ok_or_else(|| "expected '(' after transform name".to_string())?; + let name = rest[..open].trim(); + let close = rest[open + 1..].find(')').map(|i| i + open + 1).ok_or_else(|| "unterminated transform".to_string())?; + let values = parse_number_list(&rest[open + 1..close]).ok_or_else(|| "transform has invalid numeric arguments".to_string())?; + let local = match (name, values.as_slice()) { + ("matrix", [a, b, c, d, e, f]) => kurbo::Affine::new([*a, *b, *c, *d, *e, *f]), + ("translate", [x]) => kurbo::Affine::translate((*x, 0.0)), + ("translate", [x, y]) => kurbo::Affine::translate((*x, *y)), + ("scale", [s]) => kurbo::Affine::scale(*s), + ("scale", [x, y]) => kurbo::Affine::scale_non_uniform(*x, *y), + ("rotate", [deg]) => kurbo::Affine::rotate(deg.to_radians()), + ("rotate", [deg, cx, cy]) => { + let to = kurbo::Affine::translate((*cx, *cy)); + let rot = kurbo::Affine::rotate(deg.to_radians()); + let from = kurbo::Affine::translate((-*cx, -*cy)); + compose(compose(to, rot), from) + } + ("skewX", [deg]) => kurbo::Affine::new([1.0, 0.0, deg.to_radians().tan(), 1.0, 0.0, 0.0]), + ("skewY", [deg]) => kurbo::Affine::new([1.0, deg.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]), + _ => return Err(format!("unsupported or malformed transform {name:?}")), + }; + if !local.as_coeffs().iter().all(|v| v.is_finite()) { + return Err("transform contains non-finite values".to_string()); + } + result = compose(result, local); + rest = &rest[close + 1..]; + } + Ok(result) +} + +/// Matrix composition `parent(local(point))` for kurbo's [a,b,c,d,e,f] +/// affine coefficient order. +fn compose(parent: kurbo::Affine, local: kurbo::Affine) -> kurbo::Affine { + let [a, b, c, d, e, f] = parent.as_coeffs(); + let [g, h, i, j, k, l] = local.as_coeffs(); + kurbo::Affine::new([a * g + c * h, b * g + d * h, a * i + c * j, b * i + d * j, a * k + c * l + e, b * k + d * l + f]) +} + +fn transform_path(path: &BezPath, affine: kurbo::Affine) -> BezPath { + let mut out = BezPath::new(); + for elem in path.elements() { + match *elem { + kurbo::PathEl::MoveTo(p) => out.move_to(affine * p), + kurbo::PathEl::LineTo(p) => out.line_to(affine * p), + kurbo::PathEl::QuadTo(c, p) => out.quad_to(affine * c, affine * p), + kurbo::PathEl::CurveTo(c1, c2, p) => out.curve_to(affine * c1, affine * c2, affine * p), + kurbo::PathEl::ClosePath => out.close_path(), + } + } + out +} + +fn validate_import_path(path: &BezPath, path_name: &str) -> Result<(), Error> { + if path.elements().len() > 1_000_000 { + return Err(svg_error(path_name, "a shape exceeds the 1,000,000-segment limit")); + } + let valid = |p: kurbo::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= 1.0e9 && p.y.abs() <= 1.0e9; + for element in path.elements() { + let safe = match *element { + kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => valid(p), + kurbo::PathEl::QuadTo(c, p) => valid(c) && valid(p), + kurbo::PathEl::CurveTo(c1, c2, p) => valid(c1) && valid(c2) && valid(p), + kurbo::PathEl::ClosePath => true, + }; + if !safe { + return Err(svg_error(path_name, "geometry coordinates must be finite and within ±1e9 mm")); + } + } + Ok(()) +} + +fn svg_error(path_name: &str, message: &str) -> Error { + Error(format!("{path_name}: {message}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use kurbo::Shape as _; + + /// A cutter's two kinds, set by `data-kind` or by class. + #[derive(Debug, Clone, Copy, Default, PartialEq)] + enum Tag { + #[default] + Cut, + Engrave, + } + + impl Kind for Tag { + const PROPERTIES: &'static [&'static str] = &["data-kind"]; + + fn of(style: &StyleMap, class: &str) -> Option { + match style.get("data-kind").map(String::as_str) { + Some("cut") => Some(Tag::Cut), + Some("engrave") => Some(Tag::Engrave), + _ if class.contains("engrave") => Some(Tag::Engrave), + _ => None, + } + } + + fn skips_unpainted(&self) -> bool { + *self == Tag::Cut + } + } + + fn read(svg: &str) -> Imported { + import(svg, "test.svg").unwrap() + } + + fn fails(svg: &str, with: &str) { + match import::(svg, "test.svg") { + Err(Error(m)) => assert!(m.starts_with("test.svg: ") && m.contains(with), "{m}"), + Ok(_) => panic!("{svg} was read"), + } + } + + fn bounds(imported: &Imported, i: usize) -> kurbo::Rect { + imported.shapes[i].path.bounding_box() + } + + fn near(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + #[test] + fn a_fragment_is_read_in_millimetres_and_groups_are_walked() { + let got = read(r#""#); + assert_eq!(got.shapes.len(), 1); + assert_eq!(got.shapes[0].path.to_svg(), "M0,0 L5,0 L5,5 Z"); + // The unit kind reads the same paths. + assert_eq!(import::<()>("", "p.svg").unwrap().shapes[0].path.to_svg(), "M0,0 L5,0 L5,5 Z"); + } + + #[test] + fn errors_name_the_file_and_the_problem() { + fails("", "d attribute"); + fails("", ""); + fails("", "transform"); + fails("", "invalid XML"); + } + + #[test] + fn reads_all_common_basic_shapes() { + let got = read( + r#" + + + + + + + + "#, + ); + assert_eq!(got.shapes.len(), 7); + assert!(got.shapes[0].path.elements().len() >= 5); + assert!(got.shapes[1].path.elements().len() >= 6); + assert_eq!((got.width_mm, got.height_mm), (100.0, 100.0)); + } + + #[test] + fn nested_group_transforms_apply_and_the_kind_inherits() { + let got = read( + r#" + + + + + + "#, + ); + assert_eq!(got.shapes.iter().map(|s| s.kind).collect::>(), [Tag::Engrave, Tag::Engrave, Tag::Cut]); + let b = bounds(&got, 0); + assert!(near(b.x0, 12.0) && near(b.y0, 24.0) && near(b.width(), 6.0), "{b:?}"); + } + + #[test] + fn local_use_instances_take_x_y_transform_and_symbol_viewboxes() { + let got = read( + r##" + + + + + + + + + + + + "##, + ); + assert_eq!(got.shapes.len(), 3); + assert!(got.shapes.iter().all(|s| s.kind == Tag::Engrave)); + let first = bounds(&got, 0); + assert!(near(first.x0, 17.0) && near(first.y0, 24.0) && near(first.width(), 6.0), "{first:?}"); + let second = bounds(&got, 1); + assert!(near(second.x0, 32.0) && near(second.y0, 9.0), "{second:?}"); + let badge = bounds(&got, 2); + assert!(near(badge.x0, 50.0) && near(badge.y0, 20.0) && near(badge.width(), 40.0) && near(badge.height(), 20.0), "{badge:?}"); + } + + #[test] + fn use_cycles_missing_and_external_references_are_errors() { + for svg in [ + r##""##, + r##""##, + r#""#, + r##"no"##, + r##""##, + r##""##, + r##""##, + r##""##, + ] { + fails(svg, ""); + } + } + + #[test] + fn a_viewbox_maps_onto_physical_units() { + for preserve in ["xMidYMid meet extra", "notxMidYMid"] { + fails(&format!(r#""#), ""); + } + let got = read( + r#" + + "#, + ); + assert_eq!((got.width_mm, got.height_mm), (200.0, 100.0)); + let b = bounds(&got, 0); + assert!(near(b.x0, 20.0) && near(b.y0, 20.0) && near(b.width(), 10.0) && near(b.height(), 4.0), "{b:?}"); + + let px = read( + r#" + + "#, + ); + assert!(near(px.width_mm, 25.4) && near(px.height_mm, 12.7)); + let b = bounds(&px, 0); + assert!(near(b.x0, 10.0 * 25.4 / 96.0) && near(b.width(), 8.0 * 25.4 / 96.0), "{b:?}"); + } + + #[test] + fn hidden_and_undisplayed_elements_are_left_out() { + let got = read( + r#" + + + + + + "#, + ); + assert_eq!(got.shapes.len(), 1); + assert_eq!(got.shapes[0].kind, Tag::Engrave); + } + + #[test] + fn an_unpainted_element_is_left_out_only_for_a_kind_that_asks() { + let svg = |kind: &str| format!(r#""#); + assert_eq!(read(&svg("cut")).shapes.len(), 0); + assert_eq!(read(&svg("engrave")).shapes.len(), 1); + // Only one of the two set to none still draws. + assert_eq!(read(r#""#).shapes.len(), 1); + } + + #[test] + fn the_css_cascade_follows_specificity_importance_and_the_kinds_own_property() { + let got = read( + r##" + + + + + + "##, + ); + assert_eq!(got.shapes.len(), 1); + assert_eq!(got.shapes[0].kind, Tag::Cut); + assert_eq!(bounds(&got, 0).x0, 3.0); + // A property the kind doesn't list is not followed: `()` ignores `data-kind` entirely. + assert_eq!(import::<()>(r#""#, "p.svg").unwrap().shapes.len(), 1); + } + + #[test] + fn an_oversized_stylesheet_is_an_error() { + fails(&format!("", " ".repeat(MAX_CSS_BYTES + 1)), ""); + } + + #[test] + fn nonfinite_geometry_and_malformed_number_lists_are_errors() { + fails(r#""#, ""); + fails(r#""#, ""); + fails(r#""#, ""); + } + + #[test] + fn resource_limits_are_errors_not_panics() { + fails(&format!("{}", " ".repeat(MAX_SVG_BYTES)), "16 MiB"); + let deep = format!("{}{}", "".repeat(MAX_SVG_DEPTH + 2), "".repeat(MAX_SVG_DEPTH + 2)); + fails(&deep, "nesting exceeds"); + fails(&format!("{}", "".repeat(MAX_SVG_NODES + 1)), "100,000-node"); + } + + #[test] + fn deterministically_mutated_svg_never_panics_and_stays_in_range() { + let seed = r#""#; + let bytes = seed.as_bytes(); + let mut state = 0x51_6f_9a_u64; + for _ in 0..512 { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let index = (state as usize) % bytes.len(); + let mut mutated = bytes.to_vec(); + mutated[index] = (state >> 32) as u8; + if let Ok(text) = std::str::from_utf8(&mutated) + && let Ok(got) = import::(text, "mutated.svg") + { + assert!(got.width_mm.is_finite() && got.height_mm.is_finite()); + for shape in &got.shapes { + let b = shape.path.bounding_box(); + assert!(b.x0.abs() <= 1.0e9 && b.x1.abs() <= 1.0e9 && b.y0.abs() <= 1.0e9 && b.y1.abs() <= 1.0e9, "{text}"); + } + } + } + } + #[test] + fn the_stroke_paint_comes_as_written_with_fallbacks_resolved() { + let got = read( + r##" + + + + + + + + + + + "##, + ); + let strokes: Vec> = got.shapes.iter().map(|s| s.stroke.as_deref()).collect(); + assert_eq!( + strokes, + [Some("red"), Some("rgb(0 0 100%)"), Some("rgba(0, 128, 0, 0.5)"), Some("black"), Some("cornflowerblue"), None, None, None, None] + ); + assert!(got.warnings.is_empty(), "{:?}", got.warnings); + } + + #[test] + fn clip_paths_come_as_regions_in_document_space() { + let got = read( + r##" + + + + + + + + + + + + + "##, + ); + assert!(got.warnings.is_empty(), "{:?}", got.warnings); + // The shape itself is not cut down, and says who it is. + assert_eq!(bounds(&got, 0), kurbo::Rect::new(0.0, 0.0, 20.0, 20.0)); + assert_eq!(got.shapes[0].label, r#""#); + let region = |shape: usize, clip: usize| { + let pieces = &got.clips[got.shapes[shape].clips[clip]].pieces; + pieces.iter().map(|(path, rule)| (path.bounding_box(), *rule)).collect::>() + }; + assert_eq!(region(0, 0), [(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0), FillRule::NonZero)]); + // Nested clips are listed outermost first; a clip path's own transform applies. + assert_eq!(got.shapes[1].clips.len(), 2); + assert_eq!(region(1, 0), region(0, 0)); + let moved = region(1, 1); + assert_eq!(moved[0], (kurbo::Rect::new(50.0, 0.0, 55.0, 5.0), FillRule::NonZero)); + assert_eq!(moved[1].1, FillRule::EvenOdd); + // The region lives in the referencing element's user space. + assert_eq!(region(2, 0), [(kurbo::Rect::new(0.0, 60.0, 10.0, 80.0), FillRule::NonZero)]); + // An empty clip path is a region with no pieces: it clips everything away. + assert!(region(3, 0).is_empty()); + assert!(got.shapes[4].clips.is_empty()); + } + + #[test] + fn clip_paths_that_cannot_be_applied_warn_and_leave_the_shape_unclipped() { + for (svg, needle) in [ + ( + r##""##, + "objectBoundingBox", + ), + (r##""##, "not a local "), + (r##""##, "not a local "), + (r##""##, "not a "), + ] { + let got = read(svg); + assert!(got.warnings.iter().any(|w| w.contains(needle)), "{svg}: {:?}", got.warnings); + assert!(got.shapes.iter().all(|s| s.clips.is_empty()), "{svg}"); + } + let deep = format!( + "{}{}", + "".repeat(17), + "".repeat(17) + ); + fails(&deep, "clip-path nesting exceeds 16 levels"); + } + + #[test] + fn text_masks_filters_and_ignored_css_warn_by_name() { + let got = read( + r##" + + Hello wide world + hidden + + + + "##, + ); + assert_eq!(got.shapes.len(), 3); + // The rule's supported selector still applies. + assert_eq!(got.shapes[0].stroke.as_deref(), Some("red")); + let warnings = got.warnings.join("\n"); + for needle in [ + "@import", + "rect:hover", + r#" "Hello wide world""#, + "convert text to paths", + r#": mask ignored"#, + ": filter ignored", + ] { + assert!(warnings.contains(needle), "{needle}: {warnings}"); + } + assert_eq!(got.warnings.len(), 5, "{warnings}"); + } + + #[test] + fn warnings_are_capped_and_the_rest_counted() { + let svg = format!("{}", (0..200).map(|i| format!("x")).collect::()); + let mut got = read(&svg); + assert_eq!((got.warnings.len(), got.dropped_warnings), (MAX_SVG_WARNINGS, 136)); + // A caller's own warnings go through the same bound, and repeats are kept once. + push_warning(&mut got.warnings, &mut got.dropped_warnings, "mine".into()); + assert_eq!((got.warnings.len(), got.dropped_warnings), (MAX_SVG_WARNINGS, 137)); + let (mut few, mut dropped) = (Vec::new(), 0); + push_warning(&mut few, &mut dropped, "once".into()); + push_warning(&mut few, &mut dropped, "once".into()); + assert_eq!((few.len(), dropped), (1, 0)); + } + + #[test] + fn review_fixes_markers_nested_viewports_square_corners_and_document_wide_limits() { + let message = |svg: &str| import::(svg, "test.svg").unwrap_err().0; + // A marker declared outside is a template, not art. + let got = read(r#""#); + assert_eq!(got.shapes.len(), 1); + // A nested viewport would move and scale its content: refused, not misplaced. + assert!(message(r#""#).contains("nested ")); + assert_eq!(read(r#""#).shapes.len(), 1); + // rx="0" is square corners, whatever ry says: four lines, no curves. + let square = read(r#""#); + assert!(!square.shapes[0].path.elements().iter().any(|e| matches!(e, kurbo::PathEl::CurveTo(..)))); + // One 1,000-segment path instanced 5,000 times is 5,000,000 segments. + let d: String = (0..1000).map(|i| format!("L{i} 1")).collect(); + let uses = r##""##.repeat(5000); + assert!(message(&format!(r#"{uses}"#)).contains("segment import limit")); + // 5,000 rules against 5,000 elements is 25,000,000 selector tests, past the 5,000,000 allowed. + let rules: String = (0..5000).map(|i| format!(".c{i}{{fill:red}}")).collect(); + let paths = r#""#.repeat(5000); + assert!(message(&format!("{paths}")).contains("stylesheet is too large")); + } +}