diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 12e05c8a..21bfcb80 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -188,7 +188,7 @@ fn import_svg(s: &mut Session, p: &Value) -> Result { } (None, None) => return Err(bad(C, "give svg, or dataBase64 of an SVG or SVGZ file")), }; - let src = vectorcraft_svg::import(&svg).map_err(|e| bad(C, e.to_string()))?; + let (src, _) = super::fileio::import_svg(&svg, None).map_err(|e| bad(C, e.to_string()))?; let clip = Clipboard::from_document(&src); if clip.is_empty() { return Err(bad(C, "the SVG has no drawable objects")); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index cdba10f5..efec2464 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -8,7 +8,7 @@ use vectorcraft_geom::Affine; use super::super::*; use super::pdfimport::LoadOptions; -use super::{Format, SAVE_FORMATS, format, format_for_name, read_file}; +use super::{Format, SAVE_FORMATS, absolute_path, file_stamp, format, format_for_name, read_file}; use crate::EngineError; /// A file read into a document, with its format and non-fatal import notes. @@ -102,7 +102,8 @@ fn restore_or_import( Ok((doc, warnings, false)) } -/// Read a file of any readable format (`name`: its file name or path, for the extension and title). +/// Read a file of any readable format (`name`: its file name or path, for the extension and title; +/// a path's folder is where an SVG's relative image links are found). pub fn load(name: &str, bytes: &[u8]) -> Result { load_with(name, bytes, &LoadOptions::default()) } @@ -129,7 +130,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result "svg" | "svgz" => { let text = vectorcraft_svg::text_of(bytes).map_err(err)?; let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data))); - restore_or_import(editing, || vectorcraft_svg::import_with_report(&text).map_err(err))? + // Relative links are found in the SVG's folder (when `name` is a path). + let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f)); + restore_or_import(editing, || import_svg(&text, folder.as_deref()))? } "pdf" | "ai" | "ait" => { // Saved with Preserve Editing: the document it carries, unless some pages are picked. @@ -150,6 +153,20 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result Ok(Loaded { doc, format, warnings, restored, converted }) } +/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's +/// own): rasters stay linked, SVG files become art, missing ones placeholders (see +/// [`vectorcraft_svg::ImportOptions`]). +pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + let read = |path: &str| { + // Only files (not folders or devices) are read. + file_stamp(path)?; + let bytes = read_file(path).ok()?; + let link = crate::cmd::links::link_info(path, &bytes); + Some((bytes, link)) + }; + vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) +} + /// Open a file's bytes as the new active document (what `document.open` does) → /// `{index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks}`. The /// document keeps `path` (and its format, for Save) when Save can write that format back: not a diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 06439831..2f75c915 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -35,6 +35,7 @@ pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, enc pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; use load::err; +pub(crate) use load::import_svg; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image}; pub use pdfimport::{LoadOptions, page_document}; @@ -51,7 +52,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -656,3 +657,5 @@ mod tests_text; #[cfg(test)] mod tests_dxf; +#[cfg(test)] +mod tests_svgimport; diff --git a/crates/engine/src/cmd/fileio/tests_svgedit.rs b/crates/engine/src/cmd/fileio/tests_svgedit.rs index be91967e..06f98884 100644 --- a/crates/engine/src/cmd/fileio/tests_svgedit.rs +++ b/crates/engine/src/cmd/fileio/tests_svgedit.rs @@ -78,7 +78,10 @@ fn an_svg_edited_elsewhere_opens_as_plain_svg_with_a_warning() { let r = open(&mut s, "edited.svg", &edited); assert_eq!(r["warnings"][0], load::EDITING_STALE, "{r}"); let d = &s.doc().unwrap().doc; - assert!(d.symbols.is_empty() && d.patterns.iter().all(|p| p.name != "Dots"), "plain import: no symbols, no named pattern"); + // (SVG import keeps symbols since M4.41, so the roughen's result tells the plain import.) + let mut effects = 0; + d.walk(|n| effects += n.appearance.effects.len()); + assert!(effects == 0 && d.patterns.iter().all(|p| p.name != "Dots"), "plain import: the roughen as its result, no named pattern"); // Unreadable editing data (the hash only covers the markup around it) falls back too. let damaged = text.replacen(" Self { + let dir = std::env::temp_dir().join(format!("vectorcraft-svgimport-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("img")).unwrap(); + Self(dir) + } + fn file(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl Drop for Folder { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn png(w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([230, 20, 20, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// An SVG linking to `img/photo.png` (here) and `img/gone.png` (missing). +fn write_svg(dir: &Folder) -> String { + std::fs::write(dir.file("img/photo.png"), png(300, 200)).unwrap(); + let svg = r#" + + "#; + let path = dir.file("art.svg"); + std::fs::write(&path, svg).unwrap(); + path +} + +fn images(s: &Session) -> Vec { + let mut out = vec![]; + s.documents()[s.active_index().unwrap()].doc.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + out.push(im.clone()); + } + }); + out +} + +#[test] +fn opening_an_svg_links_its_images_from_its_folder() { + let dir = Folder::new("open"); + let path = write_svg(&dir); + let mut s = Session::new(); + let r = s.execute("document.open", &json!({"path": path})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("gone.png")), "{r}"); + let missing = r["missingLinks"].as_array().unwrap(); + assert_eq!(missing.len(), 1, "{r}"); + assert_eq!(missing[0]["name"], "gone.png"); + assert_eq!(r["modifiedLinks"], json!([])); + let ims = images(&s); + assert_eq!(ims.len(), 2); + let photo = ims[0].link.as_ref().unwrap(); + assert_eq!(PathBuf::from(&photo.path), PathBuf::from(dir.file("img/photo.png"))); + assert!(photo.modified.is_some() && photo.hash.is_some(), "{photo:?}"); + assert_eq!((ims[0].width, ims[0].height), (300, 200)); + // Links reports the found file as it is, the missing one as missing. + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((c["missing"].as_u64(), c["modified"].as_u64()), (Some(1), Some(0)), "{c}"); + // Once the file is there, Update Links reads it into the placeholder's box. + std::fs::write(dir.file("img/gone.png"), png(50, 50)).unwrap(); + let r = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(r["updated"].as_array().unwrap().len(), 1, "{r}"); + let gone = &images(&s)[1]; + assert_eq!((gone.width, gone.height), (50, 50)); + let b = gone.xf.transform_rect_bbox(vectorcraft_geom::Rect::new(0.0, 0.0, 50.0, 50.0)); + assert!((b.x0 - 200.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn placing_an_svg_reads_its_linked_images() { + let dir = Folder::new("place"); + let path = write_svg(&dir); + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + s.execute("file.place", &json!({"path": path})).unwrap(); + let ims = images(&s); + assert_eq!(ims.len(), 2); + assert!(ims.iter().all(|im| im.link.is_some())); + let doc = &s.documents()[s.active_index().unwrap()].doc; + assert!(!doc.images[&ims[0].key].is_proxy(), "the file's pixels"); +} diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 3ff50dc8..e855e1a3 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -125,6 +125,24 @@ fn arb_svg_attr() -> impl Strategy { "text-anchor", "side", "path", + // Filters, spreads, hidden objects and linked images. + "spreadMethod", + "display", + "filter", + "clip-path", + "stdDeviation", + "flood-color", + "flood-opacity", + "in", + "in2", + "result", + "operator", + "values", + "tableValues", + "type", + "preserveAspectRatio", + "overflow", + "data-vc-blend", ]); let value = prop_oneof![ arb_num(), @@ -138,6 +156,23 @@ fn arb_svg_attr() -> impl Strategy { prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))), prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"]) .prop_map(str::to_string), + prop::sample::select(vec![ + "reflect", + "repeat", + "SourceAlpha", + "SourceGraphic", + "e", + "over", + "in", + "table", + "1 0", + "multiply", + "img/x%2.png", + "file:///C:/nope.png", + "https://x/y.png", + "data:image/svg+xml;base64,PHN2Zy8+", + ]) + .prop_map(str::to_string), ]; (names, value).prop_map(|(n, v)| format!(" {n}=\"{v}\"")) } @@ -168,6 +203,17 @@ fn arb_svg_element(depth: u32) -> BoxedStrategy { "svg", "marker", "a", + "filter", + "feGaussianBlur", + "feOffset", + "feFlood", + "feComposite", + "feMerge", + "feMergeNode", + "feColorMatrix", + "feDropShadow", + "feComponentTransfer", + "feFuncA", ]); let attrs = || prop::collection::vec(arb_svg_attr(), 0..6).prop_map(|v| v.concat()); let leaf = (tag.clone(), attrs(), "[a-z ]{0,6}").prop_map(|(t, a, txt)| format!("<{t} id=\"e\"{a} href=\"#e\">{txt}")); diff --git a/crates/svg/Cargo.toml b/crates/svg/Cargo.toml index 9411daef..aa193591 100644 --- a/crates/svg/Cargo.toml +++ b/crates/svg/Cargo.toml @@ -19,6 +19,8 @@ kurbo = { workspace = true } usvg = { workspace = true } thiserror = { workspace = true } serde = { workspace = true } +# Live effect params read from filters. +serde_json = { workspace = true } # SVGZ (gzip, pure-Rust backend: builds for wasm). flate2 = { workspace = true } image = { workspace = true } @@ -30,4 +32,3 @@ resvg = { version = "0.45", default-features = false } vectorcraft-testkit = { workspace = true } proptest = { workspace = true } image = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 273458f5..036ec098 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -204,7 +204,7 @@ struct SymbolDef { /// canvas transforms the art's geometry alone: stroke weights, effects, patterns and unlinked /// masks stay put). #[derive(Clone, Copy)] -struct Reuse { +pub(crate) struct Reuse { /// Translations (no pattern paints or unlinked masks). moves: bool, /// Rotations and reflections (nor effects or brushes). @@ -227,7 +227,7 @@ impl Reuse { const NONE: Self = Self { moves: false, turns: false, scales: false }; /// What a def of symbol art `art` stands for. - fn of(doc: &Document, art: &Node) -> Self { + pub(crate) fn of(doc: &Document, art: &Node) -> Self { let mut r = Self { moves: true, turns: true, scales: true }; r.scan(doc, art, 0); r @@ -277,7 +277,7 @@ impl Reuse { } /// Can the def stand for an instance with transform `xf`? - fn allows(self, xf: Affine) -> bool { + pub(crate) fn allows(self, xf: Affine) -> bool { const EPS: f64 = 1e-9; let [a, b, c, d, _, _] = xf.as_coeffs(); if (a - 1.0).abs() < EPS && b.abs() < EPS && c.abs() < EPS && (d - 1.0).abs() < EPS { @@ -1250,7 +1250,9 @@ impl Writer<'_> { let art = self.detached(3, |w| w.node(&sym.art)); self.symbol_nest -= 1; (self.xf, self.instance_xf, self.knockout, self.names, self.anonymous) = saved; - self.def(1, &format!("")); + // The symbol's name, when the id had to differ from it, comes back on import. + let name = if id == sym.name { String::new() } else { format!(" data-name=\"{}\"", xml_escape(&sym.name)) }; + self.def(1, &format!("")); self.defs.push_str(&art); self.def(1, ""); if let Some(def) = self.symbols.get_mut(&sym.name) { @@ -1326,7 +1328,16 @@ impl Writer<'_> { ), RasterFx::GaussianBlur { radius } => format!("", sd(*radius)), }; - self.def(1, &format!("{body}")); + // Filters composite normally: a shadow's or glow's other blend mode is recorded for import. + let mode = match f { + RasterFx::DropShadow { mode, .. } | RasterFx::OuterGlow { mode, .. } | RasterFx::InnerGlow { mode, .. } + if *mode != BlendMode::Normal => + { + format!(" {}=\"{}\"", crate::import::BLEND, blend_css(*mode)) + } + _ => String::new(), + }; + self.def(1, &format!("{body}")); self.line(&format!("")); self.depth += 1; } diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 79c7530e..1c5e367f 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -13,24 +13,37 @@ use vectorcraft_doc::{ }; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes}; -use crate::{SvgError, fnv1a}; +use crate::export::Reuse; +use crate::{ImportOptions, SvgError, fnv1a}; use css::XNode; use text::TextSlots; +pub(crate) use fx::BLEND; + mod css; +mod files; +mod fx; +mod spread; mod text; -pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { - let (linked, links) = link_ids(svg); - let (svg, hidden) = hidden_groups(&linked); +pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { + let (svg, found) = prepass(svg, opts); let svg = svg.as_ref(); let xml = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) .map_err(|e| SvgError::Parse(e.to_string()))?; let units = RootUnits::of(xml.root_element()); - let mut warnings = Vec::new(); + let mut warnings = found.warnings; let (src, slots) = text::prepare(svg, &xml, units.dpi, &mut warnings); - let opt = usvg::Options { dpi: units.dpi as f32, font_size: DEFAULT_FONT_SIZE as f32, ..usvg::Options::default() }; - let tree = usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?; + let files = found.files; + let tree = { + let opt = usvg::Options { + dpi: units.dpi as f32, + font_size: DEFAULT_FONT_SIZE as f32, + image_href_resolver: files.resolver(), + ..usvg::Options::default() + }; + usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))? + }; let size = tree.size(); let (kx, ky) = units.k; let mut doc = Document::new(size.width() as f64 * kx, size.height() as f64 * ky); @@ -39,13 +52,20 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { doc, warnings, mask_flags: mask_flags(&xml), - links, + links: found.links, slots, patterns: HashMap::new(), midpoints: midpoint_stops(&xml), labels: labels(&xml), - hidden, + hidden: found.hidden, + uses: found.uses, + symbols: HashMap::new(), + blends: fx::blends(&xml), + files, }; + if im.files.nested_text() { + im.warn("text inside an SVG image isn't imported".into()); + } // usvg wraps everything in an id-less group carrying the viewBox transform when needed. let mut top = tree.root(); @@ -61,7 +81,7 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec), SvgError> { // Top-level `` elements become layers (as in the reference app); otherwise all art goes // into "Layer 1". 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() && !im.links.contains_key(g.id()) && is_plain(g) && im.text_slot(g).is_none()); + 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()) && is_plain(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 = top.children().iter().any(|c| is_layer(&im, c)) && top.children().iter().all(|c| is_layer(&im, c) || is_text(&im, c)); if layer_mode { @@ -138,6 +158,9 @@ impl RootUnits { } } +/// How many clip paths deep (``) an object's clip is read. +const MAX_CLIP_NEST: usize = 16; + /// Points per inch. pub(super) const PT_PER_IN: f64 = 72.0; /// CSS pixels per inch. @@ -148,7 +171,7 @@ struct Importer { warnings: Vec, /// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]). mask_flags: HashMap, - /// The URL of each ``'s group, by its id ([`link_ids`]). + /// The URL of each ``'s group, by its id ([`prepass`]). links: HashMap, /// Texts read from the XML, by placeholder ([`text`]). slots: TextSlots, @@ -158,8 +181,48 @@ struct Importer { midpoints: HashMap>>, /// Object names by element id (see [`labels`]). labels: HashMap, - /// Ids of the hidden groups shown for usvg ([`hidden_groups`]). + /// Ids of the undisplayed objects shown for usvg ([`prepass`]). hidden: HashSet, + /// The `` id each `` of a symbol shows, by the ``'s id ([`prepass`]). + uses: HashMap, + /// The symbols made so far, by `` id. + symbols: HashMap, + /// Blend modes of the shadows and glows our export wrote, by filter id ([`fx::blends`]). + blends: HashMap, + /// The files `` elements link to. + files: files::Files, +} + +/// A `` made a symbol: its name, its art with no object ids (what each `` has to show +/// to be an instance of it) and the instance transforms the art can stand for. +struct SymbolMade { + name: String, + art: Node, + reuse: Reuse, +} + +/// `n` with every object id (its descendants' and its mask art's too) zeroed, to compare art. +fn without_ids(n: &Node) -> Node { + let mut n = n.clone(); + n.id = vectorcraft_doc::NodeId(0); + if let Some(m) = n.mask.as_deref_mut() { + m.art = Arc::new(without_ids(&m.art)); + } + for c in n.children_mut().into_iter().flatten() { + *c = Arc::new(without_ids(c)); + } + n +} + +/// `base`, or `base 2`, `base 3`… : the first that isn't `taken`. +fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { + let mut name = base.to_string(); + let mut k = 2; + while taken(&name) { + name = format!("{base} {k}"); + k += 1; + } + name } /// The stops our export added for midpoints (`data-vc-midpoint`), by gradient id: for each stop, @@ -209,39 +272,151 @@ fn mask_flags(xml: &roxmltree::Document) -> HashMap { xml.descendants().filter(|n| n.tag_name().name() == "mask" && n.has_attribute(attr)).filter_map(flags).collect() } -/// Prefix of the ids [`link_ids`] gives the `` elements that have none. -const LINK_ID: &str = "vectorcraft-link-"; +/// Prefix of the ids [`prepass`] gives the elements it has to find again that have none. +const MADE_UP_ID: &str = "vectorcraft-id-"; + +/// Is `id` one [`prepass`] made up (not a name)? +fn made_up(id: &str) -> bool { + id.starts_with(MADE_UP_ID) +} /// The URL an `` element links to. pub(super) fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> { a.attribute("href").or_else(|| a.attribute(("http://www.w3.org/1999/xlink", "href"))).filter(|h| !h.is_empty()) } -/// usvg reads `` as a plain group and drops the URL. So that the group can be found, every -/// link without an id gets one ([`LINK_ID`]…); returns the SVG with those ids and the URLs by id. -fn link_ids(svg: &str) -> (std::borrow::Cow<'_, str>, HashMap) { - let mut links = HashMap::new(); - if !svg.contains("`'s group, by its id. + links: HashMap, + /// Ids of the undisplayed objects shown for usvg: they come back hidden. + hidden: HashSet, + /// The `` id each `` of a symbol shows, by the ``'s id. + uses: HashMap, + /// The files `` elements link to. + files: files::Files, + warnings: Vec, +} + +/// Changes to the SVG text: `(range, replacement)`, an empty range inserting. +#[derive(Default)] +pub(super) struct Edits { + list: Vec<(std::ops::Range, String)>, + /// The ids made up so far, by element. + ids: HashMap, +} + +impl Edits { + fn insert(&mut self, at: usize, text: String) { + self.list.push((at..at, text)); + } + + /// The id of element `n` (in `svg`), made up and written in when it has none. + fn id(&mut self, svg: &str, n: XNode) -> String { + if let Some(id) = n.attribute("id").filter(|id| !id.is_empty()) { + return id.to_string(); + } + if let Some(id) = self.ids.get(&n.id()) { + return id.clone(); + } + let id = format!("{MADE_UP_ID}{}", self.ids.len()); + self.set(svg, n, "id", &id); + self.ids.insert(n.id(), id.clone()); + id + } + + /// Set attribute `name` of element `n` (in `svg`) to `value` (XML-escaped already). + pub(super) fn set(&mut self, svg: &str, n: XNode, name: &str, value: &str) { + match n.attributes().find(|a| a.name() == name && a.namespace().is_none()) { + Some(a) => self.list.push((a.range_value(), value.to_string())), + None => self.insert(tag_name_end(svg, n), format!(" {name}=\"{value}\"")), + } + } + + /// `svg` with the edits made; overlapping ones and ranges off character boundaries are + /// skipped. + fn apply(mut self, svg: &str) -> Cow<'_, str> { + if self.list.is_empty() { + return svg.into(); + } + // Stable: inserts at one place keep their order. + self.list.sort_by_key(|(r, _)| (r.start, r.end)); + let mut out = String::with_capacity(svg.len() + self.list.iter().map(|(_, t)| t.len()).sum::()); + let mut last = 0; + for (r, text) in self.list { + let (Some(part), Some(_)) = (svg.get(last..r.start), svg.get(r.clone())) else { continue }; + out.push_str(part); + out.push_str(&text); + last = r.end; + } + out.push_str(svg.get(last..).unwrap_or("")); + out.into() + } +} + +/// usvg reads `` and `` as plain groups, drops what isn't displayed and reads linked +/// files on its own. Before it runs, the SVG is changed so that what we keep can be found: +/// * every `` and every `` of a `` gets an id when it has none +/// ([`MADE_UP_ID`]…), recorded with its URL or symbol; +/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded, +/// to come back hidden; one something links to (a `` template) stays as it is; +/// * linked files are read ([`files`]). +fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { + let mut found = Found::default(); + // (`:` for prefixed elements such as ``.) + if ![" id.to_string(), - None => { - let id = format!("{LINK_ID}{}", inserts.len()); - inserts.push((tag_name_end(svg, a), format!(" id=\"{id}\""))); - id - } - }; - links.insert(id, url.to_string()); + let mut edits = Edits::default(); + let elements = || xml.descendants().filter(|n| n.is_element()); + for a in elements().filter(|n| n.tag_name().name() == "a") { + if let Some(url) = href(a) { + found.links.insert(edits.id(svg, a), url.to_string()); + } + } + let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect(); + for u in elements().filter(|n| n.tag_name().name() == "use") { + if let Some(sym) = href(u).and_then(|h| h.strip_prefix('#')).filter(|s| symbols.contains(s)) { + found.uses.insert(edits.id(svg, u), sym.to_string()); + } + } + if svg.contains("display") { + hidden_objects(svg, &xml, &mut edits, &mut found.hidden); + } + found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings); + (edits.apply(svg), found) +} + +/// The elements that are objects (what [`hidden_objects`] shows). +const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"]; + +/// Show the undisplayed objects outside `` (and the like) for usvg, recording their ids. +fn hidden_objects(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet) { + let css = css::Styles::new(xml); + let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); + let mut todo: Vec = xml.root_element().children().filter(XNode::is_element).collect(); + while let Some(n) = todo.pop() { + let tag = n.tag_name().name(); + if !OBJECTS.contains(&tag) || n.attribute("id").is_some_and(|id| linked.contains(id)) { + continue; + } + if matches!(tag, "g" | "a") { + todo.extend(n.children().filter(XNode::is_element)); + } + if css.own(n, "display").as_deref() != Some("none") { + continue; + } + ids.insert(edits.id(svg, n)); + // A `style` declaration wins over the attribute and style sheet rules. + match n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { + Some(a) => edits.insert(a.range_value().end, ";display:inline".to_string()), + None => edits.insert(tag_name_end(svg, n), " style=\"display:inline\"".to_string()), + } } - (insert(svg, inserts), links) } /// Where the start tag of element `n` ends its name (where an attribute can go). @@ -250,58 +425,6 @@ fn tag_name_end(svg: &str, n: XNode) -> usize { svg.get(start + 1..).and_then(|s| s.find(|c: char| c.is_whitespace() || c == '/' || c == '>')).map_or(start + 2, |i| start + 1 + i) } -/// `svg` with each `(offset, text)` of `inserts` (in offset order) inserted; offsets that are out -/// of order or not on a character boundary are skipped. -fn insert(svg: &str, inserts: Vec<(usize, String)>) -> Cow<'_, str> { - if inserts.is_empty() { - return svg.into(); - } - let mut out = String::with_capacity(svg.len() + inserts.iter().map(|(_, t)| t.len()).sum::()); - let mut last = 0; - for (at, text) in inserts { - let Some(part) = svg.get(last..at) else { continue }; - out.push_str(part); - out.push_str(&text); - last = at; - } - out.push_str(svg.get(last..).unwrap_or("")); - out.into() -} - -/// usvg drops what isn't displayed, but hidden layers (as Save writes them: `display: none` on a -/// top-level ``, or on such a group inside groups) come back hidden. Those groups are shown -/// for usvg; returns the SVG so changed and their ids. A group something links to (a `` -/// template) stays as it is. -fn hidden_groups(svg: &str) -> (Cow<'_, str>, HashSet) { - let mut ids = HashSet::new(); - if !svg.contains("display") { - return (svg.into(), ids); - } - let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else { - return (svg.into(), ids); - }; - let css = css::Styles::new(&xml); - let linked: HashSet<&str> = xml.descendants().filter_map(href).filter_map(|h| h.strip_prefix('#')).collect(); - let group = |n: &XNode| n.is_element() && n.tag_name().name() == "g" && n.attribute("id").is_none_or(|id| !linked.contains(id)); - let mut inserts = vec![]; - let mut groups: Vec = xml.root_element().children().filter(group).collect(); - while let Some(g) = groups.pop() { - groups.extend(g.children().filter(group)); - let Some(id) = g.attribute("id").filter(|id| !id.is_empty()) else { continue }; - if css.own(g, "display").as_deref() != Some("none") { - continue; - } - ids.insert(id.to_string()); - // A `style` declaration wins over the attribute and style sheet rules. - inserts.push(match g.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) { - Some(a) => (a.range_value().end, ";display:inline".to_string()), - None => (tag_name_end(svg, g), " style=\"display:inline\"".to_string()), - }); - } - inserts.sort_by_key(|(at, _)| *at); - (insert(svg, inserts), ids) -} - /// Make `n` link to `url`, unless it links somewhere already (an inner link wins). fn link(n: &mut Node, url: &str) { if n.url().is_none() { @@ -373,6 +496,37 @@ fn bezpath(p: &usvg::tiny_skia_path::Path, m: Affine) -> BezPath { bp } +/// The shapes of clip path `cp` on an element whose user space `ts` maps to the document. +fn clip_shapes(cp: &usvg::ClipPath, ts: Affine) -> Vec<(BezPath, FillRule)> { + fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { + for c in g.children() { + match c { + usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out), + usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), + _ => {} + } + } + } + let mut shapes = Vec::new(); + collect(cp.root(), ts * aff(cp.transform()), &mut shapes); + shapes +} + +/// Does clip path `cp` (on an element whose user space `ts` maps to the document) leave `art`, +/// placed by `xf`, whole? (Only its bounds are checked: the box must be inside a clip shape.) +fn uncut(cp: &usvg::ClipPath, ts: Affine, art: &Node, xf: Affine) -> bool { + let Some(b) = art.visual_bounds() else { return false }; + if cp.clip_path().is_some() { + return false; + } + // Art touching the clip's edge isn't cut by it. + let e = 1e-6 * (b.width() + b.height()).max(1e-9); + let b = b.inflate(-e, -e); + let shapes = clip_shapes(cp, ts); + let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x1, b.y1), Point::new(b.x0, b.y1)]; + corners.iter().all(|c| shapes.iter().any(|(s, _)| kurbo::Shape::contains(s, xf * *c))) +} + fn rule(r: usvg::FillRule) -> FillRule { match r { usvg::FillRule::NonZero => FillRule::NonZero, @@ -392,13 +546,17 @@ impl Importer { } fn node(&mut self, n: &usvg::Node, acc: Affine) -> Option { - match n { + let mut out = match n { usvg::Node::Group(g) => self.group(g, acc), usvg::Node::Path(p) => self.path(p, acc), usvg::Node::Image(i) => self.image(i, acc), // Only present when usvg had fonts; text is read from the XML instead. usvg::Node::Text(_) => None, + }?; + if self.hidden.contains(n.id()) { + out.visible = false; } + Some(out) } /// A gradient's stops, midpoints restored. @@ -411,10 +569,11 @@ impl Importer { self.labels.get(id).map_or(id, String::as_str) } + /// A new object named after element `id` (unless it has none or a made-up one). fn named(&mut self, id: &str, kind: NodeKind) -> Node { let nid = self.doc.alloc_id(); let mut n = Node::new(nid, kind); - if !id.is_empty() { + if !id.is_empty() && !made_up(id) { n.name = Some(self.name_of(id).to_string()); } n @@ -422,13 +581,66 @@ impl Importer { fn group(&mut self, g: &usvg::Group, acc: Affine) -> Option { let url = self.links.get(g.id()).cloned(); - let mut n = self.group_node(g, acc)?; + let mut n = match self.instance(g, acc) { + Some(n) => n, + None => self.group_node(g, acc)?, + }; if let Some(url) = url { link(&mut n, &url); } - if self.hidden.contains(g.id()) { - n.visible = false; + Some(n) + } + + /// The group of a `` of a `` → an instance of the symbol made from it (on its + /// first ``). `None` when the group has to be plain art: it shows the symbol differently + /// (properties inherited from the ``), or the canvas would paint the instance differently + /// under its transform (see [`Reuse`]), or its viewport cuts the art, or it filters. + fn instance(&mut self, g: &usvg::Group, acc: Affine) -> Option { + let sym = self.uses.get(g.id())?.clone(); + if !g.filters().is_empty() { + return None; } + // usvg puts the art in a group moved by the ``'s position and the symbol's viewBox. + let ts = acc * aff(g.transform()); + let (mut xf, mut inner) = (ts, g); + while let [usvg::Node::Group(s)] = inner.children() + && s.id().is_empty() + && is_plain(s) + && self.text_slot(s).is_none() + { + xf *= aff(s.transform()); + inner = s; + } + let mut kids = self.children(inner, Affine::IDENTITY); + let art = match kids.len() { + 0 => return None, + 1 => Arc::unwrap_or_clone(kids.pop()?), + _ => self.named("", NodeKind::Group { children: kids, clip: false }), + }; + // usvg clips a `` of a symbol to its viewport: that only counts when it cuts the art. + let whole = g.clip_path().is_none_or(|cp| uncut(cp, ts, &art, xf)); + let name = match self.symbols.get(&sym) { + Some(made) if whole && made.art == without_ids(&art) && made.reuse.allows(xf) => made.name.clone(), + Some(_) => return None, + None => { + let name = unique_name(self.name_of(&sym), |n| self.doc.symbols.iter().any(|s| s.name == n)); + let reuse = Reuse::of(&self.doc, &art); + let made = SymbolMade { name: name.clone(), art: without_ids(&art), reuse }; + self.doc.symbols.push(vectorcraft_doc::Symbol { name: name.clone(), art: Arc::new(art) }); + self.symbols.insert(sym, made); + if !whole || !reuse.allows(xf) { + return None; + } + name + } + }; + let label = if made_up(g.id()) { "a symbol instance".to_string() } else { format!("'{}'", g.id()) }; + let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); + let mut n = self.named(g.id(), NodeKind::SymbolInstance { symbol: name, xf }); + n.opacity = g.opacity().get(); + n.blend = blend(g.blend_mode()); + n.isolate = g.isolate(); + n.mask = mask; Some(n) } @@ -453,7 +665,10 @@ impl Importer { let to_text = obj.xf.inverse(); let resolved: Vec = (0..paints) .map(|k| match kids.get(main + 1 + k) { - Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| self.paint(f.paint(), to_text)), + Some(usvg::Node::Path(p)) => p.fill().map_or(Paint::None, |f| { + let area = kurbo::Shape::bounding_box(&bezpath(p.data(), to_text)); + self.paint(f.paint(), to_text, Some(area)) + }), _ => Paint::None, }) .collect(); @@ -478,27 +693,52 @@ impl Importer { return None; } let is_text = text.is_some(); - // The made-up ids of links aren't names. - let id = if is_text || g.id().starts_with(LINK_ID) { "" } else { g.id() }; + // Made-up ids aren't names. + let id = if is_text || made_up(g.id()) { "" } else { g.id() }; let label = match (&text, id) { (Some(t), _) => t.first().and_then(|n| n.name.as_deref()).map_or_else(|| "a text".to_string(), |n| format!("text '{n}'")), (None, "") => "a group".to_string(), (None, id) => format!("'{id}'"), }; let mask = g.mask().and_then(|m| self.opacity_mask(m, ts, &label)); - if !g.filters().is_empty() { - self.warn(format!("filter on {label} ignored")); - } + // Blurs, shadows and glows become live effects (see [`fx`]). + let effects = match g.filters() { + [] => vec![], + filters => fx::effects(filters, ts, &self.blends).unwrap_or_else(|| { + self.warn(format!("filter on {label} ignored")); + vec![] + }), + }; let mut children = match text { Some(t) => t, None => self.children(g, ts), }; let mut n = if let Some(cp) = g.clip_path() { - if cp.clip_path().is_some() { - self.warn(format!("nested clip path on {label} approximated by its outer clip")); + // SVG filters before it clips: the effects go on the art inside the clip. + if !effects.is_empty() { + let mut inner = self.named("", NodeKind::Group { children, clip: false }); + inner.appearance.effects = effects.clone(); + children = vec![Arc::new(inner)]; } - let clip = self.clip_node(cp, ts)?; - let mut ch = vec![Arc::new(clip)]; + // A clip path clipped in turn (``): one clip group in another, so + // the clips intersect. + let mut clips = vec![self.clip_node(cp, ts)?]; + let mut next = cp.clip_path(); + while let Some(c) = next { + if clips.len() > MAX_CLIP_NEST { + self.warn(format!("clip paths nested more than {MAX_CLIP_NEST} deep on {label}: the deeper ones are ignored")); + break; + } + clips.push(self.clip_node(c, ts)?); + next = c.clip_path(); + } + let outer = clips.pop()?; + for clip in clips { + let mut ch = vec![Arc::new(clip)]; + ch.extend(children); + children = vec![Arc::new(self.named("", NodeKind::Group { children: ch, clip: true }))]; + } + let mut ch = vec![Arc::new(outer)]; ch.extend(children); self.named(id, NodeKind::Group { children: ch, clip: true }) } else { @@ -506,10 +746,12 @@ impl Importer { return None; } // An id-less wrapper around a single object (usvg adds these for opacity/transform on - // shapes): fold its opacity and blend into the object. A text takes its own mask too. + // shapes): fold its opacity, blend and effects into the object (unless the object + // blends or masks before the effects). A text takes its own mask too. if (is_text || id.is_empty() && mask.is_none()) && children.len() == 1 && (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal) + && (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].mask.is_none()) && let Some(only) = children.pop() { let mut c = Arc::unwrap_or_clone(only); @@ -521,9 +763,12 @@ impl Importer { if mask.is_some() { c.mask = mask; } + c.appearance.effects.extend(effects); return Some(c); } - self.named(id, NodeKind::Group { children, clip: false }) + let mut n = self.named(id, NodeKind::Group { children, clip: false }); + n.appearance.effects = effects; + n }; n.opacity = g.opacity().get(); n.blend = blend(g.blend_mode()); @@ -567,17 +812,7 @@ impl Importer { } fn clip_node(&mut self, cp: &usvg::ClipPath, ts: Affine) -> Option { - fn collect(g: &usvg::Group, m: Affine, out: &mut Vec<(BezPath, FillRule)>) { - for c in g.children() { - match c { - usvg::Node::Group(g) => collect(g, m * aff(g.transform()), out), - usvg::Node::Path(p) => out.push((bezpath(p.data(), m), p.fill().map(|f| rule(f.rule())).unwrap_or_default())), - _ => {} - } - } - } - let mut shapes = Vec::new(); - collect(cp.root(), ts * aff(cp.transform()), &mut shapes); + let shapes = clip_shapes(cp, ts); if shapes.is_empty() { return None; } @@ -601,35 +836,23 @@ impl Importer { Some(self.named(&id, NodeKind::Compound { children, rule: r })) } - fn paint(&mut self, p: &usvg::Paint, m: Affine) -> Paint { - let gp = |kind, stops, geom: GradientGeom| { - Paint::Gradient(Box::new(GradientPaint { - gradient: Gradient { kind, stops }, - geom: Some(geom), - angle: geom.angle_deg(), - swatch: None, - freeform: None, - })) - }; - match p { - usvg::Paint::Color(c) => Paint::solid(Color::rgb8(c.red, c.green, c.blue)), + /// A usvg paint mapped by `m`, for art covering `area` (after `m`: where a reflected or + /// repeated gradient has to reach). + fn paint(&mut self, p: &usvg::Paint, m: Affine, area: Option) -> Paint { + let (kind, base, mut geom) = match p { + usvg::Paint::Color(c) => return Paint::solid(Color::rgb8(c.red, c.green, c.blue)), + usvg::Paint::Pattern(pt) => return self.pattern(pt, m), usvg::Paint::LinearGradient(lg) => { - if lg.spread_method() != usvg::SpreadMethod::Pad { - self.warn(format!("gradient '{}': spreadMethod approximated as pad", lg.id())); - } - let mut geom = GradientGeom { + let geom = GradientGeom { start: Point::new(lg.x1() as f64, lg.y1() as f64), end: Point::new(lg.x2() as f64, lg.y2() as f64), aspect: 1.0, focal: None, }; - geom.transform(m * aff(lg.transform()), GradientKind::Linear); - gp(GradientKind::Linear, self.stops(lg), geom) + let base: &usvg::BaseGradient = lg; + (GradientKind::Linear, base, geom) } usvg::Paint::RadialGradient(rg) => { - if rg.spread_method() != usvg::SpreadMethod::Pad { - self.warn(format!("gradient '{}': spreadMethod approximated as pad", rg.id())); - } if rg.fr().get() > 1e-4 { self.warn(format!("gradient '{}': focal radius ignored", rg.id())); } @@ -637,11 +860,33 @@ impl Importer { let mut geom = GradientGeom { start: c, end: c + Vec2::new(rg.r().get() as f64, 0.0), aspect: 1.0, focal: None }; // A focal point outside the circle is pulled inside it. geom.set_focal(Some(Point::new(rg.fx() as f64, rg.fy() as f64))); - geom.transform(m * aff(rg.transform()), GradientKind::Radial); - gp(GradientKind::Radial, self.stops(rg), geom) + let base: &usvg::BaseGradient = rg; + (GradientKind::Radial, base, geom) + } + }; + geom.transform(m * aff(base.transform()), kind); + let mut stops = self.stops(base); + let reflect = match base.spread_method() { + usvg::SpreadMethod::Pad => None, + usvg::SpreadMethod::Reflect => Some(true), + usvg::SpreadMethod::Repeat => Some(false), + }; + if let Some(reflect) = reflect { + match area.map(|a| spread::expand(kind, &mut geom, &mut stops, reflect, a)) { + Some(spread::Expanded::Whole) => {} + Some(spread::Expanded::Capped) => { + self.warn(format!("gradient '{}': spreadMethod repeats more than {} times; it pads after that", base.id(), spread::MAX_PERIODS)) + } + None => self.warn(format!("gradient '{}': spreadMethod approximated as pad", base.id())), } - usvg::Paint::Pattern(pt) => self.pattern(pt, m), } + Paint::Gradient(Box::new(GradientPaint { + gradient: Gradient { kind, stops }, + geom: Some(geom), + angle: geom.angle_deg(), + swatch: None, + freeform: None, + })) } /// `` → a pattern swatch painted with the pattern's tile placement. The swatch art is @@ -664,12 +909,7 @@ impl Importer { children.extend(content); let art = self.named("", NodeKind::Group { children, clip: true }); let base = if pt.id().is_empty() { "Pattern" } else { pt.id() }; - let mut name = base.to_string(); - let mut k = 2; - while self.doc.pattern(&name).is_some() { - name = format!("{base} {k}"); - k += 1; - } + let name = unique_name(base, |n| self.doc.pattern(n).is_some()); let mut def = PatternDef::new(&name, vec![Arc::new(art)]); def.tile = tile; self.doc.patterns.push(def); @@ -677,11 +917,12 @@ impl Importer { Paint::Pattern { pattern: name, xf } } - fn appearance(&mut self, p: &usvg::Path, m: Affine) -> (Appearance, FillRule) { + /// The fill and stroke of `p` mapped by `m`; `area`: the bounds of the path after `m`. + fn appearance(&mut self, p: &usvg::Path, m: Affine, area: Option) -> (Appearance, FillRule) { let mut fill = None; let mut r = FillRule::NonZero; if let Some(f) = p.fill() { - let paint = self.paint(f.paint(), m); + let paint = self.paint(f.paint(), m, area); let mut fl = FillLayer::new(paint); fl.opacity = f.opacity().get(); r = rule(f.rule()); @@ -690,7 +931,9 @@ impl Importer { let mut stroke = None; if let Some(s) = p.stroke() { let scale = m.determinant().abs().sqrt(); - let paint = self.paint(s.paint(), m); + // The stroke reaches past the path (miters further: a generous margin). + let reach = s.width().get() as f64 * scale * (s.miterlimit().get() as f64).max(1.0); + let paint = self.paint(s.paint(), m, area.map(|a| a.inflate(reach, reach))); let mut sl = StrokeLayer::new(paint, s.width().get() as f64 * scale); sl.opacity = s.opacity().get(); sl.cap = match s.linecap() { @@ -728,7 +971,7 @@ impl Importer { if path.is_empty() { return None; } - let (appearance, r) = self.appearance(p, acc); + let (appearance, r) = self.appearance(p, acc, path.bounds()); let mut n = if path.subpaths.len() > 1 { // Multi-subpath SVG paths are compound paths (as in Illustrator). let children = path @@ -758,10 +1001,7 @@ impl Importer { usvg::ImageKind::PNG(d) => (d, "image/png"), usvg::ImageKind::GIF(d) => (d, "image/gif"), usvg::ImageKind::WEBP(d) => (d, "image/webp"), - usvg::ImageKind::SVG(_) => { - self.warn("embedded SVG image skipped".into()); - return None; - } + usvg::ImageKind::SVG(tree) => return self.svg_image(tree, acc), }; let size = i.size(); let (pw, ph) = image::ImageReader::new(std::io::Cursor::new(&bytes[..])) @@ -770,8 +1010,25 @@ impl Importer { .and_then(|r| r.into_dimensions().ok()) .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); let key = format!("img-{:016x}", fnv1a(bytes)); - self.doc.images.entry(key.clone()).or_insert_with(|| ImageBlob::new(mime, bytes.to_vec())); + // A linked file keeps a preview; a placeholder is all a missing one has (its preview). + let linked = self.files.linked(i.kind()); + let missing = linked.is_some_and(|l| l.missing); + let link = linked.map(|l| l.link.clone()); + self.doc.images.entry(key.clone()).or_insert_with(|| { + let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) }; + if link.is_some() && !missing { blob.with_proxy() } else { blob } + }); let xf = acc * Affine::scale_non_uniform(size.width() as f64 / pw as f64, size.height() as f64 / ph as f64); - Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link: None }))) + Some(self.named(i.id(), NodeKind::Image(ImageObject { key, width: pw, height: ph, xf, link }))) + } + + /// An SVG shown as an image (a data URL or a linked file) → its art, `acc` mapping its size. + /// Its ids are its own: they mean nothing in the SVG around it. + fn svg_image(&mut self, tree: &usvg::Tree, acc: Affine) -> Option { + let outer = + (std::mem::take(&mut self.links), std::mem::take(&mut self.hidden), std::mem::take(&mut self.uses), std::mem::take(&mut self.labels)); + let art = self.group_node(tree.root(), acc); + (self.links, self.hidden, self.uses, self.labels) = outer; + art } } diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs new file mode 100644 index 00000000..bbe33d84 --- /dev/null +++ b/crates/svg/src/import/files.rs @@ -0,0 +1,247 @@ +//! Files `` elements link to (`href="photo.png"`, `file:///…`): read before usvg runs, +//! through [`ImportOptions::read`], relative paths from [`ImportOptions::folder`]. A raster file +//! stays a linked image ([`LinkInfo`]); an SVG file becomes vector art. One that can't be read +//! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it +//! can be relinked, and a warning. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use usvg::{ImageHrefResolver, ImageKind, roxmltree}; +use vectorcraft_doc::LinkInfo; + +use super::{Edits, href}; +use crate::ImportOptions; + +/// The files an SVG's images link to. +#[derive(Default)] +pub(in crate::import) struct Files { + /// What usvg gets for each linked href. + sources: HashMap, + /// The link of each linked raster or placeholder, by the address of the bytes usvg got. + links: HashMap, + /// An SVG image had text (which isn't imported). + text: AtomicBool, +} + +enum Source { + /// A raster file of the kind usvg reads it as. + Raster(fn(Arc>) -> ImageKind, Arc>), + /// An SVG file, parsed by usvg as an image. + Svg(Arc>), +} + +/// A linked raster image (or the placeholder of a missing one). +pub(in crate::import) struct Linked { + pub link: LinkInfo, + /// The file wasn't read: the bytes are a placeholder. + pub missing: bool, +} + +/// The side of the placeholder image, in pixels. +const MARKER: u32 = 64; + +/// The placeholder of a missing linked image: a light tile, crossed out (drawn here). +fn marker() -> Option> { + let last = MARKER - 1; + let img = image::RgbaImage::from_fn(MARKER, MARKER, |x, y| { + let edge = x == 0 || y == 0 || x == last || y == last; + let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1; + image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] }) + }); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; + Some(png) +} + +/// `s` with `%XX` escapes decoded (an href is a URL). +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail.get(..2).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Is `p` an absolute path here or on another system (`/…`, `C:/…`, `C:\…`)? +fn is_absolute(p: &str) -> bool { + Path::new(p).is_absolute() || p.starts_with('/') || matches!(p.as_bytes(), [d, b':', b'/' | b'\\', ..] if d.is_ascii_alphabetic()) +} + +/// Where href `h` points (a path, made absolute from `folder`, or a URL) and whether it is a +/// file that can be read. +fn target(h: &str, folder: Option<&str>) -> (String, bool) { + let h = percent_decoded(h.trim()); + let path = match h.strip_prefix("file://") { + Some(rest) => { + let rest = rest.strip_prefix("localhost").unwrap_or(rest); + // `file:///C:/art.png` → `C:/art.png`. + match rest.as_bytes() { + [b'/', d, b':', ..] if d.is_ascii_alphabetic() => rest.get(1..).unwrap_or(rest).to_string(), + _ => rest.to_string(), + } + } + None if h.contains("://") => return (h, false), + None => h, + }; + if is_absolute(&path) { + return (path, true); + } + match folder { + Some(f) => (Path::new(f).join(&path).to_string_lossy().into_owned(), true), + None => (path, false), + } +} + +/// The address of image bytes, to find their link again. +fn key(bytes: &Arc>) -> usize { + Arc::as_ptr(bytes) as usize +} + +impl Files { + /// Read the files the `` elements of `xml` (the text `svg`) link to. Placeholders + /// stretch to the image's box (`preserveAspectRatio="none"`), which the file fills once + /// relinked. + pub(in crate::import) fn read(svg: &str, xml: &roxmltree::Document, opts: &ImportOptions, edits: &mut Edits, warnings: &mut Vec) -> Self { + let mut files = Files::default(); + let mut placeholder = None; + for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") { + let Some(h) = href(n).filter(|h| !h.trim_start().starts_with("data:")) else { continue }; + if !files.sources.contains_key(h) { + let (path, local) = target(h, opts.folder); + let read = if local { opts.read.and_then(|read| read(&path)) } else { None }; + let source = match read { + Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"), + None => Err("not found"), + }; + let source = source.or_else(|why| { + warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); + let bytes = Arc::new(placeholder.get_or_insert_with(marker).clone().ok_or(why)?); + files.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true }); + Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes)) + }); + let Ok(source) = source else { continue }; + files.sources.insert(h.to_string(), source); + } + if files.placeholder(h) { + edits.set(svg, n, "preserveAspectRatio", "none"); + } + } + files + } + + /// What usvg gets for a file read (`bytes`, linked by `link`): a raster usvg reads (others are + /// stored as PNG) or an SVG; `None` for anything else. + fn source(&mut self, bytes: Vec, link: LinkInfo) -> Option { + use image::ImageFormat as F; + let Ok(format) = image::guess_format(&bytes) else { + let svg = crate::text_of(&bytes).is_ok_and(|t| t.contains(" ImageKind::PNG, + F::Jpeg => ImageKind::JPEG, + F::Gif => ImageKind::GIF, + F::WebP => ImageKind::WEBP, + _ => { + let img = image::load_from_memory_with_format(&bytes, format).ok()?; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), F::Png).ok()?; + let png = Arc::new(png); + self.links.insert(key(&png), Linked { link, missing: false }); + return Some(Source::Raster(ImageKind::PNG, png)); + } + }; + let bytes = Arc::new(bytes); + self.links.insert(key(&bytes), Linked { link, missing: false }); + Some(Source::Raster(kind, bytes)) + } + + /// Does href `h` show a placeholder? + fn placeholder(&self, h: &str) -> bool { + matches!(self.sources.get(h), Some(Source::Raster(_, b)) if self.links.get(&key(b)).is_some_and(|l| l.missing)) + } + + /// The link of the image whose data usvg got as `kind`. + pub(in crate::import) fn linked(&self, kind: &ImageKind) -> Option<&Linked> { + match kind { + ImageKind::PNG(b) | ImageKind::JPEG(b) | ImageKind::GIF(b) | ImageKind::WEBP(b) => self.links.get(&key(b)), + ImageKind::SVG(_) => None, + } + } + + /// Did an SVG image have text (which isn't imported)? + pub(in crate::import) fn nested_text(&self) -> bool { + self.text.load(Ordering::Relaxed) + } + + fn note_text(&self, svg: &[u8]) { + if svg.windows(5).any(|w| w == b" ImageHrefResolver<'_> { + let data = ImageHrefResolver::default_data_resolver(); + ImageHrefResolver { + resolve_data: Box::new(move |mime, bytes, opts| { + if mime == "image/svg+xml" { + self.note_text(&bytes); + } + data(mime, bytes, opts) + }), + resolve_string: Box::new(move |h, opts| match self.sources.get(h).or_else(|| self.sources.get(h.trim()))? { + Source::Raster(kind, bytes) => Some(kind(bytes.clone())), + Source::Svg(bytes) => { + self.note_text(bytes); + usvg::Tree::from_data_nested(bytes, opts).ok().map(ImageKind::SVG) + } + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hrefs_resolve_against_the_folder() { + let sep = std::path::MAIN_SEPARATOR; + assert_eq!(target("photo%20one.png", Some("/art")), (format!("/art{sep}photo one.png"), true)); + assert_eq!(target("photo.png", None), ("photo.png".into(), false)); + assert_eq!(target("/abs/photo.png", Some("/art")), ("/abs/photo.png".into(), true)); + assert_eq!(target("file:///C:/art/a.png", None), ("C:/art/a.png".into(), true)); + assert_eq!(target("file:///home/a.png", None), ("/home/a.png".into(), true)); + assert_eq!(target("https://example.com/a.png", Some("/art")), ("https://example.com/a.png".into(), false)); + } + + #[test] + fn percent_escapes_decode() { + assert_eq!(percent_decoded("a%20b%2"), "a b%2"); + assert_eq!(percent_decoded("%E2%82%AC"), "€"); + } + + #[test] + fn the_placeholder_is_a_png() { + let png = marker().unwrap(); + assert_eq!(image::load_from_memory(&png).unwrap().width(), MARKER); + } +} diff --git a/crates/svg/src/import/fx.rs b/crates/svg/src/import/fx.rs new file mode 100644 index 00000000..9e2d55a2 --- /dev/null +++ b/crates/svg/src/import/fx.rs @@ -0,0 +1,247 @@ +//! SVG filters → live effects. A filter is read by evaluating its primitives on symbols (the +//! source, its silhouette blurred, moved or inverted, a colour flood, a coloured silhouette…), +//! and the result recognised as one of the effects the canvas draws: Gaussian Blur, Drop Shadow, +//! Outer Glow, Inner Glow or Feather. That covers `feGaussianBlur`, `feDropShadow`, the usual +//! shadow chains (offset, blur, flood or colour matrix, composite, merge) and what our export +//! writes, so export → import keeps the effects. + +use std::collections::HashMap; +use std::sync::Arc; + +use serde_json::json; +use usvg::filter::{ColorMatrixKind, CompositeOperator, Filter, Input, Kind, TransferFunction}; +use usvg::roxmltree; +use vectorcraft_color::Color; +use vectorcraft_doc::Effect; +use vectorcraft_geom::{Affine, Vec2}; + +/// Attribute our export writes on the filter of a shadow or glow whose blend mode isn't normal +/// (filters composite normally; the mode comes back on import). +pub(crate) const BLEND: &str = "data-vc-blend"; + +/// The blend modes our export recorded ([`BLEND`]), by filter id. +pub(super) fn blends(xml: &roxmltree::Document) -> HashMap { + if !xml.input_text().contains(BLEND) { + return HashMap::new(); + } + xml.descendants() + .filter(|n| n.tag_name().name() == "filter") + .filter_map(|n| Some((n.attribute("id")?.to_string(), n.attribute(BLEND)?.to_string()))) + .collect() +} + +/// The object's alpha, blurred (σ), moved and maybe inverted. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Shape { + sd: f64, + dx: f64, + dy: f64, + moved: bool, + inverted: bool, +} + +impl Shape { + /// The silhouette as it is. + fn plain(self) -> bool { + self == Shape::default() + } +} + +/// What a filter primitive's result is, as far as the effects go. +#[derive(Clone, Debug)] +enum V { + /// `SourceGraphic`. + Graphic, + /// The source blurred (σ). + Blurred(f64), + /// A black silhouette. + Alpha(Shape), + Flood(Color, f32), + /// A silhouette painted a colour and opacity; `clipped` to the object's own shape. + Paint { + shape: Shape, + color: Color, + opacity: f32, + clipped: bool, + }, + /// The source inside its own blurred silhouette (σ). + Feathered(f64), + /// Layers, bottom first. + Stack(Vec), +} + +impl V { + fn layers(self) -> Vec { + match self { + V::Stack(l) => l, + v => vec![v], + } + } + + /// Blurred by σ `sd` (blurs add up as variances). + fn blur(self, sd: f64) -> Option { + let add = |a: f64| (a * a + sd * sd).sqrt(); + Some(match self { + v if sd <= 0.0 => v, + V::Graphic => V::Blurred(sd), + V::Blurred(a) => V::Blurred(add(a)), + V::Alpha(s) => V::Alpha(Shape { sd: add(s.sd), ..s }), + V::Paint { shape, color, opacity, clipped: false } => { + V::Paint { shape: Shape { sd: add(shape.sd), ..shape }, color, opacity, clipped: false } + } + _ => return None, + }) + } + + fn offset(self, dx: f64, dy: f64) -> Option { + let moved = |s: Shape| Shape { dx: s.dx + dx, dy: s.dy + dy, moved: true, ..s }; + Some(match self { + V::Alpha(s) => V::Alpha(moved(s)), + V::Paint { shape, color, opacity, clipped: false } => V::Paint { shape: moved(shape), color, opacity, clipped: false }, + v @ (V::Graphic | V::Blurred(_)) if dx == 0.0 && dy == 0.0 => v, + _ => return None, + }) + } + + /// `self` in the alpha of `mask` (`feComposite operator="in"`). + fn inside(self, mask: V) -> Option { + // The source's alpha is its silhouette. + let mask = match mask { + V::Graphic => V::Alpha(Shape::default()), + m => m, + }; + Some(match (self, mask) { + (V::Flood(color, opacity), V::Alpha(shape)) => V::Paint { shape, color, opacity, clipped: false }, + (V::Paint { shape, color, opacity, .. }, V::Alpha(s)) if s.plain() => V::Paint { shape, color, opacity, clipped: true }, + (V::Graphic, V::Alpha(s)) if !s.moved && !s.inverted => V::Feathered(s.sd), + (f @ V::Feathered(_), V::Alpha(s)) if s.plain() => f, + _ => return None, + }) + } + + /// A silhouette (or the blurred source, by its alpha) painted by a colour matrix that ignores + /// the colour channels: the matrix's constant colour, at its alpha scale. + fn color_matrix(self, m: &[f32]) -> Option { + let shape = match self { + V::Alpha(s) => s, + V::Blurred(sd) => Shape { sd, ..Shape::default() }, + _ => return None, + }; + let row = |r: usize| m.get(r * 5..r * 5 + 5); + let (Some(r), Some(g), Some(b), Some(a)) = (row(0), row(1), row(2), row(3)) else { return None }; + let colour_only = [r, g, b].iter().all(|row| row.iter().take(4).all(|v| *v == 0.0)); + let alpha_only = a.iter().take(3).all(|v| *v == 0.0) && a.get(4) == Some(&0.0); + let (Some(cr), Some(cg), Some(cb), Some(k)) = (r.get(4), g.get(4), b.get(4), a.get(3)) else { return None }; + (colour_only && alpha_only).then(|| V::Paint { + shape, + color: Color::rgb(cr.clamp(0.0, 1.0), cg.clamp(0.0, 1.0), cb.clamp(0.0, 1.0)), + opacity: k.clamp(0.0, 1.0), + clipped: false, + }) + } +} + +/// σ of a blur with standard deviations `x`, `y` (their mean: the effects blur evenly). +fn sd(x: f32, y: f32) -> f64 { + (x as f64 + y as f64) / 2.0 +} + +fn color(c: usvg::Color) -> Color { + Color::rgb8(c.red, c.green, c.blue) +} + +/// Evaluate one filter's primitives; `None` when one does something the effects can't. +fn eval(f: &Filter) -> Option { + let mut results: HashMap<&str, V> = HashMap::new(); + let mut last = V::Graphic; + for p in f.primitives() { + let input = |i: &Input| -> Option { + match i { + Input::SourceGraphic => Some(V::Graphic), + Input::SourceAlpha => Some(V::Alpha(Shape::default())), + Input::Reference(r) => results.get(r.as_str()).cloned(), + } + }; + let v = match p.kind() { + Kind::GaussianBlur(b) => input(b.input())?.blur(sd(b.std_dev_x().get(), b.std_dev_y().get()))?, + Kind::Offset(o) => input(o.input())?.offset(o.dx() as f64, o.dy() as f64)?, + Kind::Flood(fl) => V::Flood(color(fl.color()), fl.opacity().get()), + Kind::Composite(c) => { + let (a, b) = (input(c.input1())?, input(c.input2())?); + match c.operator() { + CompositeOperator::In => a.inside(b)?, + CompositeOperator::Over => V::Stack(b.layers().into_iter().chain(a.layers()).collect()), + _ => return None, + } + } + Kind::Merge(m) => V::Stack(m.inputs().iter().map(input).collect::>>()?.into_iter().flat_map(V::layers).collect()), + Kind::ColorMatrix(c) => match c.kind() { + ColorMatrixKind::Matrix(m) => input(c.input())?.color_matrix(m)?, + _ => return None, + }, + Kind::ComponentTransfer(c) => { + let identity = [c.func_r(), c.func_g(), c.func_b()].iter().all(|f| matches!(f, TransferFunction::Identity)); + let invert = match c.func_a() { + TransferFunction::Table(t) => t.as_slice() == [1.0, 0.0], + TransferFunction::Linear { slope, intercept } => *slope == -1.0 && *intercept == 1.0, + _ => false, + }; + match input(c.input())? { + V::Alpha(s) if identity && invert && s.sd == 0.0 => V::Alpha(Shape { inverted: !s.inverted, ..s }), + _ => return None, + } + } + Kind::DropShadow(d) if matches!(input(d.input())?, V::Graphic) => { + let shape = + Shape { sd: sd(d.std_dev_x().get(), d.std_dev_y().get()), dx: d.dx() as f64, dy: d.dy() as f64, moved: true, inverted: false }; + V::Stack(vec![V::Paint { shape, color: color(d.color()), opacity: d.opacity().get(), clipped: false }, V::Graphic]) + } + _ => return None, + }; + results.insert(p.result(), v.clone()); + last = v; + } + Some(last) +} + +/// The live effects `filters` (in the order they apply) stand for, in user space mapped to the +/// document by `ts`; `blends`: the modes our export recorded ([`blends`]). `None` when one of +/// them isn't an effect the canvas draws. +pub(super) fn effects(filters: &[Arc], ts: Affine, blends: &HashMap) -> Option> { + let k = ts.determinant().abs().sqrt(); + let [a, b, c, d, _, _] = ts.as_coeffs(); + let along = |dx: f64, dy: f64| Vec2::new(a * dx + c * dy, b * dx + d * dy); + let num = |v: f64| (v * 1000.0).round() / 1000.0; + let mut out = vec![]; + for f in filters { + let blur = |sd: f64| num(2.0 * sd * k); + let mode = blends.get(f.id()).map_or("normal", String::as_str); + let paint = |c: Color, o: f32| (c.to_hex(), num(o as f64 * 100.0)); + let (id, params) = match eval(f)? { + V::Graphic => continue, + V::Blurred(sd) => ("blur.gaussian", json!({ "radius": blur(sd) })), + V::Feathered(sd) => ("stylize.feather", json!({ "radius": blur(sd) })), + V::Stack(layers) => match layers.as_slice() { + [V::Paint { shape, color, opacity, clipped: false }, V::Graphic] if !shape.inverted => { + let (color, opacity) = paint(*color, *opacity); + if shape.moved { + let v = along(shape.dx, shape.dy); + let p = json!({ "x": num(v.x), "y": num(v.y), "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode }); + ("stylize.dropShadow", p) + } else { + ("stylize.outerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode })) + } + } + [V::Graphic, V::Paint { shape, color, opacity, clipped: true }] if !shape.moved => { + let (color, opacity) = paint(*color, *opacity); + let source = if shape.inverted { "edge" } else { "center" }; + ("stylize.innerGlow", json!({ "blur": blur(shape.sd), "color": color, "opacity": opacity, "mode": mode, "source": source })) + } + _ => return None, + }, + _ => return None, + }; + out.push(Effect { id: id.into(), params, visible: true }); + } + Some(out) +} diff --git a/crates/svg/src/import/spread.rs b/crates/svg/src/import/spread.rs new file mode 100644 index 00000000..b88ecf3e --- /dev/null +++ b/crates/svg/src/import/spread.rs @@ -0,0 +1,162 @@ +//! `spreadMethod="reflect"` and `"repeat"`: gradients only pad past their ends, so the stops are +//! repeated (every other period mirrored, for reflect) as many times as the painted area needs, +//! and the gradient stretched over those periods. + +use vectorcraft_color::{GradientGeom, GradientKind, GradientStop}; +use vectorcraft_geom::{Point, Rect}; + +/// Most periods a gradient is expanded to. +pub(super) const MAX_PERIODS: usize = 64; + +/// What [`expand`] did. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum Expanded { + /// Every period the area shows. + Whole, + /// [`MAX_PERIODS`] of them: the gradient pads past those. + Capped, +} + +/// Expand `stops` and `geom` (document space) so the gradient paints `area` as the spread method +/// does: `reflect` mirrors every other period, else they repeat. +pub(super) fn expand(kind: GradientKind, geom: &mut GradientGeom, stops: &mut Vec, reflect: bool, area: Rect) -> Expanded { + let corners = [Point::new(area.x0, area.y0), Point::new(area.x1, area.y0), Point::new(area.x1, area.y1), Point::new(area.x0, area.y1)]; + let ts = corners.map(|p| geom.param_at(kind, p)); + if ts.iter().any(|t| !t.is_finite()) || stops.is_empty() { + return Expanded::Whole; + } + let radial = kind == GradientKind::Radial; + // The parameter's range over the area: a corner holds its extremes (radial levels are nested + // convex rings from the focal point, where the parameter is 0). + let lo = if radial { 0.0 } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; + let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); + if lo >= 0.0 && hi <= 1.0 { + return Expanded::Whole; + } + let capped = hi - lo > MAX_PERIODS as f64; + // Capped, the periods kept start no further back than half of them. + let lo = if capped { lo.max(-(MAX_PERIODS as f64) / 2.0) } else { lo }; + let n = (hi - lo).min(MAX_PERIODS as f64); + let period = whole_period(stops); + let mut out = Vec::with_capacity(period.len() * n as usize); + for j in 0..n as usize { + let mirrored = reflect && (lo as i64 + j as i64).rem_euclid(2) == 1; + let one = if mirrored { mirror(&period) } else { period.clone() }; + out.extend(one.into_iter().map(|s| GradientStop { offset: ((j as f64 + s.offset as f64) / n) as f32, ..s })); + } + *stops = out; + let u = geom.end - geom.start; + if radial { + // Level t of an off-centre radial is the extent ellipse scaled by t about the line from the + // focal point to the centre; level n·t' of the new one: the centre moves away from the + // focal point n times as far, and the radius grows n times. + let Some(frame) = geom.unit_frame() else { return Expanded::Whole }; + let f = geom.focal.map_or(Point::ORIGIN, |f| frame.inverse() * f); + geom.start = frame * (f.to_vec2() * (1.0 - n)).to_point(); + geom.end = geom.start + u * n; + } else { + let start = geom.start; + geom.start = start + u * lo; + geom.end = start + u * (lo + n); + } + if capped { Expanded::Capped } else { Expanded::Whole } +} + +/// The stops of one period, from offset 0 to 1: the end colours held out to the ends. +fn whole_period(stops: &[GradientStop]) -> Vec { + let mut out = stops.to_vec(); + if let Some(first) = out.first().filter(|s| s.offset > 0.0).cloned() { + out.insert(0, GradientStop { offset: 0.0, ..first }); + } + if let Some(last) = out.last_mut() { + // The last stop's midpoint leads nowhere; the copy at 1 takes over. + if last.offset < 1.0 { + let end = GradientStop { offset: 1.0, ..last.clone() }; + out.push(end); + } + } + out +} + +/// A period mirrored: the stops in reverse, each midpoint measured from the other side. +fn mirror(period: &[GradientStop]) -> Vec { + let rev: Vec<&GradientStop> = period.iter().rev().collect(); + rev.iter() + .enumerate() + .map(|(i, s)| GradientStop { + offset: 1.0 - s.offset, + // The midpoint from this stop to the next one is the mirror of the one the next stop + // had towards this one. + midpoint: rev.get(i + 1).map_or(0.5, |next| 1.0 - next.midpoint), + ..(*s).clone() + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_color::Color; + + fn stops() -> Vec { + let mut a = GradientStop::new(0.0, Color::BLACK); + a.midpoint = 0.3; + vec![a, GradientStop::new(1.0, Color::WHITE)] + } + + #[test] + fn a_linear_gradient_repeats_over_the_area() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let mut s = stops(); + let r = expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(-5.0, 0.0, 25.0, 10.0)); + assert_eq!(r, Expanded::Whole); + // From -10 to 30: four periods. + assert_eq!((geom.start, geom.end), (Point::new(-10.0, 0.0), Point::new(30.0, 0.0))); + let offsets: Vec = s.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 1.0]); + assert!(s.iter().step_by(2).all(|s| s.color == Color::BLACK && (s.midpoint - 0.3).abs() < 1e-6)); + } + + #[test] + fn reflect_mirrors_every_other_period_and_its_midpoints() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let mut s = stops(); + expand(GradientKind::Linear, &mut geom, &mut s, true, Rect::new(0.0, 0.0, 20.0, 10.0)); + let colours: Vec = s.iter().map(|s| s.color).collect(); + assert_eq!(colours, [Color::BLACK, Color::WHITE, Color::WHITE, Color::BLACK]); + // Black → white with the midpoint at 0.3 mirrors to white → black with it at 0.7. + assert!((s[2].midpoint - 0.7).abs() < 1e-6, "{s:?}"); + } + + #[test] + fn stops_inside_the_period_hold_their_colours_to_its_ends() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(10.0, 0.0), aspect: 1.0, focal: None }; + let mut s = vec![GradientStop::new(0.25, Color::BLACK), GradientStop::new(0.75, Color::WHITE)]; + expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 20.0, 10.0)); + let offsets: Vec = s.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.125, 0.375, 0.5, 0.5, 0.625, 0.875, 1.0]); + } + + #[test] + fn a_radial_gradient_grows_about_its_focal_point() { + let c = Point::new(50.0, 50.0); + let mut geom = GradientGeom { start: c, end: Point::new(60.0, 50.0), aspect: 1.0, focal: Some(Point::new(55.0, 50.0)) }; + let before = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); + let mut s = stops(); + expand(GradientKind::Radial, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 100.0)); + let n = s.len() / 2; + assert!(n >= 5, "{n}"); + assert_eq!(geom.focal, Some(Point::new(55.0, 50.0))); + // Every point keeps its level: the new parameter is the old one over n. + let after = geom.param_at(GradientKind::Radial, Point::new(72.0, 61.0)); + assert!((after * n as f64 - before).abs() < 1e-9, "{before} {after}"); + } + + #[test] + fn far_repeats_are_capped() { + let mut geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; + let mut s = stops(); + assert_eq!(expand(GradientKind::Linear, &mut geom, &mut s, false, Rect::new(0.0, 0.0, 100.0, 1.0)), Expanded::Capped); + assert_eq!(s.len(), 2 * MAX_PERIODS); + } +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index e8b28ee6..f367555e 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -36,13 +36,23 @@ //! unit of the root `width` (pixels when it has none). //! * `` imports as a luminance opacity mask; a mask we exported keeps its options and art //! (its `data-vectorcraft-mask="noclip invert"` lists the options that differ from clipping and -//! not inverted). Filters are ignored (reported as warnings); nested clip paths use the outer -//! clip only. +//! not inverted). Nested clip paths (``) are clip groups in clip groups. +//! * Filters that are a Gaussian blur, a drop shadow (`feDropShadow` or the usual chains of offset, +//! blur, flood or colour matrix, composite and merge), a glow or a feather (as we export them) +//! become those live effects; other filters are ignored (reported as warnings). //! * `` becomes a pattern swatch (its content clipped to the tile) painted with the //! pattern's placement. +//! * `` with `` becomes a symbol and its instances. A `` that shows the symbol +//! differently (inherited paint, a viewport that cuts it) or that the canvas would paint +//! differently (see the export's symbol rules) becomes plain art, as do ``s of other +//! elements. //! * A `` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back -//! as a hidden layer or group, unless a `` refers to it. Other undisplayed elements are left -//! out. `` instances (and ``s) become plain art. +//! as a hidden layer; other undisplayed objects as hidden objects, unless a `` refers to +//! them. +//! * `spreadMethod` reflect and repeat are expanded into stops over the painted area (at most +//! 64 periods). +//! * `` files are read through [`ImportOptions`]: rasters stay linked, SVG files (and SVG +//! `data:` URLs) become art (without their text), missing ones a placeholder keeping the link. //! * Text lines after the first start at the first line's x; text in a clip path is ignored; //! absolute positions inside type on a path are ignored; vertical text sets every glyph sideways. //! @@ -330,8 +340,31 @@ pub fn import(svg: &str) -> Result { } /// Import an SVG document, also returning warnings about unsupported or approximated features. +/// Linked files (``) aren't read: see [`import_with`]. pub fn import_with_report(svg: &str) -> Result<(Document, Vec), SvgError> { - import::import(svg) + import_with(svg, &ImportOptions::default()) +} + +/// Import an SVG document reading the files it links to as `opts` says, also returning warnings. +pub fn import_with(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec), SvgError> { + import::import(svg, opts) +} + +/// Reads a linked file: its bytes and the link to it (absolute path, size, time and hash), or +/// `None` when there is no readable file at the (absolute) path. +pub type ReadFile<'a> = &'a dyn Fn(&str) -> Option<(Vec, vectorcraft_doc::LinkInfo)>; + +/// How [`import_with`] reads the files an SVG's `` elements link to. +/// +/// A raster file stays linked ([`vectorcraft_doc::ImageObject::link`]); an SVG file imports as +/// vector art. A file that can't be read imports as a placeholder that keeps the link (and a +/// warning), so it can be relinked. +#[derive(Clone, Copy, Default)] +pub struct ImportOptions<'a> { + /// The folder relative links are found in: the SVG file's own. Without it they can't be read. + pub folder: Option<&'a str>, + /// Reads a linked file. Without it no file is read. + pub read: Option>, } /// FNV-1a: a stable content hash (image keys, unique id prefixes). diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs new file mode 100644 index 00000000..13031e05 --- /dev/null +++ b/crates/svg/tests/import_details.rs @@ -0,0 +1,183 @@ +//! SVG import details: linked images read from the SVG's folder and kept linked (a placeholder +//! and a warning when missing), SVG images as vector art, nested clips, undisplayed objects as +//! hidden objects, and reflected or repeated gradients. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::path::{Path, PathBuf}; + +use vectorcraft_doc::{Document, ImageObject, LinkInfo, Node, NodeKind}; +use vectorcraft_geom::Rect; +use vectorcraft_svg::{ImportOptions, import_with, import_with_report}; +use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard}; + +fn svg(body: &str) -> String { + format!(r#"{body}"#) +} + +/// All non-layer nodes in paint order. +fn art(d: &Document) -> Vec<&Node> { + let mut v = Vec::new(); + d.walk(|n| { + if !n.is_layer() { + v.push(n) + } + }); + v +} + +fn images(d: &Document) -> Vec<(&Node, &ImageObject)> { + art(d) + .into_iter() + .filter_map(|n| match &n.kind { + NodeKind::Image(im) => Some((n, im)), + _ => None, + }) + .collect() +} + +/// The SVG rendered by resvg on white at 1 px/pt. +fn resvg_render(svg: &str, w: u32, h: u32) -> Image { + let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).expect("parse"); + let mut pm = resvg::tiny_skia::Pixmap::new(w, h).unwrap(); + pm.fill(resvg::tiny_skia::Color::WHITE); + resvg::render(&tree, resvg::tiny_skia::Transform::identity(), &mut pm.as_mut()); + Image { width: w, height: h, rgba: pm.data().to_vec() } +} + +fn png(w: u32, h: u32) -> Vec { + let mut out = vec![]; + image::RgbaImage::from_pixel(w, h, image::Rgba([10, 200, 30, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out +} + +/// A fresh folder for one test's files. +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("vectorcraft-svg-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("img")).unwrap(); + dir +} + +/// Reads files as the app does (the link knows the size and hash). +fn read(path: &str) -> Option<(Vec, LinkInfo)> { + let bytes = std::fs::read(path).ok()?; + let link = LinkInfo { size: Some(bytes.len() as u64), hash: Some(vectorcraft_doc::links::hash_bytes(&bytes)), ..LinkInfo::new(path) }; + Some((bytes, link)) +} + +fn open_in(dir: &Path, body: &str) -> (Document, Vec) { + let folder = dir.to_string_lossy(); + import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&read) }).unwrap() +} + +#[test] +fn a_linked_png_resolves_against_the_svg_folder_and_stays_linked() { + let dir = folder("linked"); + let bytes = png(40, 20); + std::fs::write(dir.join("img").join("photo one.png"), &bytes).unwrap(); + let (d, w) = open_in(&dir, r#""#); + assert!(w.is_empty(), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + let link = im.link.as_ref().unwrap(); + assert_eq!(Path::new(&link.path), dir.join("img").join("photo one.png")); + assert_eq!(link.hash, Some(vectorcraft_doc::links::hash_bytes(&bytes))); + assert_eq!((im.width, im.height), (40, 20)); + assert_eq!(*d.images[&im.key].bytes, bytes, "the file's pixels"); + let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 40.0, 20.0)); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 20.0).abs() < 1e-6 && (b.width() - 80.0).abs() < 1e-6, "{b:?}"); +} + +#[test] +fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { + let dir = folder("missing"); + let (d, w) = open_in(&dir, r#""#); + assert!(w.iter().any(|w| w.contains("gone.png") && w.contains("not found")), "{w:?}"); + let [(_, im)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("gone.png")); + assert!(d.images[&im.key].is_proxy(), "a placeholder, not the file"); + // Stretched to the image's box, whatever its aspect. + let b = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)); + assert!((b.x0 - 10.0).abs() < 1e-6 && (b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}"); + // Without a reader nothing is read: the same placeholder. + let (d, w) = import_with_report(&svg(r#""#)).unwrap(); + assert_eq!(images(&d)[0].1.link.as_ref().unwrap().path, "/abs/photo.png"); + assert!(!w.is_empty()); +} + +#[test] +fn svg_images_import_as_vector_art() { + let dir = folder("svgimage"); + let inner = + r##""##; + std::fs::write(dir.join("img").join("logo.svg"), inner).unwrap(); + let data = format!("data:image/svg+xml;base64,{}", vectorcraft_svg::base64_encode(inner.as_bytes())); + let body = + format!(r#""#); + let (d, w) = open_in(&dir, &body); + assert!(w.is_empty(), "{w:?}"); + assert!(images(&d).is_empty()); + let mut boxes: Vec = art(&d).iter().filter(|n| n.path_data().is_some()).filter_map(|n| n.geometric_bounds()).collect(); + boxes.sort_by(|a, b| a.x0.total_cmp(&b.x0)); + assert_eq!(boxes.len(), 2, "{:?}", art(&d)); + assert!((boxes[0].x0 - 10.0).abs() < 1e-6 && (boxes[0].width() - 20.0).abs() < 1e-6, "{boxes:?}"); + assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}"); +} + +#[test] +fn nested_clips_intersect() { + let body = r##" + + "##; + let (d, w) = import_with_report(&svg(body)).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let a = art(&d); + assert!(matches!(&a[0].kind, NodeKind::Group { clip: true, children } if matches!(children[1].kind, NodeKind::Group { clip: true, .. }))); + let mine = render_artboard(&d); + assert_similar(&mine, &resvg_render(&svg(body), 200, 120), 24.0, 0.003); + // The left half of the circle is cut. + assert_eq!(mine.over_white(45, 60), [255, 255, 255]); + assert_ne!(mine.over_white(90, 60), [255, 255, 255]); +} + +#[test] +fn undisplayed_objects_import_hidden() { + let body = r##" +