mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Merge pull request #3676 from odgrim/fix/3562-svg-nested-image-depth
SVG import: images in images share the depth cap, SVGZ images one unpacking cap (#3562, #3563)
This commit is contained in:
@@ -3353,19 +3353,40 @@ fn deeply_nested_svg_is_refused_or_merged() {
|
||||
"<svg {SVG_NS} width=\"9\" height=\"9\"><defs>{links}<rect id=\"u3000\" width=\"1\" height=\"1\"/></defs><use href=\"#u0\"/></svg>"
|
||||
);
|
||||
assert!(open_deep("chain.svg", chain.as_bytes()).is_none());
|
||||
// SVG images in SVG images, read on the same stack: each too deep, of a type that is read
|
||||
// as SVG too (`text/plain`), or each within the cap but not all together (#3562). What is
|
||||
// past the cap is left out.
|
||||
let images = [nested_images(3, 1000), nested_images(12, most - 4)];
|
||||
for (i, file) in images.iter().enumerate() {
|
||||
let (_, warnings) = open_deep("images.svg", file.as_bytes()).expect("opens");
|
||||
assert!(warnings.iter().any(|w| w.contains("SVG images were left out")), "{i}: {warnings:?}");
|
||||
}
|
||||
// Placed and pasted, as opened.
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();
|
||||
for n in [40, 2 * most] {
|
||||
let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(nested_svg(n).as_bytes())});
|
||||
for svg in [nested_svg(40), nested_svg(2 * most)].iter().chain(&images) {
|
||||
let p = json!({"name": "deep.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())});
|
||||
let _ = no_panic(s.execute("file.place", &p));
|
||||
let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": nested_svg(n)})));
|
||||
let _ = no_panic(s.execute("clipboard.importSvg", &json!({"svg": svg})));
|
||||
let _ = no_panic(s.execute("edit.paste", &json!({})));
|
||||
assert!(!s.doc().unwrap().doc.too_deep(None));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// An SVG showing a chain of `levels` SVG images from `text/plain` data URLs, each `n` groups
|
||||
/// deep around the next.
|
||||
fn nested_images(levels: usize, n: usize) -> String {
|
||||
let image = |svg: &str| {
|
||||
format!("<image href=\"data:text/plain;base64,{}\" width=\"9\" height=\"9\"/>", vectorcraft_format::base64_encode(svg.as_bytes()))
|
||||
};
|
||||
let mut svg = nested_svg(0);
|
||||
for _ in 0..levels {
|
||||
svg = format!("<svg {SVG_NS} width=\"9\" height=\"9\">{}{}{}</svg>", "<g>".repeat(n), image(&svg), "</g>".repeat(n));
|
||||
}
|
||||
format!("<svg {SVG_NS} width=\"9\" height=\"9\">{}</svg>", image(&svg))
|
||||
}
|
||||
|
||||
/// A page whose content is `content`, with `resources`.
|
||||
fn deep_pdf(content: &str, resources: &str) -> Vec<u8> {
|
||||
handmade_pdf(content, resources, "[0 0 100 100]")
|
||||
|
||||
@@ -47,9 +47,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
return Err(too_deep());
|
||||
}
|
||||
let xml = parse(svg)?;
|
||||
if depth::too_deep(&xml) {
|
||||
let Some(height) = depth::height(&xml, depth::MAX_ELEMENT_DEPTH) else {
|
||||
return Err(too_deep());
|
||||
}
|
||||
};
|
||||
let (svg, found) = prepass(svg, &scan.markers, &xml, opts);
|
||||
// The prepass's edits are in the text: the XML is read again only when it made any.
|
||||
let xml = match &svg {
|
||||
@@ -66,7 +66,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
let opt = usvg::Options {
|
||||
dpi: units.dpi as f32,
|
||||
font_size: DEFAULT_FONT_SIZE as f32,
|
||||
image_href_resolver: files.resolver(),
|
||||
image_href_resolver: files.resolver(height),
|
||||
..usvg::Options::default()
|
||||
};
|
||||
usvg::Tree::from_str(&src, &opt).map_err(|e| SvgError::Parse(e.to_string()))?
|
||||
@@ -100,6 +100,9 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
if im.files.nested_text() {
|
||||
im.warn("text inside an SVG image isn't imported".into());
|
||||
}
|
||||
for note in im.files.left_out() {
|
||||
im.warn(note);
|
||||
}
|
||||
|
||||
// usvg wraps everything in an id-less group carrying the viewBox transform when needed.
|
||||
let mut top = tree.root();
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
//! refers to, and a clip path, mask, pattern, marker or filter is converted where it is used
|
||||
//! (`clip-path: url(#a)` on an element of a clip path makes a chain as deep as there are clip
|
||||
//! paths). So each element's depth is the deepest of what it holds and what it refers to.
|
||||
//!
|
||||
//! An SVG image (`<image>` of SVG data or an SVG file) is read while the SVG showing it is, on
|
||||
//! the same stack: the depths of the SVGs around it are added to its own, and an image past the
|
||||
//! cap is left out (`files`).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
@@ -88,10 +92,21 @@ pub(crate) fn xml_too_deep(svg: &str) -> bool {
|
||||
prescan(svg).too_deep
|
||||
}
|
||||
|
||||
/// The one pass: tags are counted as the text has them; markup in the values of a document
|
||||
/// type's entities counts ten times (how deep entities may refer to one another), on top of the
|
||||
/// deepest element. Past the cap the pass stops, and the markers are the whole text.
|
||||
/// Do the elements of the XML text `svg` nest deeper than `most`? See [`prescan_with`].
|
||||
pub(super) fn xml_deeper_than(svg: &str, most: usize) -> bool {
|
||||
prescan_with(svg, most).too_deep
|
||||
}
|
||||
|
||||
/// [`prescan_with`] against [`MAX_ELEMENT_DEPTH`].
|
||||
pub(crate) fn prescan(svg: &str) -> Prescan<'_> {
|
||||
prescan_with(svg, MAX_ELEMENT_DEPTH)
|
||||
}
|
||||
|
||||
/// The one pass, with elements allowed to nest `most` deep: tags are counted as the text has
|
||||
/// them; markup in the values of a document type's entities counts ten times (how deep entities
|
||||
/// may refer to one another), on top of the deepest element. Past the cap the pass stops, and
|
||||
/// the markers are the whole text.
|
||||
fn prescan_with(svg: &str, most: usize) -> Prescan<'_> {
|
||||
let b = svg.as_bytes();
|
||||
let find =
|
||||
|from: usize, what: &[u8]| b.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len());
|
||||
@@ -158,7 +173,7 @@ pub(crate) fn prescan(svg: &str) -> Prescan<'_> {
|
||||
depth.0 += usize::from(!empty);
|
||||
end
|
||||
};
|
||||
if elements.1 + 10 * entities.1 > MAX_ELEMENT_DEPTH {
|
||||
if elements.1 + 10 * entities.1 > most {
|
||||
return Prescan { too_deep: true, markers: Cow::Borrowed(svg) };
|
||||
}
|
||||
}
|
||||
@@ -178,9 +193,9 @@ pub(crate) fn prescan(svg: &str) -> Prescan<'_> {
|
||||
Prescan { too_deep: false, markers }
|
||||
}
|
||||
|
||||
/// Does anything in `xml` nest deeper than [`MAX_ELEMENT_DEPTH`], references followed? Without
|
||||
/// recursion.
|
||||
pub(super) fn too_deep(xml: &roxmltree::Document) -> bool {
|
||||
/// How deep the elements of `xml` nest, references followed (the root alone is 1); `None` past
|
||||
/// `most`. Without recursion.
|
||||
pub(super) fn height(xml: &roxmltree::Document, most: usize) -> Option<usize> {
|
||||
let elements = || xml.descendants().filter(|n| n.is_element());
|
||||
let ids: HashMap<&str, XNode> = elements().filter_map(|n| Some((n.attribute("id")?, n))).collect();
|
||||
let css = Styles::new(xml);
|
||||
@@ -210,14 +225,14 @@ pub(super) fn too_deep(xml: &roxmltree::Document) -> bool {
|
||||
let held = n.children().filter(|c| c.is_element()).map(of).max().unwrap_or(0);
|
||||
let used = |inherited: bool| refs.iter().filter(|(_, i)| *i == inherited).map(|(t, _)| of(*t)).max().unwrap_or(0);
|
||||
let height = 1 + held.max(used(false)) + used(true);
|
||||
if height > MAX_ELEMENT_DEPTH {
|
||||
return true;
|
||||
if height > most {
|
||||
return None;
|
||||
}
|
||||
heights.insert(n.id(), height);
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
heights.get(&xml.root_element().id()).copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -225,7 +240,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn deep(svg: &str) -> bool {
|
||||
too_deep(&roxmltree::Document::parse(svg).unwrap())
|
||||
height(&roxmltree::Document::parse(svg).unwrap(), MAX_ELEMENT_DEPTH).is_none()
|
||||
}
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
@@ -246,6 +261,15 @@ mod tests {
|
||||
assert!(xml_too_deep(&svg(&groups(200_000))), "any depth is measured");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_left_of_the_cap_is_measured_too() {
|
||||
let ten = svg(&groups(8));
|
||||
assert!(!xml_deeper_than(&ten, 10) && xml_deeper_than(&ten, 9));
|
||||
let xml = roxmltree::Document::parse(&ten).unwrap();
|
||||
assert_eq!((height(&xml, 10), height(&xml, 9)), (Some(10), None));
|
||||
assert_eq!(height(&roxmltree::Document::parse("<svg/>").unwrap(), 1), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_text_is_measured_as_xml_reads_it() {
|
||||
let many = r#"<g a='>' b="<g>"><rect/></g>"#.repeat(500);
|
||||
|
||||
+176
-43
@@ -7,13 +7,13 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
|
||||
use usvg::{ImageHrefResolver, ImageKind, roxmltree};
|
||||
use vectorcraft_doc::LinkInfo;
|
||||
|
||||
use super::{Edits, href};
|
||||
use crate::ImportOptions;
|
||||
use super::{Edits, depth, href};
|
||||
use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError};
|
||||
|
||||
/// The files an SVG's images link to.
|
||||
#[derive(Default)]
|
||||
@@ -24,13 +24,28 @@ pub(in crate::import) struct Files {
|
||||
links: HashMap<usize, Linked>,
|
||||
/// An SVG image had text (which isn't imported).
|
||||
text: AtomicBool,
|
||||
/// How deep the SVGs around the SVG image being read nest, added up: usvg reads an SVG image
|
||||
/// while it reads the SVG showing it, on the same stack, so one cap ([`MAX_ELEMENT_DEPTH`])
|
||||
/// holds for them all.
|
||||
depth: AtomicUsize,
|
||||
/// The bytes the SVGZ images (data and files) unpacked to so far, whether or not they
|
||||
/// decoded: together they unpack to at most what one SVGZ file may ([`MAX_SVGZ`]), however
|
||||
/// they nest.
|
||||
unpacked: AtomicU64,
|
||||
/// An SVG image was left out for nesting too deep in the SVGs around it.
|
||||
too_deep: AtomicBool,
|
||||
/// An SVGZ image was left out for unpacking past what the SVGZ images may.
|
||||
too_big: AtomicBool,
|
||||
}
|
||||
|
||||
/// How usvg holds the bytes of a raster of one format (`ImageKind::PNG`…).
|
||||
type RasterKind = fn(Arc<Vec<u8>>) -> ImageKind;
|
||||
|
||||
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>>),
|
||||
Raster(RasterKind, Arc<Vec<u8>>),
|
||||
/// An SVG file's text (unpacked when it is SVGZ), parsed by usvg as an image.
|
||||
Svg(String),
|
||||
}
|
||||
|
||||
/// A linked raster image (or the placeholder of a missing one).
|
||||
@@ -150,24 +165,18 @@ impl 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)));
|
||||
// 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(text.into_owned()));
|
||||
};
|
||||
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 Some(kind) = raster(format) else {
|
||||
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), image::ImageFormat::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 });
|
||||
@@ -192,41 +201,103 @@ impl Files {
|
||||
self.text.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn note_text(&self, svg: &[u8]) {
|
||||
if svg.windows(5).any(|w| w == b"<text") {
|
||||
/// The warnings of the SVG images left out.
|
||||
pub(in crate::import) fn left_out(&self) -> Vec<String> {
|
||||
let mut notes = vec![];
|
||||
if self.too_deep.load(Ordering::Relaxed) {
|
||||
notes.push(format!("SVG images were left out: with the SVGs around them, their elements nest more than {MAX_ELEMENT_DEPTH} deep"));
|
||||
}
|
||||
if self.too_big.load(Ordering::Relaxed) {
|
||||
notes.push(format!("SVGZ images were left out: together they unpack to more than {} MB", MAX_SVGZ >> 20));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
fn note_text(&self, svg: &str) {
|
||||
if svg.contains("<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();
|
||||
/// usvg's image resolver, for an SVG whose elements nest `height` deep: data URLs as usvg
|
||||
/// reads them, other hrefs only from the files read. SVG images are read here
|
||||
/// ([`Files::svg_data`], [`Files::svg`]), never by usvg's own resolver, which caps neither how
|
||||
/// deep they nest nor what they unpack to.
|
||||
pub(in crate::import) fn resolver(&self, height: usize) -> ImageHrefResolver<'_> {
|
||||
self.depth.store(height, Ordering::Relaxed);
|
||||
ImageHrefResolver {
|
||||
// By type as usvg reads them; one without a type (`text/plain`) by its bytes: a
|
||||
// raster usvg reads, else SVG.
|
||||
resolve_data: Box::new(move |mime, bytes, opts| {
|
||||
if mime == "image/svg+xml" {
|
||||
if nested_too_deep(&bytes) {
|
||||
return None;
|
||||
}
|
||||
self.note_text(&bytes);
|
||||
}
|
||||
data(mime, bytes, opts)
|
||||
let kind = match mime {
|
||||
"image/jpg" | "image/jpeg" => ImageKind::JPEG,
|
||||
"image/png" => ImageKind::PNG,
|
||||
"image/gif" => ImageKind::GIF,
|
||||
"image/webp" => ImageKind::WEBP,
|
||||
"image/svg+xml" => return self.svg_data(&bytes, opts),
|
||||
"text/plain" => match image::guess_format(&bytes).ok().and_then(raster) {
|
||||
Some(kind) => kind,
|
||||
None => return self.svg_data(&bytes, opts),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
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(bytes) if nested_too_deep(bytes) => None,
|
||||
Source::Svg(bytes) => {
|
||||
self.note_text(bytes);
|
||||
usvg::Tree::from_data_nested(bytes, opts).ok().map(ImageKind::SVG)
|
||||
}
|
||||
Source::Svg(text) => self.svg(text, opts),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SVG image of data `bytes` (SVG or SVGZ), as [`Files::svg`] reads it. Unpacked here,
|
||||
/// within what the SVGZ images have left, so usvg unpacks nothing itself; past it, `None`,
|
||||
/// 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),
|
||||
Err(e) => leave_out(&self.too_big, matches!(e, SvgError::TooBig(_))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The SVG image `text` as usvg reads it with `opts`. `None`, the image left out as one usvg
|
||||
/// 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.
|
||||
fn svg(&self, text: &str, 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);
|
||||
if depth::xml_deeper_than(text, left) {
|
||||
return leave_out(&self.too_deep, true);
|
||||
}
|
||||
let height = depth::height(&super::parse(text).ok()?, left);
|
||||
let Some(height) = height else { return leave_out(&self.too_deep, true) };
|
||||
self.note_text(text);
|
||||
self.depth.store(used + height, Ordering::Relaxed);
|
||||
let tree = usvg::Tree::from_data_nested(text.as_bytes(), opts);
|
||||
self.depth.store(used, Ordering::Relaxed);
|
||||
tree.ok().map(ImageKind::SVG)
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the SVG image `bytes` nested too deep for usvg to read (as the SVG holding it would be)?
|
||||
/// It is then left out, as an image usvg can't read.
|
||||
fn nested_too_deep(bytes: &[u8]) -> bool {
|
||||
crate::text_of(bytes).is_ok_and(|text| super::xml_too_deep(&text))
|
||||
/// How usvg holds a raster of `format`, for the formats it reads.
|
||||
fn raster(format: image::ImageFormat) -> Option<RasterKind> {
|
||||
use image::ImageFormat as F;
|
||||
match format {
|
||||
F::Png => Some(ImageKind::PNG),
|
||||
F::Jpeg => Some(ImageKind::JPEG),
|
||||
F::Gif => Some(ImageKind::GIF),
|
||||
F::WebP => Some(ImageKind::WEBP),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// No image: left out, noted in `why` when `noted`.
|
||||
fn leave_out(why: &AtomicBool, noted: bool) -> Option<ImageKind> {
|
||||
if noted {
|
||||
why.store(true, Ordering::Relaxed);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -305,6 +376,68 @@ mod tests {
|
||||
assert_eq!(percent_decoded("%E2%82%AC"), "€");
|
||||
}
|
||||
|
||||
/// SVGZ images unpack, all together, to at most what one SVGZ file may: past it, an image is
|
||||
/// left out and noted, where usvg unpacked it without a cap (#3563). Text that isn't packed
|
||||
/// isn't counted, and a broken SVGZ image is left out as one usvg can't read.
|
||||
#[test]
|
||||
fn svgz_images_share_the_unpacking_cap() {
|
||||
let inner = r#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="1" height="1"/></svg>"#;
|
||||
let (gz, plain) = (Arc::new(crate::compress(inner)), Arc::new(inner.as_bytes().to_vec()));
|
||||
let files = Files::default();
|
||||
let resolver = files.resolver(2);
|
||||
let opts = usvg::Options::default();
|
||||
let read = |mime: &str, bytes: &Arc<Vec<u8>>| matches!((resolver.resolve_data)(mime, bytes.clone(), &opts), Some(ImageKind::SVG(_)));
|
||||
assert!(read("image/svg+xml", &gz) && read("text/plain", &gz) && read("image/svg+xml", &plain));
|
||||
assert_eq!(files.unpacked.load(Ordering::Relaxed), 2 * inner.len() as u64);
|
||||
assert!(!read("image/svg+xml", &Arc::new(vec![0x1f, 0x8b, 8, 0, 1, 2])));
|
||||
assert!(files.left_out().is_empty(), "broken, not too big");
|
||||
// What they unpack to is counted, not the text it decodes to (UTF-16 is twice as long),
|
||||
// and counted when it doesn't decode.
|
||||
let counted = |bytes: &[u8], svg: bool| {
|
||||
let before = files.unpacked.load(Ordering::Relaxed);
|
||||
assert_eq!(read("image/svg+xml", &Arc::new(crate::compress_bytes(bytes))), svg);
|
||||
files.unpacked.load(Ordering::Relaxed) - before
|
||||
};
|
||||
let utf16: Vec<u8> = [0xff, 0xfe].into_iter().chain(inner.encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||||
assert_eq!(counted(&utf16, true), utf16.len() as u64);
|
||||
assert_eq!(counted(&[0xff; 100], false), 100);
|
||||
files.unpacked.store(MAX_SVGZ - inner.len() as u64 + 1, Ordering::Relaxed);
|
||||
assert!(!read("image/svg+xml", &gz) && !read("text/plain", &gz));
|
||||
assert!(read("image/svg+xml", &plain), "not packed");
|
||||
assert_eq!(files.left_out(), [format!("SVGZ images were left out: together they unpack to more than {} MB", MAX_SVGZ >> 20)]);
|
||||
}
|
||||
|
||||
/// A linked SVGZ file is unpacked once, when it is read, within the same cap.
|
||||
#[test]
|
||||
fn a_linked_svgz_file_unpacks_once() {
|
||||
let inner = r#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2"><rect width="1" height="1"/></svg>"#;
|
||||
let mut files = Files::default();
|
||||
let source = files.source(crate::compress(inner), LinkInfo::new("a.svgz")).unwrap();
|
||||
files.sources.insert("a.svgz".into(), source);
|
||||
assert_eq!(files.unpacked.load(Ordering::Relaxed), inner.len() as u64);
|
||||
let resolver = files.resolver(2);
|
||||
assert!(matches!((resolver.resolve_string)("a.svgz", &usvg::Options::default()), Some(ImageKind::SVG(_))));
|
||||
assert_eq!(files.unpacked.load(Ordering::Relaxed), inner.len() as u64);
|
||||
drop(resolver);
|
||||
files.unpacked.store(MAX_SVGZ, Ordering::Relaxed);
|
||||
assert!(files.source(crate::compress(inner), LinkInfo::new("b.svgz")).is_none(), "past the cap");
|
||||
}
|
||||
|
||||
/// An SVG image gets what the SVGs around it leave of the depth cap.
|
||||
#[test]
|
||||
fn svg_images_get_what_is_left_of_the_depth_cap() {
|
||||
let svg = |n: usize| format!(r#"<svg xmlns="http://www.w3.org/2000/svg">{}{}</svg>"#, "<g>".repeat(n), "</g>".repeat(n));
|
||||
let read = |around: usize, n: usize| {
|
||||
let files = Files::default();
|
||||
let resolver = files.resolver(around);
|
||||
let kind = (resolver.resolve_data)("image/svg+xml", Arc::new(svg(n).into_bytes()), &usvg::Options::default());
|
||||
(matches!(kind, Some(ImageKind::SVG(_))), files.left_out().len())
|
||||
};
|
||||
assert_eq!(read(1, MAX_ELEMENT_DEPTH - 2), (true, 0));
|
||||
assert_eq!(read(2, MAX_ELEMENT_DEPTH - 2), (false, 1));
|
||||
assert_eq!(read(MAX_ELEMENT_DEPTH, 0), (false, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_placeholder_is_a_png() {
|
||||
let png = marker().unwrap();
|
||||
|
||||
+18
-7
@@ -328,6 +328,9 @@ pub enum SvgError {
|
||||
/// Its elements nest (references followed) deeper than the reader goes.
|
||||
#[error("the SVG's elements are nested more than {0} deep")]
|
||||
TooDeep(usize),
|
||||
/// SVGZ data unpacks to more than the reader takes (a guard against decompression bombs).
|
||||
#[error("SVG parse error: {0}")]
|
||||
TooBig(String),
|
||||
}
|
||||
|
||||
/// Export a document as an SVG string (linked images are not returned: see [`export_full`]).
|
||||
@@ -435,25 +438,33 @@ pub fn compress_bytes(svg: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
|
||||
/// Most bytes an SVGZ file may unpack to (a guard against decompression bombs).
|
||||
const MAX_SVGZ: u64 = 512 << 20;
|
||||
pub(crate) const MAX_SVGZ: u64 = 512 << 20;
|
||||
|
||||
/// SVG text from SVG or SVGZ bytes: UTF-8, UTF-16 (after a byte order mark) or, when the XML
|
||||
/// declaration says so, ISO 8859-1.
|
||||
pub fn text_of(bytes: &[u8]) -> Result<std::borrow::Cow<'_, str>, SvgError> {
|
||||
text_within(bytes, &std::sync::atomic::AtomicU64::new(0))
|
||||
}
|
||||
|
||||
/// [`text_of`], SVGZ bytes unpacking within what `unpacked`, the bytes SVGZ data unpacked to so
|
||||
/// far, leaves of [`MAX_SVGZ`]. What they unpack to is added to it, whether or not it decodes.
|
||||
pub(crate) fn text_within<'a>(bytes: &'a [u8], unpacked: &std::sync::atomic::AtomicU64) -> Result<std::borrow::Cow<'a, str>, SvgError> {
|
||||
use std::io::Read as _;
|
||||
use std::sync::atomic::Ordering;
|
||||
if !is_svgz(bytes) {
|
||||
return match decode(bytes)? {
|
||||
Decoded::Utf8(skip) => std::str::from_utf8(bytes.get(skip..).unwrap_or_default()).map(Into::into).map_err(|_| not_utf8()),
|
||||
Decoded::Text(t) => Ok(t.into()),
|
||||
};
|
||||
}
|
||||
let most = MAX_SVGZ.saturating_sub(unpacked.load(Ordering::Relaxed));
|
||||
let mut raw = Vec::new();
|
||||
flate2::read::GzDecoder::new(bytes)
|
||||
.take(MAX_SVGZ + 1)
|
||||
.read_to_end(&mut raw)
|
||||
.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
|
||||
if raw.len() as u64 > MAX_SVGZ {
|
||||
return Err(SvgError::Parse(format!("the SVGZ file unpacks to more than {} MB", MAX_SVGZ >> 20)));
|
||||
let read = flate2::read::GzDecoder::new(bytes).take(most.saturating_add(1)).read_to_end(&mut raw);
|
||||
// Read before a failure is kept: it was unpacked too.
|
||||
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
|
||||
read.map_err(|e| SvgError::Parse(format!("not a valid SVGZ file: {e}")))?;
|
||||
if raw.len() as u64 > most {
|
||||
return Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)));
|
||||
}
|
||||
match decode(&raw)? {
|
||||
Decoded::Utf8(skip) => {
|
||||
|
||||
@@ -126,7 +126,7 @@ fn svg_images_import_as_vector_art() {
|
||||
assert!((boxes[1].x0 - 100.0).abs() < 1e-6 && (boxes[1].height() - 20.0).abs() < 1e-6, "{boxes:?}");
|
||||
}
|
||||
|
||||
/// usvg unpacks gzipped SVG images itself (its `svgz` feature, which the workspace keeps).
|
||||
/// Gzipped SVG images are unpacked before usvg reads them, within the cap of an SVGZ file.
|
||||
#[test]
|
||||
fn svgz_images_import_as_vector_art() {
|
||||
let dir = folder("svgzimage");
|
||||
@@ -147,6 +147,58 @@ 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:?}");
|
||||
}
|
||||
|
||||
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).
|
||||
fn image_of(mime: &str, svg: &str) -> String {
|
||||
format!(r#"<image href="data:{mime};base64,{}" width="20" height="10"/>"#, vectorcraft_svg::base64_encode(svg.as_bytes()))
|
||||
}
|
||||
|
||||
/// An SVG showing a chain of `levels` SVG images from data URLs of type `mime`: each holds a
|
||||
/// rectangle in `n` groups in one another, and the next image beside it.
|
||||
fn nested_images(levels: usize, n: usize, mime: &str) -> String {
|
||||
let mut inner = String::new();
|
||||
for _ in 0..levels {
|
||||
let (open, close) = ("<g>".repeat(n), "</g>".repeat(n));
|
||||
let svg = format!(r#"<svg {SVG_NS} width="20" height="10">{open}<rect width="10" height="10"/>{inner}{close}</svg>"#);
|
||||
inner = image_of(mime, &svg);
|
||||
}
|
||||
format!(r#"<svg {SVG_NS} width="20" height="10">{inner}</svg>"#)
|
||||
}
|
||||
|
||||
fn rects(d: &Document) -> usize {
|
||||
art(d).iter().filter(|n| n.path_data().is_some()).count()
|
||||
}
|
||||
|
||||
/// usvg reads an SVG image while it reads the SVG showing it, on the same stack, so images in
|
||||
/// images share one depth cap, whatever type their data URL has (usvg reads SVG from
|
||||
/// `image/svg+xml`, `text/plain` and untyped data): what is past it is left out, with a warning,
|
||||
/// where it overflowed the stack, an abort (#3562).
|
||||
#[test]
|
||||
fn svg_images_in_svg_images_share_the_depth_cap() {
|
||||
let most = vectorcraft_svg::MAX_ELEMENT_DEPTH;
|
||||
let left_out = |w: &[String]| w.iter().any(|w| w.contains("SVG images were left out"));
|
||||
for mime in ["image/svg+xml", "text/plain", ""] {
|
||||
// Within the cap together: all of it.
|
||||
let (d, w) = import_with_report(&nested_images(3, 20, mime)).unwrap();
|
||||
assert_eq!(rects(&d), 3, "{mime}");
|
||||
assert!(!left_out(&w), "{mime}: {w:?}");
|
||||
// Each image past the cap; each within it, but not together (the file, the image, then
|
||||
// the groups and the rectangle in each).
|
||||
for (levels, n, kept) in [(3, 1000, 0), (12, most - 4, 1)] {
|
||||
let (d, w) = import_with_report(&nested_images(levels, n, mime)).unwrap();
|
||||
assert_eq!(rects(&d), kept, "{mime}: {levels} × {n}");
|
||||
assert!(left_out(&w), "{mime}: {levels} × {n}: {w:?}");
|
||||
}
|
||||
}
|
||||
// Shallow XML, a deep tree: a chain of `<use>`, measured as in the file itself.
|
||||
let links: String = (0..3000).map(|i| format!("<g id='u{i}'><use href='#u{}'/></g>", i + 1)).collect();
|
||||
let chain = format!(r##"<svg {SVG_NS} width="9" height="9"><defs>{links}<rect id="u3000" width="1" height="1"/></defs><use href="#u0"/></svg>"##);
|
||||
let (d, w) = import_with_report(&svg(&image_of("image/svg+xml", &chain))).unwrap();
|
||||
assert_eq!(rects(&d), 0);
|
||||
assert!(left_out(&w), "{w:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_clips_intersect() {
|
||||
let body = r##"<defs><clipPath id="inner"><rect x="60" y="0" width="200" height="200"/></clipPath>
|
||||
|
||||
+7
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user