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:
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");
|
||||
}
|
||||
|
|
@ -14,26 +14,36 @@ use vectorcraft_doc::{
|
|||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
|
||||
|
||||
use crate::export::Reuse;
|
||||
use crate::{SvgError, fnv1a};
|
||||
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 (svg, found) = prepass(svg);
|
||||
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);
|
||||
|
|
@ -51,7 +61,11 @@ pub(crate) fn import(svg: &str) -> Result<(Document, Vec<String>), SvgError> {
|
|||
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();
|
||||
|
|
@ -144,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.
|
||||
|
|
@ -164,7 +181,7 @@ 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 ([`prepass`]).
|
||||
/// 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>,
|
||||
|
|
@ -172,6 +189,8 @@ struct Importer {
|
|||
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
|
||||
|
|
@ -271,15 +290,18 @@ pub(super) fn href<'a>(a: XNode<'a, '_>) -> Option<&'a str> {
|
|||
struct Found {
|
||||
/// The URL of each `<a href>`'s group, by its id.
|
||||
links: HashMap<String, String>,
|
||||
/// Ids of the hidden groups shown for usvg: they come back hidden.
|
||||
/// 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)]
|
||||
struct Edits {
|
||||
pub(super) struct Edits {
|
||||
list: Vec<(std::ops::Range<usize>, String)>,
|
||||
/// The ids made up so far, by element.
|
||||
ids: HashMap<roxmltree::NodeId, String>,
|
||||
|
|
@ -305,7 +327,7 @@ impl Edits {
|
|||
}
|
||||
|
||||
/// Set attribute `name` of element `n` (in `svg`) to `value` (XML-escaped already).
|
||||
fn set(&mut self, svg: &str, n: XNode, name: &str, value: &str) {
|
||||
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}\"")),
|
||||
|
|
@ -333,16 +355,17 @@ impl Edits {
|
|||
}
|
||||
}
|
||||
|
||||
/// usvg reads `<a href>` and `<use>` as plain groups and drops what isn't displayed. Before it
|
||||
/// runs, the SVG is changed so that what we keep can be found:
|
||||
/// 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;
|
||||
/// * hidden layers (as Save writes them: `display: none` on a top-level `<g id>`, or on such a
|
||||
/// group inside groups) are shown and recorded, to come back hidden; a group something links to
|
||||
/// (a `<use>` template) stays as it is.
|
||||
fn prepass(svg: &str) -> (Cow<'_, str>, Found) {
|
||||
/// * 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();
|
||||
if !["<a", "display", "<use"].iter().any(|t| svg.contains(t)) {
|
||||
// (`:` 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 {
|
||||
|
|
@ -362,28 +385,36 @@ fn prepass(svg: &str) -> (Cow<'_, str>, Found) {
|
|||
}
|
||||
}
|
||||
if svg.contains("display") {
|
||||
hidden_groups(svg, &xml, &mut edits, &mut found.hidden);
|
||||
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)
|
||||
}
|
||||
|
||||
/// Show the hidden `<g id>` layers for usvg, recording their ids.
|
||||
fn hidden_groups(svg: &str, xml: &roxmltree::Document, edits: &mut Edits, ids: &mut HashSet<String>) {
|
||||
/// 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 group = |n: &XNode| n.is_element() && n.tag_name().name() == "g" && n.attribute("id").is_none_or(|id| !linked.contains(id));
|
||||
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") {
|
||||
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;
|
||||
}
|
||||
ids.insert(id.to_string());
|
||||
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 g.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) {
|
||||
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, g), " style=\"display:inline\"".to_string()),
|
||||
None => edits.insert(tag_name_end(svg, n), " style=\"display:inline\"".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -634,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();
|
||||
|
|
@ -686,11 +720,25 @@ impl Importer {
|
|||
inner.appearance.effects = effects.clone();
|
||||
children = vec![Arc::new(inner)];
|
||||
}
|
||||
if cp.clip_path().is_some() {
|
||||
self.warn(format!("nested clip path on {label} approximated by its outer 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 clip = self.clip_node(cp, ts)?;
|
||||
let mut ch = vec![Arc::new(clip)];
|
||||
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 {
|
||||
|
|
@ -788,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()));
|
||||
}
|
||||
|
|
@ -824,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
|
||||
|
|
@ -859,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());
|
||||
|
|
@ -872,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() {
|
||||
|
|
@ -910,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
|
||||
|
|
@ -940,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[..]))
|
||||
|
|
@ -952,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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -36,7 +36,7 @@
|
|||
//! 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). 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).
|
||||
|
|
@ -47,8 +47,12 @@
|
|||
//! 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.
|
||||
//! 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.
|
||||
//!
|
||||
|
|
@ -336,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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -154,7 +154,10 @@ symbol (named after its `data-name`, else its id) with an instance per `<use>`;
|
|||
(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`.
|
||||
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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue