Merge pull request #7570 from dotCooCoo/fix/svg-nested-image-links-read

SVG import: linked rasters inside a linked SVG file are read
This commit is contained in:
BFlat
2026-11-14 21:47:30 +07:00
committed by GitHub
5 changed files with 600 additions and 55 deletions
+404 -54
View File
@@ -4,6 +4,10 @@
//! (missing, remote, not an image) becomes a placeholder that keeps the link and its place, so it
//! can be relinked, and a warning.
//!
//! A linked SVG file's own `<image>` links are read the same way, relative paths from that file's
//! folder, within the same limits; an href that leads back to an SVG file showing it gets the
//! placeholder and a warning.
//!
//! A raster image, linked or a data URL, whose orientation (EXIF, or a TIFF's Orientation tag)
//! turns or flips its pixels is turned before usvg gets it, so it is sized, fitted and stored as
//! it is shown.
@@ -35,6 +39,9 @@ pub const MAX_NESTED_SVG_TEXT: u64 = 512 << 20;
pub(in crate::import) struct Files {
/// What usvg gets for each linked href.
sources: HashMap<String, Source>,
/// The SVG images usvg is reading, innermost last: a linked SVG file, whose own links an href
/// inside it is looked up in, or `None` for a data URL.
within: Mutex<Vec<Option<Arc<SvgFile>>>>,
/// 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).
@@ -61,6 +68,9 @@ pub(in crate::import) struct Files {
svg_text: AtomicU64,
/// SVG images were left out for exceeding [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`].
too_much_svg_read: AtomicBool,
/// Images linked from linked SVG files were left out, their files not read, past the same
/// limits for the files read for links.
too_much_linked: AtomicBool,
/// The CMYK TIFF stored in place of a turned CMYK image, by the address of the PNG usvg got
/// (kept here too, so the address stays its own).
cmyk: Mutex<HashMap<usize, (Bytes, Bytes)>>,
@@ -84,8 +94,82 @@ type RasterKind = fn(Arc<Vec<u8>>) -> ImageKind;
enum Source {
/// A raster file of the kind usvg reads it as.
Raster(RasterKind, Arc<Vec<u8>>),
/// An SVG file's text (unpacked when it is SVGZ), parsed by usvg as an image.
Svg(Arc<str>),
/// An SVG file, parsed by usvg as an image.
Svg(Arc<SvgFile>),
}
/// A linked SVG file as usvg reads it.
struct SvgFile {
/// Its text (unpacked when it is SVGZ), placeholders stretched to their boxes.
text: Arc<str>,
/// What usvg gets for each href its own `<image>` elements link to.
sources: HashMap<String, Source>,
}
/// The state of [`Files::read`] across the SVG files it reads the links of.
struct Reading<'a, 'w> {
opts: &'a ImportOptions<'a>,
warnings: &'w mut Vec<String>,
/// The placeholder's bytes, made once.
placeholder: Option<Option<Vec<u8>>>,
/// What each file read gave, by the file: hrefs spelled differently (`a.png`, `./a.png`,
/// `%61.png`), and in different SVG files, share one read and one copy of its bytes. Only an
/// SVG file whose art depends on where it is shown ([`Reading::placed`]) isn't kept, and a
/// raster read past the limits, which is left out.
by_file: HashMap<String, Outcome>,
/// The SVG files whose links are being read, innermost last, by [`identity`].
active: Vec<String>,
/// What the file being read gives depends on where it is shown: a link inside it led back to
/// an SVG file around it, or an SVG file inside it nests too deep there for its links to be
/// read.
placed: bool,
/// How many linked SVG files were read for their links, and the bytes of every linked SVG
/// file read and of every file read for the links of a linked SVG file. Rasters the imported
/// SVG links to aren't counted.
svg_reads: usize,
svg_text: u64,
}
impl Reading<'_, '_> {
/// The files read for links passed [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`].
fn spent(&self) -> bool {
self.svg_reads > MAX_NESTED_SVG_READS || self.svg_text > MAX_NESTED_SVG_TEXT
}
}
/// What reading a linked file gave: what usvg gets, or why it can't be read.
#[derive(Clone)]
struct Outcome {
source: Result<Source, &'static str>,
/// An SVG file whose own links weren't read, past [`Reading::spent`]: inside a linked SVG
/// file it is left out, and where the imported SVG links to it, it is shown without them.
capped: bool,
}
impl Outcome {
fn not_read(why: &'static str) -> Self {
Outcome { source: Err(why), capped: false }
}
fn shown(source: Source) -> Self {
Outcome { source: Ok(source), capped: false }
}
}
/// Does SVG text `text` have an `<image>` that links to a file (not a data URL)?
fn links_files(text: &str) -> bool {
super::parse(text).is_ok_and(|xml| xml.descendants().any(|n| n.is_element() && n.tag_name().name() == "image" && file_href(n).is_some()))
}
/// The href of `<image>` element `n`, unless it is a data URL.
fn file_href<'a>(n: roxmltree::Node<'a, '_>) -> Option<&'a str> {
href(n).filter(|h| !h.trim_start().starts_with("data:"))
}
/// What the link to an SVG file being read is compared by, to find a link back to it: its path
/// with links and `..` resolved, or the path as given when that can't be found.
fn identity(file: &str) -> String {
std::fs::canonicalize(file).map_or_else(|_| file.to_string(), |p| p.to_string_lossy().into_owned())
}
/// A linked raster image (or the placeholder of a missing one).
@@ -164,54 +248,156 @@ impl Files {
/// 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;
// What each file read gave, by the file: hrefs spelled differently (`a.png`, `./a.png`,
// `%61.png`) share one read and one copy of its bytes.
let mut by_file: HashMap<String, Result<Source, &str>> = HashMap::new();
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, file) = target(h, opts.folder);
// A failed read is cached too, but each href gets its own placeholder: its own link
// path (an Arc shared between hrefs would share the first href's link) and warning.
let cached = file.as_ref().and_then(|f| by_file.get(f)).cloned();
let source = match cached {
Some(source) => source,
None => {
let read = file.clone().and_then(|file| opts.read.and_then(|read| read(&file)));
let source = match read {
Some((bytes, link)) => files.source(bytes, link).ok_or("is not an image VectorCraft reads"),
None => Err("not found"),
};
if let Some(file) = file {
by_file.insert(file, source.clone());
}
source
}
};
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(missing_placeholder).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");
}
}
let mut reading =
Reading { opts, warnings, placeholder: None, by_file: HashMap::new(), active: vec![], placed: false, svg_reads: 0, svg_text: 0 };
files.sources = files.links_of(svg, xml, opts.folder, None, edits, &mut reading);
files
}
/// What usvg gets for each href the `<image>` elements of `xml` (the text `svg`) link to,
/// relative paths taken from `folder`. `inside` is how deep the SVG images `xml` shows nest in
/// it and the SVGs around it, measured when an SVG file is read (`None` for `xml` itself).
fn links_of(
&mut self,
svg: &str,
xml: &roxmltree::Document,
folder: Option<&str>,
mut inside: Option<usize>,
edits: &mut Edits,
reading: &mut Reading<'_, '_>,
) -> HashMap<String, Source> {
let mut sources: HashMap<String, Source> = HashMap::new();
let nested = !reading.active.is_empty();
for n in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "image") {
let Some(h) = file_href(n) else { continue };
if !sources.contains_key(h) {
let (path, file) = target(h, folder);
// A failed read is cached too, but each href gets its own placeholder: its own link
// path (an Arc shared between hrefs would share the first href's link) and warning.
let back = nested && file.as_deref().is_some_and(|f| reading.active.contains(&identity(f)));
let cached = file.as_ref().and_then(|f| reading.by_file.get(f)).cloned();
let outcome = match cached {
_ if back => {
reading.placed = true;
Some(Outcome::not_read("leads back to an SVG file that shows it"))
}
Some(outcome) => Some(outcome),
None => {
let around = std::mem::replace(&mut reading.placed, false);
let outcome = self.file(file.as_deref(), xml, &mut inside, reading);
// Kept unless what it shows depends on where it is.
let placed = reading.placed;
reading.placed |= around;
if let (Some(file), Some(outcome)) = (file, &outcome)
&& !placed
{
reading.by_file.insert(file, outcome.clone());
}
outcome
}
};
// Inside a linked SVG file, past what may be read, the image is left out (noted).
let source = match outcome {
None => continue,
Some(Outcome { capped: true, .. }) if nested => {
self.too_much_svg_read.store(true, Ordering::Relaxed);
continue;
}
Some(outcome) => outcome.source,
};
let source = source.or_else(|why| {
reading.warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked"));
let bytes = Arc::new(reading.placeholder.get_or_insert_with(missing_placeholder).clone().ok_or(why)?);
self.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true });
Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes))
});
let Ok(source) = source else { continue };
sources.insert(h.to_string(), source);
}
if sources.get(h).is_some_and(|s| self.missing(s)) {
edits.set(svg, n, "preserveAspectRatio", "none");
}
}
sources
}
/// What reading linked file `file` gives, linked from `xml`, whose SVG images nest `inside`
/// deep (measured here when an SVG file is read); `None` for an image left out, noted. Inside
/// a linked SVG file each file read counts toward [`MAX_NESTED_SVG_TEXT`] before it is looked
/// at, and once the files read for links pass [`MAX_NESTED_SVG_READS`] or
/// [`MAX_NESTED_SVG_TEXT`] no file is read there; a raster whose read passes them is left out
/// undecoded. Every file the imported SVG links to is read.
fn file(&mut self, file: Option<&str>, xml: &roxmltree::Document, inside: &mut Option<usize>, reading: &mut Reading<'_, '_>) -> Option<Outcome> {
let (Some(file), Some(read)) = (file, reading.opts.read) else { return Some(Outcome::not_read("not found")) };
let nested = !reading.active.is_empty();
if nested && reading.spent() {
self.too_much_linked.store(true, Ordering::Relaxed);
return None;
}
let Some((bytes, link)) = read(file) else { return Some(Outcome::not_read("not found")) };
let counted = if nested { bytes.len() as u64 } else { 0 };
reading.svg_text = reading.svg_text.saturating_add(counted);
if nested && reading.spent() && image::guess_format(&bytes).is_ok() {
self.too_much_linked.store(true, Ordering::Relaxed);
return None;
}
match self.source(bytes, link) {
Some(Source::Svg(svg)) => {
let inside = *inside.get_or_insert_with(|| depth::height(xml, MAX_ELEMENT_DEPTH).unwrap_or(MAX_ELEMENT_DEPTH));
Some(self.svg_file(svg, file, inside, counted, reading))
}
source => Some(source.map_or_else(|| Outcome::not_read("is not an image VectorCraft reads"), Outcome::shown)),
}
}
/// Linked SVG file `file`, read as `svg`, with the files its own `<image>` elements link to
/// read from its folder; `inside` is how deep it nests in the SVGs around it, and `counted` the
/// bytes of it already counted toward [`MAX_NESTED_SVG_TEXT`]. Its text counts toward
/// [`MAX_NESTED_SVG_TEXT`], and reading its links toward [`MAX_NESTED_SVG_READS`]; once they
/// are passed, its links aren't read ([`Outcome::capped`], noted when it links to files). Its
/// links aren't read either when it nests too deep to be shown, or when it isn't XML.
fn svg_file(&mut self, svg: Arc<SvgFile>, file: &str, inside: usize, counted: u64, reading: &mut Reading<'_, '_>) -> Outcome {
reading.svg_text = reading.svg_text.saturating_add((svg.text.len() as u64).saturating_sub(counted));
let links = svg.text.contains("image");
reading.svg_reads += usize::from(links);
if reading.spent() {
if links && links_files(&svg.text) {
self.too_much_linked.store(true, Ordering::Relaxed);
}
return Outcome { source: Ok(Source::Svg(svg)), capped: true };
}
let left = MAX_ELEMENT_DEPTH.saturating_sub(inside);
if !links {
return Outcome::shown(Source::Svg(svg));
}
if depth::xml_deeper_than(&svg.text, left) {
reading.placed = true;
return Outcome::shown(Source::Svg(svg));
}
// Read from its own handle: the text stays borrowed while `svg` may be given back.
let text = svg.text.clone();
let Ok(xml) = super::parse(&text) else { return Outcome::shown(Source::Svg(svg)) };
let Some(height) = depth::height(&xml, left) else {
reading.placed = true;
return Outcome::shown(Source::Svg(svg));
};
let folder = Path::new(file).parent().map(|p| p.to_string_lossy().into_owned());
let mut edits = Edits::default();
reading.active.push(identity(file));
let sources = self.links_of(&text, &xml, folder.as_deref(), Some(inside + height), &mut edits, reading);
reading.active.pop();
let text = Arc::from(edits.apply(&text).as_ref());
Outcome::shown(Source::Svg(Arc::new(SvgFile { text, sources })))
}
/// What usvg gets for a file read (`bytes`, linked by `link`): a raster as [`shown`] gives it
/// (formats usvg doesn't read are stored as PNG) or an SVG; `None` for anything else.
/// (formats usvg doesn't read are stored as PNG) or an SVG, its own links not read yet; `None`
/// for anything else.
fn source(&mut self, bytes: Vec<u8>, link: LinkInfo) -> Option<Source> {
let Ok(format) = image::guess_format(&bytes) else {
// Unpacked once, here, within what the SVGZ images may unpack to.
let text = crate::text_within(&bytes, &self.unpacked).ok()?;
return text.contains("<svg").then(|| Source::Svg(Arc::from(text.as_ref())));
return text.contains("<svg").then(|| Source::Svg(Arc::new(SvgFile { text: Arc::from(text.as_ref()), sources: HashMap::new() })));
};
let (kind, bytes) = self.keep(shown(Arc::new(bytes), format)?);
self.links.insert(key(&bytes), Linked { link, missing: false });
@@ -236,9 +422,21 @@ impl Files {
self.cmyk.lock().ok()?.get(&key(b)).map(|(_, tiff)| tiff.clone())
}
/// 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))
/// Is `source` a placeholder?
fn missing(&self, source: &Source) -> bool {
matches!(source, Source::Raster(_, b) if self.links.get(&key(b)).is_some_and(|l| l.missing))
}
/// What usvg gets for href `h` where it is read: inside a linked SVG file, from that file's
/// own links; outside any SVG image, from the imported SVG's. Inside an SVG data URL, which
/// has no folder of its own, nothing.
fn find(&self, h: &str) -> Option<Source> {
let within = self.within.lock().ok()?;
let sources = match within.last() {
None => &self.sources,
Some(file) => &file.as_ref()?.sources,
};
sources.get(h).or_else(|| sources.get(h.trim())).cloned()
}
/// The link of the image whose data usvg got as `kind`.
@@ -272,6 +470,12 @@ impl Files {
MAX_NESTED_SVG_TEXT >> 20
));
}
if self.too_much_linked.load(Ordering::Relaxed) {
notes.push(format!(
"images linked from linked SVG files were left out: once {MAX_NESTED_SVG_READS} SVG files or {} MB have been read for their links, the files they link to aren't read",
MAX_NESTED_SVG_TEXT >> 20
));
}
notes
}
@@ -308,9 +512,9 @@ impl Files {
let (kind, bytes) = self.keep(shown(bytes, format)?);
Some(kind(bytes))
}),
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(text) => self.svg(text, opts),
resolve_string: Box::new(move |h, opts| match self.find(h)? {
Source::Raster(kind, bytes) => Some(kind(bytes)),
Source::Svg(file) => self.svg(&file.text.clone(), Some(file), opts),
}),
}
}
@@ -320,7 +524,7 @@ impl Files {
/// noted.
fn svg_data(&self, bytes: &[u8], opts: &usvg::Options) -> Option<ImageKind> {
match crate::text_within(bytes, &self.unpacked) {
Ok(text) => self.svg(&text, opts),
Ok(text) => self.svg(&text, None, opts),
Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))),
}
}
@@ -329,8 +533,9 @@ impl Files {
/// can't read, when it isn't SVG or when its elements nest deeper than the SVGs around it
/// leave: usvg reads it while it reads them, on the same stack. Only a decoded image, with the
/// images decoded inside it, counts toward [`MAX_NESTED_SVG_IMAGES`]; every image read counts
/// toward [`MAX_NESTED_SVG_READS`] and [`MAX_NESTED_SVG_TEXT`].
fn svg(&self, text: &str, opts: &usvg::Options) -> Option<ImageKind> {
/// toward [`MAX_NESTED_SVG_READS`] and [`MAX_NESTED_SVG_TEXT`]. `file` is the linked SVG file
/// the text is, whose own links the hrefs in it are looked up in.
fn svg(&self, text: &str, file: Option<Arc<SvgFile>>, opts: &usvg::Options) -> Option<ImageKind> {
let decoded = self.svg_images.load(Ordering::Relaxed);
if decoded >= MAX_NESTED_SVG_IMAGES {
return leave_out(&self.too_many_svg_images, true);
@@ -345,7 +550,7 @@ impl Files {
// The image's place is taken while usvg reads it, so the images inside it get what is left.
// usvg reads them all on this thread, so no other read comes between these steps.
self.svg_images.store(decoded + 1, Ordering::Relaxed);
let tree = self.svg_tree(text, opts);
let tree = self.svg_tree(text, file, opts);
if tree.is_none() {
self.svg_images.store(decoded, Ordering::Relaxed);
}
@@ -353,7 +558,7 @@ impl Files {
}
/// [`Files::svg`]'s image once it is counted.
fn svg_tree(&self, text: &str, opts: &usvg::Options) -> Option<ImageKind> {
fn svg_tree(&self, text: &str, file: Option<Arc<SvgFile>>, opts: &usvg::Options) -> Option<ImageKind> {
// Measured as the SVG holding it is (`import`): the text before the XML reader.
let used = self.depth.load(Ordering::Relaxed);
let left = MAX_ELEMENT_DEPTH.saturating_sub(used);
@@ -365,7 +570,27 @@ impl Files {
// usvg's parser would overflow on it, as on the SVG holding it (`import`).
super::convolve_matrices_ok(&xml).ok()?;
self.depth.store(used + height, Ordering::Relaxed);
let tree = usvg::Tree::from_data_nested(text.as_bytes(), opts);
let within = self.within.lock().ok().map(|mut w| w.push(file)).is_some();
// usvg's `Tree::from_data_nested`, except that hrefs to files go to the resolver.
let nested = usvg::Options {
resources_dir: None,
dpi: opts.dpi,
font_size: opts.font_size,
languages: opts.languages.clone(),
shape_rendering: opts.shape_rendering,
text_rendering: opts.text_rendering,
image_rendering: opts.image_rendering,
default_size: opts.default_size,
image_href_resolver: ImageHrefResolver {
resolve_data: Box::new(|mime, bytes, o| (opts.image_href_resolver.resolve_data)(mime, bytes, o)),
resolve_string: Box::new(|h, o| (opts.image_href_resolver.resolve_string)(h, o)),
},
..usvg::Options::default()
};
let tree = usvg::Tree::from_str(text, &nested);
if within && let Ok(mut w) = self.within.lock() {
w.pop();
}
self.depth.store(used, Ordering::Relaxed);
let tree = tree.ok()?;
self.note_text(text);
@@ -630,6 +855,131 @@ mod tests {
assert!(read("1"));
}
/// An SVG file of 2 × 2 showing `hrefs`.
fn linking(hrefs: &[&str]) -> Vec<u8> {
let images: String = hrefs.iter().map(|h| format!(r#"<image href="{h}" width="1" height="1"/>"#)).collect();
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">{images}</svg>"#).into_bytes()
}
/// The links of the SVG showing `hrefs` read as [`Files::read`] reads them, the files `served`
/// by name, with the SVG files and bytes read for links starting at `reads` and `text`: the
/// names of the files read, in order, and the files.
fn read_links(hrefs: &[&str], served: &[(&str, Vec<u8>)], reads: usize, text: u64) -> (Vec<String>, Files) {
let log = Mutex::new(vec![]);
let read = |path: &str| {
let name = Path::new(path).file_name()?.to_string_lossy().into_owned();
log.lock().ok()?.push(name.clone());
served.iter().find(|(n, _)| *n == name).map(|(_, bytes)| (bytes.clone(), LinkInfo::new(path)))
};
let folder = std::env::temp_dir().join("vectorcraft-svg-limits");
let folder = folder.to_string_lossy();
let opts = ImportOptions { folder: Some(&folder), read: Some(&read) };
let svg = String::from_utf8(linking(hrefs)).unwrap();
let xml = super::super::parse(&svg).unwrap();
let mut warnings = vec![];
let mut reading = Reading {
opts: &opts,
warnings: &mut warnings,
placeholder: None,
by_file: HashMap::new(),
active: vec![],
placed: false,
svg_reads: reads,
svg_text: text,
};
let mut files = Files::default();
files.sources = files.links_of(&svg, &xml, opts.folder, None, &mut Edits::default(), &mut reading);
(log.into_inner().unwrap(), files)
}
fn png() -> Vec<u8> {
let mut out = vec![];
image::RgbaImage::new(1, 1).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn linked_note() -> String {
format!(
"images linked from linked SVG files were left out: once {MAX_NESTED_SVG_READS} SVG files or {} MB have been read for their links, the files they link to aren't read",
MAX_NESTED_SVG_TEXT >> 20
)
}
/// Every file read for a linked SVG file's links counts toward [`MAX_NESTED_SVG_TEXT`], a
/// raster too, after the linked SVG file's own text: the file that passes it is left out, and
/// no file after it is read.
#[test]
fn linked_files_past_the_text_limit_are_not_read() {
let a = linking(&["one.png", "two.png", "three.png"]);
let served = [("a.svg", a.clone()), ("one.png", png()), ("two.png", png()), ("three.png", png())];
let (log, files) = read_links(&["a.svg"], &served, 0, MAX_NESTED_SVG_TEXT - (png().len() + a.len()) as u64);
assert_eq!(log, ["a.svg", "one.png", "two.png"]);
let Some(Source::Svg(a)) = files.sources.get("a.svg") else { panic!("a.svg is shown") };
assert!(a.sources.contains_key("one.png") && a.sources.len() == 1, "{:?}", a.sources.keys());
assert_eq!(files.left_out(), [linked_note()]);
}
/// Once more than [`MAX_NESTED_SVG_READS`] SVG files are read for their links, the SVG file
/// past it is left out inside a linked SVG file, and no file a linked SVG file links to is
/// read (a nested link past the limit); rasters the imported SVG links to still are.
#[test]
fn linked_files_past_the_read_limit_are_not_read() {
let served = [("a.svg", linking(&["b.svg", "two.png"])), ("b.svg", linking(&["one.png"])), ("one.png", png()), ("two.png", png())];
let (log, files) = read_links(&["a.svg", "two.png"], &served, MAX_NESTED_SVG_READS - 1, 0);
assert_eq!(log, ["a.svg", "b.svg", "two.png"]);
let Some(Source::Svg(a)) = files.sources.get("a.svg") else { panic!("a.svg is shown") };
assert!(a.sources.is_empty(), "{:?}", a.sources.keys());
assert!(matches!(files.sources.get("two.png"), Some(Source::Raster(..))));
assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out());
assert!(files.left_out().contains(&linked_note()));
}
/// Reading the SVG files the imported SVG links to, and their links, counts toward the
/// limits: past them, such a file is still shown, but the files it links to aren't read.
#[test]
fn svg_files_the_imported_svg_links_to_are_shown_without_their_links_past_the_limits() {
let served = [("a.svg", linking(&["one.png"])), ("one.png", png())];
for (reads, text) in [(MAX_NESTED_SVG_READS, 0), (0, MAX_NESTED_SVG_TEXT)] {
let (log, files) = read_links(&["a.svg"], &served, reads, text);
assert_eq!(log, ["a.svg"], "one.png isn't read");
let Some(Source::Svg(a)) = files.sources.get("a.svg") else { panic!("a.svg is shown") };
assert!(a.sources.is_empty(), "{:?}", a.sources.keys());
assert_eq!(files.left_out(), [linked_note()]);
}
// More of them than the limit: those past it are shown without their links.
let names: Vec<String> = (0..MAX_NESTED_SVG_READS + 2).map(|i| format!("s{i}.svg")).collect();
let hrefs: Vec<&str> = names.iter().map(String::as_str).collect();
let mut served: Vec<(&str, Vec<u8>)> = hrefs.iter().map(|h| (*h, linking(&["one.png"]))).collect();
served.push(("one.png", png()));
let (log, files) = read_links(&hrefs, &served, 0, 0);
assert_eq!(log.len(), hrefs.len() + 1, "one.png is read once");
let with_links = hrefs.iter().filter(|h| matches!(files.sources.get(**h), Some(Source::Svg(s)) if s.sources.contains_key("one.png")));
assert_eq!(with_links.count(), MAX_NESTED_SVG_READS);
assert!(hrefs.iter().all(|h| matches!(files.sources.get(*h), Some(Source::Svg(_)))), "all shown");
assert_eq!(files.left_out(), [linked_note()]);
}
/// An SVG file read once is kept by its file wherever it was read: another spelling of its
/// href doesn't read it again, past the limits too.
#[test]
fn an_svg_file_is_read_once_past_the_limits() {
let served = [("a.svg", linking(&["one.png"])), ("one.png", png())];
let (log, files) = read_links(&["a.svg", "./a.svg", "%61.svg"], &served, MAX_NESTED_SVG_READS, 0);
assert_eq!(log, ["a.svg"]);
assert!(["a.svg", "./a.svg", "%61.svg"].iter().all(|h| matches!(files.sources.get(*h), Some(Source::Svg(_)))));
// Read inside a linked SVG file, where its read passes the text limit: left out there,
// and shown, not read again, where the imported SVG links to it.
let x = linking(&["a.svg"]);
let served = [("x.svg", x.clone()), ("a.svg", linking(&["one.png"])), ("one.png", png())];
let (log, files) = read_links(&["x.svg", "./a.svg"], &served, 0, MAX_NESTED_SVG_TEXT - x.len() as u64);
assert_eq!(log, ["x.svg", "a.svg"]);
let Some(Source::Svg(x)) = files.sources.get("x.svg") else { panic!("x.svg is shown") };
assert!(x.sources.is_empty(), "{:?}", x.sources.keys());
let Some(Source::Svg(a)) = files.sources.get("./a.svg") else { panic!("a.svg is shown") };
assert!(a.sources.is_empty(), "{:?}", a.sources.keys());
assert_eq!(files.left_out().len(), 2, "{:?}", files.left_out());
}
/// The nested SVG images read, decoded or not, take at most [`MAX_NESTED_SVG_TEXT`] bytes of
/// text together: an image whose text goes past it is left out and noted. A broken image's
/// text counts too, and its decoded place is given back.
+1 -1
View File
@@ -109,7 +109,7 @@ impl Edits {
/// `svg` with the edits made; overlapping ones and ranges off character boundaries are
/// skipped.
fn apply(mut self, svg: &str) -> Cow<'_, str> {
pub(super) fn apply(mut self, svg: &str) -> Cow<'_, str> {
self.list.append(&mut self.id_edits);
for (at, has_style, decls) in std::mem::take(&mut self.styles).into_values() {
self.insert(at, if has_style { decls } else { format!(" style=\"{}\"", decls.strip_prefix(';').unwrap_or(&decls)) });
+15
View File
@@ -276,3 +276,18 @@ fn a_turned_jpeg_in_an_svg_image_imports_upright() {
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)));
}
/// A turned JPEG that a linked SVG file links to is turned too.
#[test]
fn a_turned_jpeg_linked_from_a_linked_svg_file_imports_upright() {
let dir = folder("nested-file");
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("photo.jpg"), jpeg(Some(6))).unwrap();
let inner = r#"<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><image href="photo.jpg" width="80" height="80"/></svg>"#;
std::fs::write(dir.join("sub").join("inner.svg"), inner).unwrap();
let (d, im) = import_one(r#"<image href="sub/inner.svg" width="80" height="80"/>"#, Some(&dir));
assert_eq!((im.width, im.height), (8, 16));
assert!(near(placed(&im), Rect::new(20.0, 0.0, 60.0, 80.0)), "{:?}", placed(&im));
let px = pixels(&d, &im);
assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)));
}
+179
View File
@@ -216,6 +216,185 @@ fn svgz_images_import_as_vector_art() {
assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}");
}
/// An SVG file of 20 × 20 holding a 5 × 5 rectangle and `images`.
fn svg_file(images: &str) -> String {
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="5" height="5"/>{images}</svg>"#)
}
/// Reads as [`read`] does, counting the reads of each file name.
fn counting(reads: &std::sync::Mutex<Vec<String>>) -> impl Fn(&str) -> Option<(Vec<u8>, LinkInfo)> + Sync + '_ {
move |path: &str| {
if let Ok(mut reads) = reads.lock() {
reads.push(Path::new(path).file_name().unwrap().to_string_lossy().into_owned());
}
read(path)
}
}
/// A raster an SVG file links to is read from that file's folder and stays linked, in its place in
/// the SVG file's art.
#[test]
fn a_raster_linked_from_a_linked_svg_file_is_read_from_its_folder() {
let dir = folder("nested-link");
let bytes = png(40, 20);
std::fs::write(dir.join("img").join("photo.png"), &bytes).unwrap();
std::fs::write(dir.join("img").join("inner.svg"), svg_file(r#"<image href="photo.png" x="2" y="4" width="10" height="5"/>"#)).unwrap();
let (d, w) = open_in(&dir, r#"<image href="img/inner.svg" x="10" y="10" width="20" height="20"/>"#);
assert!(w.is_empty(), "{w:?}");
assert_eq!(rects(&d), 1, "the SVG file's art");
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.png"));
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 - 12.0).abs() < 1e-6 && (b.y0 - 14.0).abs() < 1e-6 && (b.width() - 10.0).abs() < 1e-6, "{b:?}");
}
/// One href in two SVG files of different folders names two files: each shows its own.
#[test]
fn one_href_in_svg_files_of_different_folders_shows_each_folders_file() {
let dir = folder("nested-same-href");
let (outer, inner) = (png(4, 4), png(6, 6));
std::fs::write(dir.join("photo.png"), &outer).unwrap();
std::fs::write(dir.join("img").join("photo.png"), &inner).unwrap();
std::fs::write(dir.join("img").join("inner.svg"), svg_file(r#"<image href="photo.png" width="10" height="10"/>"#)).unwrap();
let (d, w) = open_in(&dir, r#"<image href="photo.png" width="10" height="10"/><image href="img/inner.svg" x="50" width="20" height="20"/>"#);
assert!(w.is_empty(), "{w:?}");
let [(_, a), (_, b)] = images(&d)[..] else { panic!("{:?}", art(&d)) };
assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("photo.png"));
assert_eq!(*d.images[&a.key].bytes, outer);
assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("photo.png"));
assert_eq!(*d.images[&b.key].bytes, inner);
}
/// A raster an SVG file links to that can't be read (missing, or refused by the reader) gets the
/// placeholder, stretched to its box, its link and a warning, as one the imported SVG links to.
#[test]
fn a_missing_raster_linked_from_a_linked_svg_file_keeps_a_placeholder() {
let dir = folder("nested-missing");
std::fs::write(dir.join("img").join("secret.png"), png(4, 4)).unwrap();
let links = r#"<image href="gone.png" x="2" y="4" width="10" height="5"/><image href="secret.png" x="2" y="12" width="4" height="4"/>"#;
std::fs::write(dir.join("img").join("inner.svg"), svg_file(links)).unwrap();
let refusing = |path: &str| if path.ends_with("secret.png") { None } else { read(path) };
let folder = dir.to_string_lossy();
let body = r#"<image href="img/inner.svg" x="10" y="10" width="20" height="20"/>"#;
let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&refusing) }).unwrap();
for name in ["gone.png", "secret.png"] {
assert!(w.iter().any(|w| w.contains(&format!("'{name}' not found"))), "{name}: {w:?}");
}
let [(_, gone), (_, secret)] = images(&d)[..] else { panic!("{:?}", art(&d)) };
assert_eq!(Path::new(&gone.link.as_ref().unwrap().path), dir.join("img").join("gone.png"));
assert_eq!(Path::new(&secret.link.as_ref().unwrap().path), dir.join("img").join("secret.png"));
assert!(d.images[&gone.key].is_proxy() && d.images[&secret.key].is_proxy(), "placeholders, not the files");
let b = gone.xf.transform_rect_bbox(Rect::new(0.0, 0.0, gone.width as f64, gone.height as f64));
assert!((b.x0 - 12.0).abs() < 1e-6 && (b.width() - 10.0).abs() < 1e-6 && (b.height() - 5.0).abs() < 1e-6, "{b:?}");
}
/// An SVG file that links to itself, or to an SVG file that links back to it, is read once each
/// time it is shown: the link back gets the placeholder and a warning.
#[test]
fn svg_files_that_link_back_keep_a_placeholder_where_they_lead_back() {
let dir = folder("nested-cycle");
std::fs::write(dir.join("img").join("self.svg"), svg_file(r#"<image href="self.svg" width="10" height="10"/>"#)).unwrap();
std::fs::write(dir.join("img").join("a.svg"), svg_file(r#"<image href="b.svg" width="10" height="10"/>"#)).unwrap();
std::fs::write(dir.join("img").join("b.svg"), svg_file(r#"<image href="./a.svg" width="10" height="10"/>"#)).unwrap();
let folder = dir.to_string_lossy();
for (body, files, back) in [("img/self.svg", vec!["self.svg"], "self.svg"), ("img/a.svg", vec!["a.svg", "b.svg"], "./a.svg")] {
let reads = std::sync::Mutex::new(vec![]);
let counted = counting(&reads);
let body = format!(r#"<image href="{body}" width="20" height="20"/><image href="./{body}" x="50" width="20" height="20"/>"#);
let (d, w) = import_with(&svg(&body), &ImportOptions { folder: Some(&folder), read: Some(&counted) }).unwrap();
assert_eq!(*reads.lock().unwrap(), [files.clone(), files.clone()].concat());
let warning = format!("linked image '{back}' leads back to an SVG file that shows it: a placeholder keeps its place until it is relinked");
assert_eq!(w, [warning.clone(), warning]);
assert_eq!(rects(&d), 2 * files.len(), "each file's art once in each place");
let found = images(&d);
assert_eq!(found.len(), 2);
for (_, im) in found {
assert!(d.images[&im.key].is_proxy());
assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join(back.trim_start_matches("./")));
}
}
}
/// Two SVG files that link to each other, each shown by the imported SVG, each show the other
/// once, then the placeholder of the link back to themselves.
#[test]
fn svg_files_that_link_to_each_other_each_lead_back_to_themselves() {
let dir = folder("nested-cycle-both");
std::fs::write(dir.join("img").join("a.svg"), svg_file(r#"<image href="b.svg" width="10" height="10"/>"#)).unwrap();
std::fs::write(dir.join("img").join("b.svg"), svg_file(r#"<image href="a.svg" width="10" height="10"/>"#)).unwrap();
let (d, w) = open_in(&dir, r#"<image href="img/a.svg" width="20" height="20"/><image href="img/b.svg" x="50" width="20" height="20"/>"#);
assert_eq!(w.len(), 2, "{w:?}");
assert_eq!(rects(&d), 4, "each file's art once in each place");
let [(_, a), (_, b)] = images(&d)[..] else { panic!("{:?}", art(&d)) };
assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("img").join("a.svg"));
assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("b.svg"));
}
/// An SVG file shown twice, and a raster that two SVG files link to, are read once.
#[test]
fn a_repeated_linked_svg_file_is_read_once() {
let dir = folder("nested-repeat");
std::fs::write(dir.join("img").join("photo.png"), png(4, 4)).unwrap();
std::fs::write(dir.join("img").join("a.svg"), svg_file(r#"<image href="photo.png" width="10" height="10"/>"#)).unwrap();
std::fs::write(
dir.join("img").join("b.svg"),
svg_file(r#"<image href="a.svg" width="10" height="10"/><image href="photo.png" width="10" height="10"/>"#),
)
.unwrap();
let reads = std::sync::Mutex::new(vec![]);
let counted = counting(&reads);
let folder = dir.to_string_lossy();
let body = r#"<image href="img/a.svg" width="20" height="20"/><image href="./img/a.svg" width="20" height="20"/><image href="img/b.svg" width="20" height="20"/>"#;
let (d, w) = import_with(&svg(body), &ImportOptions { folder: Some(&folder), read: Some(&counted) }).unwrap();
assert!(w.is_empty(), "{w:?}");
let mut reads = reads.lock().unwrap().clone();
reads.sort();
assert_eq!(reads, ["a.svg", "b.svg", "photo.png"]);
let found = images(&d);
assert_eq!(found.len(), 4);
assert!(found.iter().all(|(_, im)| Path::new(&im.link.as_ref().unwrap().path) == dir.join("img").join("photo.png")));
}
/// A chain of SVG files, each linking to the next, is read only as deep as SVG images may nest:
/// past it the images are left out, with a warning.
#[test]
fn a_chain_of_linked_svg_files_stops_at_the_depth_cap() {
let dir = folder("nested-chain");
let levels = vectorcraft_svg::MAX_ELEMENT_DEPTH;
for i in 0..levels {
std::fs::write(dir.join("img").join(format!("{i}.svg")), svg_file(&format!(r#"<image href="{}.svg" width="10" height="10"/>"#, i + 1)))
.unwrap();
}
let reads = std::sync::Mutex::new(vec![]);
let counted = counting(&reads);
let folder = dir.to_string_lossy();
let (d, w) =
import_with(&svg(r#"<image href="img/0.svg" width="20" height="20"/>"#), &ImportOptions { folder: Some(&folder), read: Some(&counted) })
.unwrap();
let reads = reads.lock().unwrap().len();
assert!(reads <= levels / 2 + 4, "{reads} reads");
assert!(rects(&d) < levels / 2, "{}", rects(&d));
assert!(w.iter().any(|w| w.contains("nest more than")), "{w:?}");
}
/// An SVG file reached first inside an SVG file where it nests too deep to be shown, then from the
/// imported SVG, shows the raster it links to where it fits.
#[test]
fn an_svg_file_too_deep_in_one_place_shows_its_links_in_another() {
let dir = folder("nested-deep-then-shallow");
let depth = vectorcraft_svg::MAX_ELEMENT_DEPTH;
let nest = |n: usize, inner: &str| format!("{}{inner}{}", "<g>".repeat(n), "</g>".repeat(n));
std::fs::write(dir.join("img").join("photo.png"), png(4, 4)).unwrap();
std::fs::write(dir.join("img").join("deep.svg"), svg_file(&nest(depth * 2 / 3, r#"<image href="a.svg" width="10" height="10"/>"#))).unwrap();
std::fs::write(dir.join("img").join("a.svg"), svg_file(&nest(depth / 2, r#"<image href="photo.png" width="10" height="10"/>"#))).unwrap();
let (d, w) = open_in(&dir, r#"<image href="img/deep.svg" width="20" height="20"/><image href="img/a.svg" x="50" width="20" height="20"/>"#);
assert!(w.iter().any(|w| w.contains("nest more than")), "{w:?}");
let [(_, im)] = images(&d)[..] else { panic!("{:?} {w:?}", art(&d)) };
assert_eq!(Path::new(&im.link.as_ref().unwrap().path), dir.join("img").join("photo.png"));
}
const SVG_NS: &str = r#"xmlns="http://www.w3.org/2000/svg""#;
/// An image showing `svg`, from a base64 data URL of type `mime` (none when empty).
+1
View File
@@ -1802,6 +1802,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`).
- SVG import shows a raster `<image>` whose EXIF orientation or TIFF Orientation tag turns or flips it upright, linked or as a data URL, and inside an SVG shown as an image: the pixels are stored turned, the width and height swap, and the `<image>` box fits the turned image under `preserveAspectRatio`, as CSS `image-orientation: from-image` describes (<https://www.w3.org/TR/css-images-3/#the-image-orientation>). A turned CMYK JPEG or TIFF keeps its inks as a CMYK TIFF, as does a CMYK TIFF that is not turned. TIFF data URLs (`image/tiff`, or untyped TIFF bytes) are read, and TIFFs with an alpha or other extra channel, CCITT Group 3 TIFFs and CMYK TIFFs stored one plane per ink are read as Open and Place read them. The pixels are decoded within the decode allowance, and a TIFF claiming more strips or tiles than the decoder can count is refused before it is decoded (`crates/svg/tests/it/image_orientation.rs`).
- SVG import leaves out an SVG shown as an image that it can't read (text that isn't XML, an SVG without a size) and goes on with the images after it, as SVG 2's error processing describes (<https://www.w3.org/TR/SVG2/conform.html>). Only images that decode count toward the 256 nested SVG images an SVG may show, so an image after many broken ones is imported. Every nested SVG image read, decoded or not, counts toward VectorCraft's own limits of 1,024 reads and 512 MB of text per import; past them, nested SVG images are left out with a warning (`crates/svg/tests/it/import_details.rs`).
- SVG import reads the files that a linked SVG file's own `<image>` elements link to, relative paths from that file's folder (<https://www.rfc-editor.org/rfc/rfc3986#section-5.1.3>), as it reads the imported SVG's links: a raster stays linked, an SVG file imports as vector art, and one that can't be read gets the placeholder, its link and a warning. SVG 2 tells a viewer not to load these links (<https://www.w3.org/TR/SVG2/conform.html#secure-static-mode>); reading them is VectorCraft's own rule. The same href in SVG files of two folders names two files. A link that leads back to an SVG file showing it gets the placeholder and a warning, and reading the links of linked SVG files stops at the nesting depth cap and at 1,024 reads and 512 MB per import, the same limits decoded nested SVG images have, counted separately from them. The text of every linked SVG file read counts, and so does every file read for the links of a linked SVG file, raster or SVG; rasters the imported SVG links to don't. Once either limit is passed, no file linked from a linked SVG file is read and its image is left out with a warning, and an SVG file the imported SVG links to is still shown, without the files its own links name, with a warning. Another href to an SVG file already read, spelled differently or in another SVG file, does not read it again, unless a link back or the depth cap changes what it shows there (`crates/svg/tests/it/import_details.rs`, `crates/svg/src/import/files.rs`).
- File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`).
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- In the Linux Flatpak on GNOME, where the clipboard goes through the compositor's core Wayland protocol, one Copy offers the PNG (`image/png`), the SVG (`image/svg+xml`, with Include SVG Code on) and the text, and Paste takes another app's SVG, text or PNG in that order, a picture over blank text or text that only names it; the Paste menu item is enabled by the types offered (`clipboard::wayland` tests in `apps/vectorcraft/src/clipboard.rs`; not yet run in the Flatpak on a GNOME session).