M4.42: SVG import reads linked images from the SVG's folder, intersects nested clips, keeps hidden objects and expands reflected gradients

document.open {path} (and File > Place) find an SVG's relative <image>
links in its folder: raster files stay linked images (LinkInfo with size,
time and hash), SVG files and data: SVGs import as vector art, and a file
that can't be read becomes a placeholder in its box that keeps the link,
with a warning and a missingLinks row (Update Links / Relink read it
later). The SVG library itself reads no file unless given a reader.
Nested clip paths become clip groups in clip groups; undisplayed objects
(display:none) come back hidden; spreadMethod reflect and repeat are
expanded into stops over the painted area (matching resvg). Midpoint stops
and focal points keep folding back as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 12:12:06 +07:00
co-authored by Claude Opus 5.5
parent 0d75df4da8
commit 303fc3cbca
11 changed files with 910 additions and 79 deletions
+1 -1
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"));
+20 -3
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
+4 -1
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;
@@ -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");
}