//! SVG import (see README.md for the supported subset and the specifications used). //! //! [`parse`] resolves styles, `use` references and gradients into a small render tree //! ([`Doc`]): groups with transforms and opacity, and shapes with fills and strokes in their own //! user space. [`rasterize`] draws it at any scale; the engine converts it to shape layers. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod bounds; mod color; mod pathdata; mod raster; use std::collections::HashMap; pub use color::parse_color; pub use kurbo::{Affine, BezPath}; pub use pathdata::parse_path_data; pub use raster::{blend_pixel, rasterize, rasterize_with}; #[derive(Debug, Clone, PartialEq)] pub enum Error { Xml(String), NotSvg, } impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Error::Xml(e) => write!(f, "invalid SVG: {e}"), Error::NotSvg => write!(f, "not an SVG document"), } } } impl std::error::Error for Error {} /// Quick sniff: an XML document whose root looks like ` bool { let head = &bytes[..bytes.len().min(2048)]; let s = String::from_utf8_lossy(head); s.contains(", /// `gradientUnits="objectBoundingBox"` (the default): coordinates are fractions of the /// shape's bounding box. pub bbox_units: bool, pub transform: Affine, pub spread: Spread, } #[derive(Clone, Debug, PartialEq)] pub enum Paint { Color([f64; 3]), Gradient(Gradient), } #[derive(Clone, Debug, PartialEq)] pub struct Stroke { pub paint: Paint, pub opacity: f64, pub width: f64, pub cap: Cap, pub join: Join, pub miter: f64, /// Dash array and offset. pub dash: Option<(Vec, f64)>, } impl Stroke { fn style(&self) -> effectcraft_path::StrokeStyle { effectcraft_path::StrokeStyle { width: self.width, cap: match self.cap { Cap::Butt => effectcraft_path::Cap::Butt, Cap::Round => effectcraft_path::Cap::Round, Cap::Square => effectcraft_path::Cap::Square, }, join: match self.join { Join::Miter => effectcraft_path::Join::Miter, Join::Round => effectcraft_path::Join::Round, Join::Bevel => effectcraft_path::Join::Bevel, }, miter: self.miter, dash: self.dash.clone(), taper: None, wave: None, } } } /// Shape geometry in its user space (basic shapes are kept so they can become parametric shape /// layer items). #[derive(Clone, Debug, PartialEq)] pub enum Geom { Rect { x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64 }, Ellipse { cx: f64, cy: f64, rx: f64, ry: f64 }, Path(BezPath), } impl Geom { pub fn to_path(&self) -> BezPath { use kurbo::Shape as _; match self { Geom::Rect { x, y, w, h, rx, ry } => { let r = kurbo::Rect::new(*x, *y, x + w, y + h); if *rx > 0.0 || *ry > 0.0 { if (rx - ry).abs() < 1e-9 { kurbo::RoundedRect::from_rect(r, *rx).to_path(0.01) } else { elliptic_round_rect(*x, *y, *w, *h, *rx, *ry) } } else { r.to_path(0.01) } } Geom::Ellipse { cx, cy, rx, ry } => kurbo::Ellipse::new((*cx, *cy), (*rx, *ry), 0.0).to_path(0.01), Geom::Path(p) => p.clone(), } } } fn elliptic_round_rect(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64) -> BezPath { const K: f64 = 0.552_284_749_830_793_6; let (kx, ky) = (rx * K, ry * K); let mut p = BezPath::new(); p.move_to((x + rx, y)); p.line_to((x + w - rx, y)); p.curve_to((x + w - rx + kx, y), (x + w, y + ry - ky), (x + w, y + ry)); p.line_to((x + w, y + h - ry)); p.curve_to((x + w, y + h - ry + ky), (x + w - rx + kx, y + h), (x + w - rx, y + h)); p.line_to((x + rx, y + h)); p.curve_to((x + rx - kx, y + h), (x, y + h - ry + ky), (x, y + h - ry)); p.line_to((x, y + ry)); p.curve_to((x, y + ry - ky), (x + rx - kx, y), (x + rx, y)); p.close_path(); p } #[derive(Clone, Debug, PartialEq)] pub struct Shape { /// Element id, or the element name (`rect`, `path`…). pub name: String, pub geom: Geom, /// User space → parent space. pub transform: Affine, pub fill: Option, pub fill_opacity: f64, pub fill_rule: FillRule, pub stroke: Option, pub opacity: f64, } #[derive(Clone, Debug, PartialEq)] pub struct Group { pub name: String, pub transform: Affine, pub opacity: f64, pub children: Vec, /// Clipping paths in the group's space (after `transform`); the children show only where /// every one of them covers (PDF / EPS clipping; SVG `clipPath` is not read yet). pub clip: Vec<(BezPath, FillRule)>, /// How the group composites onto what is below it (PDF blend modes; SVG `mix-blend-mode` /// is not read yet). pub blend: BlendMode, /// A soft mask (PDF `SMask` in the graphics state): the group shows where the mask's /// luminosity (or alpha) is high. pub mask: Option>, /// Composited on its own (a transparent backdrop, as SVG groups and PDF isolated /// transparency groups are) rather than over what is below it (PDF non-isolated groups and /// clipping groups: blend modes inside reach the backdrop). Matters only when the group is /// drawn offscreen (clip, mask, blend mode, opacity or knockout). pub isolated: bool, /// A PDF knockout group: each child composites with the group's initial backdrop instead /// of with the children below it. pub knockout: bool, } impl Group { /// An empty, opaque, unclipped group. pub fn new(name: &str) -> Group { Group { name: name.to_string(), transform: Affine::IDENTITY, opacity: 1.0, children: vec![], clip: vec![], blend: BlendMode::Normal, mask: None, isolated: true, knockout: false, } } } /// Separable and non-separable blend modes (ISO 32000-1 §11.3.5; the same formulas as the /// W3C Compositing and Blending Level 1 specification). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum BlendMode { #[default] Normal, Multiply, Screen, Overlay, Darken, Lighten, ColorDodge, ColorBurn, HardLight, SoftLight, Difference, Exclusion, Hue, Saturation, Color, Luminosity, } impl BlendMode { /// A PDF blend mode name (`/Multiply` …); unknown names are Normal. pub fn from_pdf_name(n: &str) -> BlendMode { use BlendMode::*; match n { "Multiply" => Multiply, "Screen" => Screen, "Overlay" => Overlay, "Darken" => Darken, "Lighten" => Lighten, "ColorDodge" => ColorDodge, "ColorBurn" => ColorBurn, "HardLight" => HardLight, "SoftLight" => SoftLight, "Difference" => Difference, "Exclusion" => Exclusion, "Hue" => Hue, "Saturation" => Saturation, "Color" => Color, "Luminosity" => Luminosity, _ => Normal, } } } /// A soft mask: `content` is drawn in the masked group's space; its luminosity (composited /// over `backdrop`) or its alpha is the mask. #[derive(Clone, Debug, PartialEq)] pub struct SoftMask { pub content: Group, pub luminosity: bool, pub backdrop: [f64; 3], } /// A raster image: `width`×`height` straight RGBA8 pixels; `transform` maps pixel space (y /// down, pixel `(0, 0)` at the top-left corner) to the parent's space. #[derive(Clone, Debug, PartialEq)] pub struct Image { pub name: String, pub transform: Affine, pub width: u32, pub height: u32, pub rgba: std::sync::Arc>, pub opacity: f64, } #[derive(Clone, Debug, PartialEq)] #[allow(clippy::large_enum_variant)] pub enum Node { Group(Group), Shape(Shape), Image(Image), } /// A parsed SVG document. `root.transform` maps the viewBox to `width`×`height` pixels. #[derive(Clone, Debug, PartialEq)] pub struct Doc { pub width: f64, pub height: f64, pub root: Group, /// Elements that were skipped (text, image, filter…), by name. pub skipped: Vec, } impl Doc { /// Pixel size (rounded up, at least 1×1). pub fn pixel_size(&self) -> (u32, u32) { (self.width.ceil().clamp(1.0, 30000.0) as u32, self.height.ceil().clamp(1.0, 30000.0) as u32) } /// Every shape with its accumulated transform (to document pixels) and opacity, in paint /// order. pub fn flatten(&self) -> Vec<(Affine, f64, &Shape)> { fn walk<'a>(g: &'a Group, m: Affine, o: f64, out: &mut Vec<(Affine, f64, &'a Shape)>) { let m = m * g.transform; let o = o * g.opacity; for c in &g.children { match c { Node::Group(sub) => walk(sub, m, o, out), Node::Shape(s) => out.push((m * s.transform, o * s.opacity, s)), Node::Image(_) => {} } } } let mut out = vec![]; walk(&self.root, Affine::IDENTITY, 1.0, &mut out); out } } // ---------------------------------------------------------------- styles /// Inherited style state. #[derive(Clone, Debug)] struct Style { fill: Option, fill_opacity: f64, fill_rule: FillRule, stroke: Option, stroke_opacity: f64, stroke_width: f64, cap: Cap, join: Join, miter: f64, dash: Option>, dash_offset: f64, color: [f64; 4], visible: bool, font_size: f64, } impl Default for Style { fn default() -> Self { Style { fill: Some(PaintRef::Color([0.0, 0.0, 0.0, 1.0])), fill_opacity: 1.0, fill_rule: FillRule::NonZero, stroke: None, stroke_opacity: 1.0, stroke_width: 1.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None, dash_offset: 0.0, color: [0.0, 0.0, 0.0, 1.0], visible: true, font_size: 16.0, } } } #[derive(Clone, Debug)] enum PaintRef { Color([f64; 4]), Url(String, Option<[f64; 4]>), Current, } /// The most selectors (each entry of a comma list counts) the `