mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
SVG import: linked rasters inside a linked SVG file are read
An SVG file that an <image> links to was parsed with usvg's from_data_nested, which drops every href to a file, so a PNG or JPEG that the SVG file linked to disappeared without a placeholder or a warning. The files a linked SVG file's own <image> elements link to are now read the way the imported SVG's links are, relative paths from that file's folder: a raster stays linked, an SVG file imports as vector art, and one that can't be read (missing, or refused by the reader) gets the placeholder stretched to its box, its link and a warning. 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. Reading the links of linked SVG files stops at the element depth cap and at 1,024 reads and 512 MB of text per import, the same limits decoded nested SVG images have, counted separately from them. An SVG data URL still reads no file links, having no folder of its own. SVG 2 tells a viewer to ignore external references in an SVG shown by <image>; reading them on import is VectorCraft's own rule.
This commit is contained in:
+175
-35
@@ -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).
|
||||
@@ -84,8 +91,42 @@ 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. An SVG
|
||||
/// file with a link inside it that led back to an SVG file isn't kept: what it shows depends
|
||||
/// on where it is.
|
||||
by_file: HashMap<String, Result<Source, &'static str>>,
|
||||
/// The SVG files whose links are being read, innermost last, by [`identity`].
|
||||
active: Vec<String>,
|
||||
/// A link led back to one of them while the file being read was read.
|
||||
led_back: bool,
|
||||
/// How many linked SVG files were read for their links, and the bytes of their text.
|
||||
svg_reads: usize,
|
||||
svg_text: u64,
|
||||
}
|
||||
|
||||
/// 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 +205,120 @@ 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();
|
||||
let mut reading =
|
||||
Reading { opts, warnings, placeholder: None, by_file: HashMap::new(), active: vec![], led_back: 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();
|
||||
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);
|
||||
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 cached = file.as_ref().and_then(|f| by_file.get(f)).cloned();
|
||||
let back = !reading.active.is_empty() && 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 source = match cached {
|
||||
_ if back => {
|
||||
reading.led_back = true;
|
||||
Err("leads back to an SVG file that shows it")
|
||||
}
|
||||
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"),
|
||||
let read = file.clone().and_then(|file| reading.opts.read.and_then(|read| read(&file)));
|
||||
let around = std::mem::replace(&mut reading.led_back, false);
|
||||
let source = match (read, file.as_deref()) {
|
||||
(Some((bytes, link)), Some(f)) => match self.source(bytes, link) {
|
||||
Some(Source::Svg(text)) => {
|
||||
let inside = *inside.get_or_insert_with(|| depth::height(xml, MAX_ELEMENT_DEPTH).unwrap_or(MAX_ELEMENT_DEPTH));
|
||||
// Past what may be read of the SVG files, the image is left out (noted).
|
||||
let Some(source) = self.svg_file(text, f, inside, reading) else {
|
||||
reading.led_back = around;
|
||||
continue;
|
||||
};
|
||||
Ok(source)
|
||||
}
|
||||
source => source.ok_or("is not an image VectorCraft reads"),
|
||||
},
|
||||
_ => Err("not found"),
|
||||
};
|
||||
if let Some(file) = file {
|
||||
by_file.insert(file, source.clone());
|
||||
// Kept unless a link inside it led back to an SVG file.
|
||||
let led_back = reading.led_back;
|
||||
reading.led_back |= around;
|
||||
if let Some(file) = file
|
||||
&& !led_back
|
||||
{
|
||||
reading.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 });
|
||||
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 };
|
||||
files.sources.insert(h.to_string(), source);
|
||||
sources.insert(h.to_string(), source);
|
||||
}
|
||||
if files.placeholder(h) {
|
||||
if sources.get(h).is_some_and(|s| self.missing(s)) {
|
||||
edits.set(svg, n, "preserveAspectRatio", "none");
|
||||
}
|
||||
}
|
||||
files
|
||||
sources
|
||||
}
|
||||
|
||||
/// 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. Its links aren't
|
||||
/// read when it nests too deep to be shown, or when it isn't XML. `None`, noted, once the SVG
|
||||
/// files read for their links pass [`MAX_NESTED_SVG_READS`] or [`MAX_NESTED_SVG_TEXT`].
|
||||
fn svg_file(&mut self, svg: Arc<SvgFile>, file: &str, inside: usize, reading: &mut Reading<'_, '_>) -> Option<Source> {
|
||||
let left = MAX_ELEMENT_DEPTH.saturating_sub(inside);
|
||||
if !svg.text.contains("image") || depth::xml_deeper_than(&svg.text, left) {
|
||||
return Some(Source::Svg(svg));
|
||||
}
|
||||
reading.svg_reads += 1;
|
||||
reading.svg_text = reading.svg_text.saturating_add(svg.text.len() as u64);
|
||||
if reading.svg_reads > MAX_NESTED_SVG_READS || reading.svg_text > MAX_NESTED_SVG_TEXT {
|
||||
self.too_much_svg_read.store(true, Ordering::Relaxed);
|
||||
return None;
|
||||
}
|
||||
// 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 Some(Source::Svg(svg)) };
|
||||
let Some(height) = depth::height(&xml, left) else { return Some(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());
|
||||
Some(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 +343,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`.
|
||||
@@ -308,9 +427,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 +439,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 +448,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 +465,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 +473,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 +485,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);
|
||||
|
||||
@@ -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)) });
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -216,6 +216,169 @@ 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:?}");
|
||||
}
|
||||
|
||||
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).
|
||||
|
||||
@@ -1785,6 +1785,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 of text per import, the same limits decoded nested SVG images have, counted separately from them (`crates/svg/tests/it/import_details.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).
|
||||
|
||||
Reference in New Issue
Block a user