Merge pull request #94 from storytold/pr/m4-svg-import-live

M4: SVG import keeps symbols, patterns and filters live; linked images, nested clips, reflected gradients (M4.41, M4.42)
This commit is contained in:
BFlat 2026-10-05 12:25:30 +07:00 committed by GitHub
commit 7aee3bd487
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1765 additions and 176 deletions

View File

@ -188,7 +188,7 @@ fn import_svg(s: &mut Session, p: &Value) -> Result<Value> {
}
(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"));

View File

@ -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<Loaded> {
load_with(name, bytes, &LoadOptions::default())
}
@ -129,7 +130,9 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
"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<Loaded>
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<String>)> {
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

View File

@ -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<CommandSpec> {
"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 <image> 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;

View File

@ -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("<![CDATA[", "<![CDATA[!!", 1);
let r = open(&mut s, "damaged.svg", &damaged);

View File

@ -0,0 +1,104 @@
//! `document.open` of an SVG whose images link to files: relative links are found in the SVG's
//! folder and stay linked (Links reports them), missing ones warn and are listed in
//! `missingLinks` with a placeholder; File → Place reads them the same way.
use std::path::PathBuf;
use serde_json::json;
use vectorcraft_doc::{ImageObject, NodeKind};
use super::*;
/// A fresh folder for one test (removed when dropped).
struct Folder(PathBuf);
impl Folder {
fn new(name: &str) -> 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<u8> {
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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="400" height="300">
<image xlink:href="img/photo.png" x="10" y="10" width="150" height="100"/>
<image href="img/gone.png" x="200" y="10" width="100" height="100"/></svg>"#;
let path = dir.file("art.svg");
std::fs::write(&path, svg).unwrap();
path
}
fn images(s: &Session) -> Vec<ImageObject> {
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");
}

View File

@ -125,6 +125,24 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
"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<Value = String> {
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<String> {
"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}</{t}>"));

View File

@ -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 }

View File

@ -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!("<symbol id=\"{id}\" overflow=\"visible\">"));
// 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!("<symbol id=\"{id}\"{name} overflow=\"visible\">"));
self.defs.push_str(&art);
self.def(1, "</symbol>");
if let Some(def) = self.symbols.get_mut(&sym.name) {
@ -1326,7 +1328,16 @@ impl Writer<'_> {
),
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
};
self.def(1, &format!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>"));
// 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!("<filter id=\"{fid}\"{region_attr}{mode} color-interpolation-filters=\"sRGB\">{body}</filter>"));
self.line(&format!("<g filter=\"url(#{fid})\">"));
self.depth += 1;
}

View File

@ -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<String>), SvgError> {
let (linked, links) = link_ids(svg);
let (svg, hidden) = hidden_groups(&linked);
pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<String>), 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<String>), 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<String>), SvgError> {
// Top-level `<g id>` 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 (`<clipPath clip-path>`) 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<String>,
/// Opacity-mask options our export wrote, by mask id (see [`mask_flags`]).
mask_flags: HashMap<String, (bool, bool)>,
/// The URL of each `<a href>`'s group, by its id ([`link_ids`]).
/// The URL of each `<a href>`'s group, by its id ([`prepass`]).
links: HashMap<String, String>,
/// Texts read from the XML, by placeholder ([`text`]).
slots: TextSlots,
@ -158,8 +181,48 @@ struct Importer {
midpoints: HashMap<String, Vec<Option<f32>>>,
/// Object names by element id (see [`labels`]).
labels: HashMap<String, String>,
/// Ids of the hidden groups shown for usvg ([`hidden_groups`]).
/// Ids of the undisplayed objects shown for usvg ([`prepass`]).
hidden: HashSet<String>,
/// The `<symbol>` id each `<use>` of a symbol shows, by the `<use>`'s id ([`prepass`]).
uses: HashMap<String, String>,
/// The symbols made so far, by `<symbol>` id.
symbols: HashMap<String, SymbolMade>,
/// Blend modes of the shadows and glows our export wrote, by filter id ([`fx::blends`]).
blends: HashMap<String, String>,
/// The files `<image>` elements link to.
files: files::Files,
}
/// A `<symbol>` made a symbol: its name, its art with no object ids (what each `<use>` 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<String, (bool, bool)> {
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 `<a>` 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 `<a>` 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 `<a href>` 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<String, String>) {
let mut links = HashMap::new();
if !svg.contains("<a") {
return (svg.into(), links);
/// What usvg would lose, found in the XML before it runs ([`prepass`]).
#[derive(Default)]
struct Found {
/// The URL of each `<a href>`'s group, by its id.
links: HashMap<String, String>,
/// Ids of the undisplayed objects shown for usvg: they come back hidden.
hidden: HashSet<String>,
/// The `<symbol>` id each `<use>` of a symbol shows, by the `<use>`'s id.
uses: HashMap<String, String>,
/// The files `<image>` elements link to.
files: files::Files,
warnings: Vec<String>,
}
/// Changes to the SVG text: `(range, replacement)`, an empty range inserting.
#[derive(Default)]
pub(super) struct Edits {
list: Vec<(std::ops::Range<usize>, String)>,
/// The ids made up so far, by element.
ids: HashMap<roxmltree::NodeId, String>,
}
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::<usize>());
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 `<a href>` and `<use>` 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 `<a href>` and every `<use>` of a `<symbol>` 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 `<use>` 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 `<svg:image>`.)
if !["<a", "display", "<use", ":use", "<image", ":image"].iter().any(|t| svg.contains(t)) {
return (svg.into(), found);
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
return (svg.into(), links);
return (svg.into(), found);
};
// Where to insert each new id: right after the tag name.
let mut inserts = vec![];
for a in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "a") {
let Some(url) = href(a) else { continue };
let id = match a.attribute("id") {
Some(id) => 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 `<defs>` (and the like) for usvg, recording their ids.
fn hidden_objects(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet<String>) {
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<XNode> = 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::<usize>());
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 `<g id>`, 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 `<use>`
/// template) stays as it is.
fn hidden_groups(svg: &str) -> (Cow<'_, str>, HashSet<String>) {
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<XNode> = 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<Node> {
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<Node> {
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 `<use>` of a `<symbol>` → an instance of the symbol made from it (on its
/// first `<use>`). `None` when the group has to be plain art: it shows the symbol differently
/// (properties inherited from the `<use>`), 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<Node> {
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 `<use>`'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 `<use>` 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<Paint> = (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 (`<clipPath clip-path>`): 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<Node> {
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<Rect>) -> 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,
}))
}
/// `<pattern>` → 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<Rect>) -> (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<Node> {
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
}
}

View File

@ -0,0 +1,247 @@
//! Files `<image>` 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<String, Source>,
/// The link of each linked raster or placeholder, by the address of the bytes usvg got.
links: HashMap<usize, Linked>,
/// 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<Vec<u8>>) -> ImageKind, Arc<Vec<u8>>),
/// An SVG file, parsed by usvg as an image.
Svg(Arc<Vec<u8>>),
}
/// 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<Vec<u8>> {
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<Vec<u8>>) -> usize {
Arc::as_ptr(bytes) as usize
}
impl Files {
/// Read the files the `<image>` 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<String>) -> 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<u8>, link: LinkInfo) -> Option<Source> {
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("<svg"));
return svg.then(|| Source::Svg(Arc::new(bytes)));
};
let kind = match format {
F::Png => 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"<text") {
self.text.store(true, Ordering::Relaxed);
}
}
/// usvg's image resolver: data URLs as usvg reads them, other hrefs only from the files read.
pub(in crate::import) fn resolver(&self) -> 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);
}
}

247
crates/svg/src/import/fx.rs Normal file
View File

@ -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<String, String> {
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<V>),
}
impl V {
fn layers(self) -> Vec<V> {
match self {
V::Stack(l) => l,
v => vec![v],
}
}
/// Blurred by σ `sd` (blurs add up as variances).
fn blur(self, sd: f64) -> Option<V> {
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<V> {
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<V> {
// 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<V> {
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<V> {
let mut results: HashMap<&str, V> = HashMap::new();
let mut last = V::Graphic;
for p in f.primitives() {
let input = |i: &Input| -> Option<V> {
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::<Option<Vec<_>>>()?.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<Filter>], ts: Affine, blends: &HashMap<String, String>) -> Option<Vec<Effect>> {
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)
}

View File

@ -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<GradientStop>, 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<GradientStop> {
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<GradientStop> {
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<GradientStop> {
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<f32> = 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<Color> = 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<f32> = 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);
}
}

View File

@ -36,13 +36,23 @@
//! unit of the root `width` (pixels when it has none).
//! * `<mask>` 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 (`<clipPath clip-path>`) 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).
//! * `<pattern>` becomes a pattern swatch (its content clipped to the tile) painted with the
//! pattern's placement.
//! * `<symbol>` with `<use>` becomes a symbol and its instances. A `<use>` 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 `<use>`s of other
//! elements.
//! * A `<g id>` layer (top-level, or inside one) that isn't displayed (`display: none`) comes back
//! as a hidden layer or group, unless a `<use>` refers to it. Other undisplayed elements are left
//! out. `<use>` instances (and `<symbol>`s) become plain art.
//! as a hidden layer; other undisplayed objects as hidden objects, unless a `<use>` refers to
//! them.
//! * `spreadMethod` reflect and repeat are expanded into stops over the painted area (at most
//! 64 periods).
//! * `<image>` 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<Document, SvgError> {
}
/// Import an SVG document, also returning warnings about unsupported or approximated features.
/// Linked files (`<image href="photo.png">`) aren't read: see [`import_with`].
pub fn import_with_report(svg: &str) -> Result<(Document, Vec<String>), 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<String>), 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<u8>, vectorcraft_doc::LinkInfo)>;
/// How [`import_with`] reads the files an SVG's `<image>` 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<ReadFile<'a>>,
}
/// FNV-1a: a stable content hash (image keys, unique id prefixes).

View File

@ -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#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120">{body}</svg>"#)
}
/// 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<u8> {
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<u8>, 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<String>) {
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#"<image href="img/photo%20one.png" x="10" y="20" width="80" height="40"/>"#);
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#"<image href="img/gone.png" x="10" y="20" width="100" height="50"/>"#);
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#"<image href="/abs/photo.png" width="10" height="10"/>"#)).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##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10" viewBox="0 0 20 10"><rect width="10" height="10" fill="#f00"/></svg>"##;
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#"<image href="img/logo.svg" x="10" y="10" width="40" height="20"/><image href="{data}" x="100" y="10" width="40" height="20"/>"#);
let (d, w) = open_in(&dir, &body);
assert!(w.is_empty(), "{w:?}");
assert!(images(&d).is_empty());
let mut boxes: Vec<Rect> = 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##"<defs><clipPath id="inner"><rect x="60" y="0" width="200" height="200"/></clipPath>
<clipPath id="outer" clip-path="url(#inner)"><circle cx="70" cy="60" r="40"/></clipPath></defs>
<rect width="200" height="120" fill="#06c" clip-path="url(#outer)"/>"##;
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##"<rect width="10" height="10" display="none"/><g style="display:none"><circle cx="50" cy="50" r="5"/></g>
<path id="p" d="M0 0H5V5Z" style="display: none"/><image width="4" height="4" display="none" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGD4DwABBAEAwS2OUAAAAABJRU5ErkJggg=="/>
<defs><rect id="t" width="3" height="3" display="none"/></defs><rect x="20" width="10" height="10"/>"##;
let (d, w) = import_with_report(&svg(body)).unwrap();
assert!(w.is_empty(), "{w:?}");
let top = d.layers[0].children().unwrap();
let hidden: Vec<bool> = top.iter().map(|n| !n.visible).collect();
assert_eq!(hidden, [true, true, true, true, false], "{top:?}");
// An id-less group of one object is that object.
assert!(top[1].path_data().is_some());
assert!(matches!(top[3].kind, NodeKind::Image(_)));
assert_eq!(top[2].name.as_deref(), Some("p"), "named after its id");
assert!(top[0].name.is_none() && top[1].name.is_none(), "made-up ids aren't names");
}
#[test]
fn reflect_and_repeat_match_resvg() {
for (spread, grad) in [
(
"reflect",
r##"<linearGradient id="g" x1="40" y1="0" x2="70" y2="0" gradientUnits="userSpaceOnUse" spreadMethod="reflect"><stop offset="0.2" stop-color="#f00"/><stop offset="1" stop-color="#00f"/></linearGradient>"##,
),
(
"repeat",
r##"<linearGradient id="g" x1="0.3" y1="0" x2="0.5" y2="0.3" spreadMethod="repeat"><stop offset="0" stop-color="#fc0"/><stop offset="0.5" stop-color="#063"/><stop offset="1" stop-color="#fff"/></linearGradient>"##,
),
(
"radial",
r##"<radialGradient id="g" cx="100" cy="60" r="25" fx="90" fy="55" gradientUnits="userSpaceOnUse" spreadMethod="reflect"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#0cf"/></radialGradient>"##,
),
] {
let body = format!(r#"<defs>{grad}</defs><rect x="10" y="10" width="180" height="100" fill="url(#g)"/>"#);
let (d, w) = import_with_report(&svg(&body)).unwrap();
assert!(w.is_empty(), "{spread}: {w:?}");
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.01);
}
}

View File

@ -0,0 +1,251 @@
//! SVG import keeps what the canvas edits live: `<pattern>` becomes a pattern swatch, `<symbol>`
//! with `<use>` a symbol and its instances, blurs and drop-shadow chains live effects; and
//! export → import keeps them.
// 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::sync::Arc;
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeKind};
use vectorcraft_effects::RasterFx;
use vectorcraft_geom::{Point, Rect, shapes};
use vectorcraft_svg::{ExportOptions, export, import, import_with_report};
use vectorcraft_testkit::raster::{Image, assert_similar, render_artboard};
fn svg(body: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="120">{body}</svg>"#)
}
fn open(body: &str) -> (Document, Vec<String>) {
import_with_report(&svg(body)).unwrap()
}
/// 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
}
/// 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() }
}
/// The effects of the only object.
fn effects(d: &Document) -> Vec<Effect> {
let a = art(d);
assert_eq!(a.len(), 1, "{a:?}");
a[0].appearance.effects.clone()
}
#[test]
fn a_checker_pattern_becomes_a_pattern_swatch() {
let (d, w) = open(
r##"<defs><pattern id="Checker" width="20" height="20" patternUnits="userSpaceOnUse">
<rect width="10" height="10" fill="#000"/><rect x="10" y="10" width="10" height="10" fill="#000"/>
</pattern></defs>
<rect width="200" height="120" fill="url(#Checker)"/>"##,
);
assert!(w.is_empty(), "{w:?}");
assert_eq!(d.patterns.len(), 1);
let p = &d.patterns[0];
assert_eq!((p.name.as_str(), p.tile), ("Checker", Rect::new(0.0, 0.0, 20.0, 20.0)));
assert!(matches!(art(&d)[0].appearance.fill_paint(), Paint::Pattern { pattern, .. } if pattern == "Checker"));
assert_similar(
&render_artboard(&d),
&resvg_render(
&svg(
r##"<defs><pattern id="Checker" width="20" height="20" patternUnits="userSpaceOnUse"><rect width="10" height="10" fill="#000"/><rect x="10" y="10" width="10" height="10" fill="#000"/></pattern></defs><rect width="200" height="120" fill="url(#Checker)"/>"##,
),
200,
120,
),
24.0,
0.01,
);
}
const SYMBOL: &str = r##"<defs><symbol id="Star" data-name="Gold Star"><path d="M0 0H20V20H0Z" fill="#e0a000"/><circle cx="10" cy="10" r="4" fill="#fff"/></symbol></defs>"##;
#[test]
fn two_uses_give_one_symbol_and_two_instances() {
let body = format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/><use xlink:href="#Star" transform="translate(100 50) rotate(30)"/>"##);
let (d, w) = open(&body);
assert!(w.is_empty(), "{w:?}");
assert_eq!(d.symbols.len(), 1);
assert_eq!(d.symbols[0].name, "Gold Star");
assert!(matches!(&d.symbols[0].art.kind, NodeKind::Group { children, .. } if children.len() == 2));
let instances: Vec<_> = art(&d)
.into_iter()
.filter_map(|n| match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => Some((symbol.clone(), *xf)),
_ => None,
})
.collect();
assert_eq!(instances.len(), 2, "{:?}", art(&d));
assert!(instances.iter().all(|(s, _)| s == "Gold Star"));
assert!((instances[0].1 * Point::ORIGIN).distance(Point::new(20.0, 30.0)) < 1e-9);
assert!((instances[1].1 * Point::ORIGIN).distance(Point::new(100.0, 50.0)) < 1e-9);
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.003);
}
#[test]
fn uses_the_symbol_cant_stand_for_stay_art() {
// Inherited paint: the second use shows the symbol in another colour. Scaled: the canvas keeps
// stroke weights as they are.
let body = r##"<defs><symbol id="Dot"><circle cx="5" cy="5" r="4" stroke="#000" stroke-width="2"/></symbol></defs>
<use href="#Dot" fill="red"/><use href="#Dot" x="40" fill="blue"/><use href="#Dot" x="80" fill="red"/>
<use href="#Dot" fill="red" transform="translate(120 0) scale(3)"/>"##;
let (d, _) = open(body);
assert_eq!(d.symbols.len(), 1);
let a = art(&d);
let instances = a.iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count();
assert_eq!(instances, 2, "the red ones not scaled: {a:?}");
assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 24.0, 0.003);
}
#[test]
fn symbols_round_trip_through_export() {
let (d, _) = open(&format!(r##"{SYMBOL}<use href="#Star" x="20" y="30"/><use href="#Star" x="120" y="60"/>"##));
let back = import(&export(&d, &ExportOptions::default())).unwrap();
assert_eq!(back.symbols.len(), 1);
assert_eq!(back.symbols[0].name, "Gold Star");
assert_eq!(art(&back).iter().filter(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).count(), 2);
assert_similar(&render_artboard(&d), &render_artboard(&back), 8.0, 0.001);
}
#[test]
fn a_gaussian_blur_becomes_the_effect_with_its_radius() {
let (d, w) = open(
r##"<defs><filter id="b"><feGaussianBlur stdDeviation="3"/></filter></defs><rect x="50" y="30" width="60" height="40" fill="#08f" filter="url(#b)"/>"##,
);
assert!(w.is_empty(), "{w:?}");
let fx = effects(&d);
assert_eq!(fx.len(), 1);
assert_eq!((fx[0].id.as_str(), &fx[0].params), ("blur.gaussian", &json!({"radius": 6.0})));
// In a scaled group, the radius scales too.
let (d, _) = open(
r##"<defs><filter id="b"><feGaussianBlur stdDeviation="3"/></filter></defs><g transform="scale(2)"><rect width="30" height="20" filter="url(#b)"/></g>"##,
);
assert_eq!(effects(&d)[0].params["radius"], json!(12.0));
}
/// The one drop shadow of the only object: (dx, dy, blur, colour, opacity %).
fn shadow(d: &Document) -> (f64, f64, f64, Color, f32) {
let fx = vectorcraft_effects::raster_effects(&effects(d));
match fx.as_slice() {
[RasterFx::DropShadow { dx, dy, blur, color, opacity, mode }] => {
assert_eq!(*mode, BlendMode::Normal, "filters composite normally");
(*dx, *dy, *blur, *color, (opacity * 100.0).round())
}
other => panic!("{other:?}"),
}
}
#[test]
fn drop_shadow_chains_become_drop_shadows() {
let rect = r##"<rect x="50" y="30" width="60" height="40" fill="#08f" filter="url(#s)"/>"##;
let chains = [
// feDropShadow.
r##"<feDropShadow dx="4" dy="5" stdDeviation="2" flood-color="#ff0000" flood-opacity="0.5"/>"##,
// Offset, blur, flood, composite, merge.
r##"<feOffset in="SourceAlpha" dx="4" dy="5"/><feGaussianBlur stdDeviation="2" result="b"/><feFlood flood-color="#ff0000" flood-opacity="0.5"/><feComposite in2="b" operator="in"/><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>"##,
// Flood in the source, blur, offset, the source over it.
r##"<feFlood flood-color="#ff0000" flood-opacity="0.5" result="f"/><feComposite in="f" in2="SourceGraphic" operator="in"/><feGaussianBlur stdDeviation="2"/><feOffset dx="4" dy="5" result="o"/><feComposite in="SourceGraphic" in2="o" operator="over"/>"##,
// A colour matrix paints the blurred, moved silhouette.
r##"<feGaussianBlur in="SourceAlpha" stdDeviation="2"/><feOffset dx="4" dy="5"/><feColorMatrix values="0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0.5 0" result="c"/><feMerge><feMergeNode in="c"/><feMergeNode in="SourceGraphic"/></feMerge>"##,
];
for chain in chains {
let (d, w) = open(&format!(r#"<defs><filter id="s">{chain}</filter></defs>{rect}"#));
assert!(w.is_empty(), "{chain}: {w:?}");
let (dx, dy, blur, color, opacity) = shadow(&d);
assert_eq!((dx, dy, blur, opacity), (4.0, 5.0, 4.0, 50.0), "{chain}");
assert_eq!(color.to_hex(), "#ff0000", "{chain}");
}
}
#[test]
fn other_filters_are_reported() {
let (d, w) =
open(r##"<defs><filter id="t"><feTurbulence baseFrequency="0.05"/></filter></defs><rect width="60" height="40" filter="url(#t)"/>"##);
assert!(w.iter().any(|w| w.contains("filter")), "{w:?}");
assert!(effects(&d).is_empty());
}
#[test]
fn a_filter_and_a_clip_on_one_object_filter_first() {
let body = r##"<defs><filter id="b"><feGaussianBlur stdDeviation="4"/></filter><clipPath id="c"><rect x="60" y="40" width="40" height="40"/></clipPath></defs>
<rect x="50" y="30" width="60" height="60" fill="#08f" filter="url(#b)" clip-path="url(#c)"/>"##;
let (d, w) = open(body);
assert!(w.is_empty(), "{w:?}");
let a = art(&d);
assert!(matches!(a[0].kind, NodeKind::Group { clip: true, .. }) && a[0].appearance.effects.is_empty());
assert!(a.iter().any(|n| n.appearance.effects.iter().any(|e| e.id == "blur.gaussian")));
assert_similar(&render_artboard(&d), &resvg_render(&svg(body), 200, 120), 40.0, 0.02);
}
#[test]
fn exported_effects_import_as_the_same_effects() {
let fx = |id: &str, p: serde_json::Value| Effect { id: id.into(), params: p, visible: true };
let cases = [
vec![fx("stylize.dropShadow", json!({"x": 7.0, "y": -3.0, "blur": 5.0, "opacity": 60.0, "color": "#203040"}))],
vec![fx("stylize.outerGlow", json!({"blur": 8.0, "color": "#ffcc00", "opacity": 80.0}))],
vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#ffffff", "source": "edge", "mode": "normal"}))],
vec![fx("stylize.innerGlow", json!({"blur": 6.0, "color": "#00ff00", "source": "center", "mode": "multiply"}))],
vec![fx("stylize.feather", json!({"radius": 4.0}))],
// Stacked: the first innermost.
vec![fx("blur.gaussian", json!({"radius": 3.0})), fx("stylize.dropShadow", json!({"x": 2.0, "y": 2.0, "blur": 1.0}))],
];
for effects in cases {
let mut d = Document::new(200.0, 120.0);
let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(50.0, 30.0, 110.0, 90.0)), Appearance::default_art());
n.appearance.effects = effects.clone();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let (back, w) = import_with_report(&export(&d, &ExportOptions::default())).unwrap();
assert!(w.is_empty(), "{w:?}");
let got = vectorcraft_effects::raster_effects(&art(&back)[0].appearance.effects);
assert_eq!(got, vectorcraft_effects::raster_effects(&effects), "{effects:?}");
}
}
#[test]
fn symbols_and_effects_export_import_export_is_stable() {
// A doc with a symbol instance carrying a shadow: export → import → export is stable.
let mut d = Document::new(200.0, 120.0);
let dot = Node::path(
d.alloc_id(),
shapes::ellipse(Rect::new(0.0, 0.0, 20.0, 20.0)),
Appearance::basic(Paint::solid(Color::rgb8(200, 30, 30)), Paint::None, 0.0),
);
d.symbols.push(vectorcraft_doc::Symbol { name: "Dot".into(), art: Arc::new(dot) });
let l = d.layers[0].id;
for x in [10.0, 60.0] {
let id = d.alloc_id();
d.insert(
Some(l),
usize::MAX,
Node::new(id, NodeKind::SymbolInstance { symbol: "Dot".into(), xf: vectorcraft_geom::Affine::translate((x, 40.0)) }),
)
.unwrap();
}
let mut blurred = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(120.0, 30.0, 180.0, 90.0)), Appearance::default_art());
blurred.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params: json!({"x": 3.0, "y": 3.0, "blur": 2.0}), visible: true });
d.insert(Some(l), usize::MAX, blurred).unwrap();
let opts = ExportOptions { object_ids: vectorcraft_svg::ObjectIds::Minimal, ..Default::default() };
let first = export(&d, &opts);
let second = export(&import(&first).unwrap(), &opts);
let strip = |s: &str| s.lines().filter(|l| !l.contains("<title>")).collect::<Vec<_>>().join("\n");
assert_eq!(strip(&first), strip(&second));
}

View File

@ -123,7 +123,17 @@ fn hidden_text_is_skipped_and_use_instances_are_kept() {
r##"<text y="10" display="none">gone</text><g visibility="hidden"><text y="20">hidden</text></g>
<defs><text id="t" y="30">Used</text></defs><use href="#t" x="10"/><use xlink:href="#t" x="50"/>"##,
);
let t = texts(&d);
// An undisplayed text comes back as a hidden object (since SVG import keeps hidden objects).
let hidden: Vec<&Node> = art(&d).into_iter().filter(|n| !n.visible).collect();
assert!(matches!(hidden.as_slice(), [n] if matches!(&n.kind, NodeKind::Text(t) if t.plain_text() == "gone")), "{hidden:?}");
let t: Vec<&TextObject> = art(&d)
.into_iter()
.filter(|n| n.visible)
.filter_map(|n| match &n.kind {
NodeKind::Text(t) => Some(&**t),
_ => None,
})
.collect();
assert_eq!(t.len(), 2);
assert!(t.iter().all(|t| t.plain_text() == "Used"));
assert!(t[0].xf.translation().to_point().distance(Point::new(10.0, 30.0)) < 1e-9);

View File

@ -52,12 +52,16 @@ fn three_instances_give_one_symbol_and_three_uses() {
// The art is written once.
assert_eq!(svg.matches("<path").count(), 1, "{svg}");
assert_similar(&render_artboard(&d), &resvg_render(&svg, 200, 120), 24.0, 0.002);
// Read back: the three squares are where the instances are.
// Read back (since SVG import keeps symbols: as the symbol and three instances): the three
// squares are where the instances are.
let back = import(&svg).unwrap();
assert_eq!(back.symbols.len(), 1);
assert_eq!(back.symbols[0].name, "My Mark", "the name comes back");
let art = back.symbols[0].art.path_data().unwrap().bounds().unwrap();
let mut boxes = vec![];
back.walk(|n| {
if let Some(p) = n.path_data() {
boxes.push(p.bounds().unwrap());
if let NodeKind::SymbolInstance { xf, .. } = &n.kind {
boxes.push(xf.transform_rect_bbox(art));
}
});
assert_eq!(boxes.len(), 3, "{boxes:?}");

View File

@ -149,6 +149,16 @@ of the file, so Save asks for a new name instead of writing it back.
`file.documentColorMode {mode, convert?, intent?}` switches the document colour mode, converting its colours
through the colour settings (`object.convertDocumentColorMode` is an alias kept for older scripts).
SVG import keeps what the canvas edits live. `<pattern>` becomes a pattern swatch. A `<symbol>` with `<use>` becomes a
symbol (named after its `data-name`, else its id) with an instance per `<use>`; a `<use>` that shows it differently
(paint inherited from the `<use>`, a viewport that cuts the art) or that the canvas would draw otherwise (a scaled
symbol with strokes) stays art. `feGaussianBlur`, `feDropShadow` and the usual shadow chains (offset, blur, flood or
colour matrix, composite, merge) become Gaussian Blur, Drop Shadow, Outer Glow, Inner Glow and Feather effects; other
filters are listed in `warnings`. Undisplayed objects (`display:none`) come back hidden, nested clip paths intersect,
and reflected or repeated gradients (`spreadMethod`) are expanded into stops. `<image>` files (relative links are
found in the SVG's folder) stay linked images (see Linked images); SVG files and `data:` SVGs become art; a file that
can't be read becomes a placeholder in its box, named in `warnings` and `missingLinks`.
## Resources
| URI | Content |