mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -06:00
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:
co-authored by
Claude Opus 5.5
parent
0d75df4da8
commit
303fc3cbca
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user