mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:12:11 +00:00
Merge pull request #6256 from dotCooCoo/fix/svg-filter-export-size-limit
SVG Filters: export writes a filter live only when import can read it back
This commit is contained in:
@@ -57,16 +57,27 @@ fn numbers(v: &str) -> Option<Vec<f64>> {
|
||||
/// Filter `xml` as it is drawn and written ([`rebuilt`]) when it passes [`check`], `None` when it
|
||||
/// doesn't. Remembered for the filters seen last, so drawing does not check a filter again each time.
|
||||
fn checked(xml: &str) -> Option<Arc<str>> {
|
||||
use std::hash::BuildHasher;
|
||||
type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize);
|
||||
static SEEN: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new();
|
||||
let seen = SEEN.get_or_init(Default::default);
|
||||
remembered(&SEEN, xml, |xml| checked_form(xml).ok().map(|(_, form)| Arc::from(form)))
|
||||
}
|
||||
|
||||
/// The results [`remembered`] keeps: the hash's seed, the results by key, and their size in bytes.
|
||||
type Seen = (std::collections::hash_map::RandomState, std::collections::HashMap<(u64, usize), Option<Arc<str>>>, usize);
|
||||
|
||||
/// `make(xml)`, remembered in `seen` for the filters seen last.
|
||||
fn remembered(
|
||||
seen: &'static std::sync::OnceLock<std::sync::Mutex<Seen>>,
|
||||
xml: &str,
|
||||
make: impl FnOnce(&str) -> Option<Arc<str>>,
|
||||
) -> Option<Arc<str>> {
|
||||
use std::hash::BuildHasher;
|
||||
let seen = seen.get_or_init(Default::default);
|
||||
// Keyed by a hash that a file can't aim at (randomly seeded) and the length.
|
||||
let key = seen.lock().ok().map(|s| (s.0.hash_one(xml), xml.len()));
|
||||
if let Some(hit) = key.and_then(|k| seen.lock().ok().and_then(|s| s.1.get(&k).cloned())) {
|
||||
return hit;
|
||||
}
|
||||
let form: Option<Arc<str>> = checked_form(xml).ok().map(|(_, form)| Arc::from(form));
|
||||
let form = make(xml);
|
||||
if let (Some(k), Ok(mut s)) = (key, seen.lock())
|
||||
&& !s.1.contains_key(&k)
|
||||
{
|
||||
@@ -83,7 +94,7 @@ fn checked(xml: &str) -> Option<Arc<str>> {
|
||||
form
|
||||
}
|
||||
|
||||
/// The most check results [`checked`] keeps, and the most bytes of filters it keeps.
|
||||
/// The most results [`remembered`] keeps in one place, and the most bytes of filters it keeps there.
|
||||
const MAX_CHECKED: usize = 65_536;
|
||||
const MAX_CHECKED_BYTES: usize = 32 << 20;
|
||||
|
||||
@@ -279,9 +290,30 @@ const LATER_BLEND_MODES: [&str; 11] =
|
||||
/// The `feComposite` operators of SVG 1.1.
|
||||
const SVG11_OPERATORS: [&str; 6] = ["over", "in", "out", "atop", "xor", "arithmetic"];
|
||||
|
||||
/// The longest form SVG output writes ([`svg11`]), leaving room within [`MAX_DRAWN_XML`] for the
|
||||
/// region and id written into it.
|
||||
const MAX_WRITTEN_XML: usize = MAX_DRAWN_XML - 1024;
|
||||
/// The longest id SVG output gives a filter it writes: a unique-id prefix of up to 13 characters,
|
||||
/// `filter-` and a counter of up to 20 digits, with room to spare.
|
||||
const WRITTEN_ID_ROOM: usize = 64;
|
||||
|
||||
/// The longest text `{}` writes for a finite `f64`: a sign, `0.`, 307 zeros and 17 digits, as for
|
||||
/// `-f64::MIN_POSITIVE`.
|
||||
const MAX_NUMBER_TEXT: usize = 327;
|
||||
|
||||
/// The most the four region attributes written into a filter ([`SvgFilterFx::written_xml`]) add to
|
||||
/// it: each of `x`, `y`, `width` and `height` as ` height="…"` with the longest number.
|
||||
const REGION_ROOM: usize = 4 * (" height=\"\"".len() + MAX_NUMBER_TEXT);
|
||||
|
||||
/// The longest form SVG output writes ([`svg11`]): room is left within [`SvgFilter::MAX_XML`], the
|
||||
/// longest filter SVG import reads, for the id written into it. Measured with [`written_len`].
|
||||
const MAX_WRITTEN_XML: usize = SvgFilter::MAX_XML - WRITTEN_ID_ROOM;
|
||||
|
||||
/// How long `s` is as SVG output can write it and import reads it back: SVG output with the Latin-1
|
||||
/// encoding writes each character past U+00FF as `&#x…;`, which import keeps as text, so the
|
||||
/// longer of the two lengths is counted for each such character.
|
||||
fn written_len(s: &str) -> usize {
|
||||
// `&#x`, one hex digit per four significant bits, and `;`.
|
||||
let reference = |c: u32| 4 + ((u32::BITS - c.leading_zeros()) as usize).div_ceil(4);
|
||||
s.chars().map(|c| if u32::from(c) < 0x100 { c.len_utf8() } else { reference(u32::from(c)) }).sum()
|
||||
}
|
||||
|
||||
/// Filter `xml` (a rebuilt one, [`rebuilt`]) as SVG 1.1 output writes it, in the form an SVG 1.1
|
||||
/// viewer draws as resvg draws `xml`:
|
||||
@@ -413,7 +445,7 @@ fn svg11(xml: &str) -> Option<String> {
|
||||
}
|
||||
out.push_str("</filter>");
|
||||
}
|
||||
(out.len() <= MAX_WRITTEN_XML).then_some(out)
|
||||
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
|
||||
}
|
||||
|
||||
/// The result names of a filter's SVG 1.1 form ([`svg11`]): the names its elements use, and the
|
||||
@@ -1278,13 +1310,28 @@ impl SvgFilterFx {
|
||||
/// whose box in the document is `bounds`: in its SVG 1.1 form ([`svg11`]), `None` when SVG 1.1
|
||||
/// can't hold it ([`Self::writes_as_svg11`]).
|
||||
pub fn written_xml(&self, bounds: Rect) -> Option<String> {
|
||||
self.with_region(&svg11(&self.xml)?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))
|
||||
let out = self.with_region(&self.svg11_form()?, (bounds - self.origin).scale_from_origin(1.0 / self.scale))?;
|
||||
(written_len(&out) <= MAX_WRITTEN_XML).then_some(out)
|
||||
}
|
||||
|
||||
/// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`])? When it can't, SVG
|
||||
/// output treats it as it treats the other pixel effects.
|
||||
/// Can SVG 1.1 output write the filter as a `<filter>` element ([`svg11`]) that SVG import
|
||||
/// reads back? When it can't, SVG output treats it as it treats the other pixel effects.
|
||||
pub fn writes_as_svg11(&self) -> bool {
|
||||
svg11(&self.xml).is_some()
|
||||
self.svg11_form().is_some()
|
||||
}
|
||||
|
||||
/// The filter's SVG 1.1 form ([`svg11`]) when, with any region and id that SVG output writes
|
||||
/// into it, it is a filter SVG import reads ([`check`]): at most [`SvgFilter::MAX_XML`] bytes,
|
||||
/// [`MAX_PRIMITIVES`] primitives and [`MAX_DEPTH`] levels deep, and drawn. Remembered for the
|
||||
/// filters seen last, so export does not check a filter again for each object.
|
||||
fn svg11_form(&self) -> Option<Arc<str>> {
|
||||
static FORMS: std::sync::OnceLock<std::sync::Mutex<Seen>> = std::sync::OnceLock::new();
|
||||
// `measured` follows from the XML, so the XML is the whole key.
|
||||
let region = if self.measured { REGION_ROOM } else { 0 };
|
||||
remembered(&FORMS, &self.xml, |xml| {
|
||||
let form = svg11(xml)?;
|
||||
(written_len(&form) + region <= MAX_WRITTEN_XML && check(&form).is_ok()).then(|| Arc::from(form))
|
||||
})
|
||||
}
|
||||
|
||||
/// How finely the filter runs on a raster whose pixels are `space.px` points wide, for an
|
||||
|
||||
@@ -457,9 +457,48 @@ fn a_filter_at_the_size_limit_draws_with_its_measured_region() {
|
||||
assert_eq!(xml.len(), SvgFilter::MAX_XML);
|
||||
assert_eq!(check(&xml), Ok("r".into()));
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
|
||||
assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len());
|
||||
assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read");
|
||||
let drawn = f.drawn_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
|
||||
assert!(drawn.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", drawn.len());
|
||||
assert!(with_id(&drawn, DRAWN_ID).is_some(), "the drawn form is read");
|
||||
}
|
||||
|
||||
/// SVG output writes a filter live only when the form it writes, with its region and id, is one
|
||||
/// SVG import reads back: at most [`SvgFilter::MAX_XML`] bytes. A filter at the limit whose region
|
||||
/// is measured against the box, one at the limit whose id is a character long, and one whose
|
||||
/// shadows make its SVG 1.1 form longer are left to be written as other pixel effects are.
|
||||
#[test]
|
||||
fn svg_output_writes_a_filter_only_in_a_form_import_reads() {
|
||||
let at_limit = |open: &str, close: &str| {
|
||||
let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
assert_eq!((xml.len(), check(&xml).is_ok()), (SvgFilter::MAX_XML, true));
|
||||
filter(json!({ "xml": xml }))
|
||||
};
|
||||
let measured = at_limit(
|
||||
r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#,
|
||||
r#""/><feOffset/></filter>"#,
|
||||
);
|
||||
let short_id = at_limit(r#"<filter id="r"><feOffset result=""#, r#""/><feOffset/></filter>"#);
|
||||
let shadows = format!(r#"<filter id="s">{}<feOffset result="{}"/></filter>"#, "<feDropShadow/>".repeat(12), "a".repeat(SvgFilter::MAX_XML - 400));
|
||||
assert!(check(&shadows).is_ok() && shadows.len() <= SvgFilter::MAX_XML);
|
||||
for f in [measured, short_id, filter(json!({ "xml": shadows }))] {
|
||||
assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{} bytes", f.xml().len());
|
||||
}
|
||||
// Room for the id and the longest region: still written, and read back.
|
||||
let open = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#;
|
||||
let close = r#""/><feOffset/></filter>"#;
|
||||
let xml = format!("{open}{}{close}", "a".repeat(MAX_WRITTEN_XML - REGION_ROOM - open.len() - close.len()));
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
assert!(f.writes_as_svg11());
|
||||
let id = format!("u_vc_unique_-filter-{}", usize::MAX);
|
||||
let out = with_id(&f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap(), &id).unwrap();
|
||||
assert!(out.len() <= SvgFilter::MAX_XML && check(&out).is_ok(), "{} bytes", out.len());
|
||||
// The longest numbers a region can take still fit.
|
||||
let far = f.written_xml(Rect::new(-f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::MAX / 4.0, f64::MAX / 4.0)).unwrap();
|
||||
assert!(with_id(&far, &id).is_some_and(|out| out.len() <= SvgFilter::MAX_XML));
|
||||
for v in [-f64::MIN_POSITIVE, -5e-324, -2.225073858507201e-308, f64::MIN, -1.0 / 3.0] {
|
||||
assert!(format!("{v}").len() <= MAX_NUMBER_TEXT, "{v:e}");
|
||||
}
|
||||
assert_eq!(format!("{}", -f64::MIN_POSITIVE).len(), MAX_NUMBER_TEXT);
|
||||
}
|
||||
|
||||
/// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach
|
||||
|
||||
@@ -461,6 +461,25 @@ fn svg_export_writes_the_filter_when_it_is_the_last_effect() {
|
||||
assert!(!out.contains("<feOffset dx=\"3\""), "{out}");
|
||||
}
|
||||
|
||||
/// A filter at the size limit whose written form, with the id SVG export gives it, would be longer
|
||||
/// than SVG import reads is exported as an image, which opens again as the canvas draws it.
|
||||
#[test]
|
||||
fn svg_export_draws_a_filter_too_long_to_read_back_as_an_image() {
|
||||
let (mut s, id) = blue_square();
|
||||
let (open, close) =
|
||||
(r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##, r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#);
|
||||
let xml = format!("{open}{}{close}", "a".repeat(vectorcraft_doc::SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
run(&mut s, "effect.apply", json!({"effect": "svg.filter", "ids": [id.0], "params": {"xml": xml}}));
|
||||
assert_eq!(&render(&s).pixel(100, 100)[..3], &[255, 0, 0]);
|
||||
let out = svg(&mut s);
|
||||
assert!(out.contains("<image") && !out.contains("<filter"), "{} bytes", out.len());
|
||||
let (back, warnings) = vectorcraft_svg::import_with_report(&out).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let again = vectorcraft_render::Renderer::new().render_region(&back, vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
let px = again.pixel(100, 100);
|
||||
assert!(px[0] > 240 && px[1] < 15 && px[2] < 15, "{px:?}");
|
||||
}
|
||||
|
||||
/// A filter SVG 1.1 can't hold (a blend mode SVG 1.1 lacks) is exported as an image, as the canvas
|
||||
/// draws it; an SVG 1.1 one stays a filter.
|
||||
#[test]
|
||||
|
||||
@@ -657,6 +657,62 @@ fn an_exported_filter_image_has_an_xlink_reference() {
|
||||
}
|
||||
}
|
||||
|
||||
/// SVG export writes a filter only in a form SVG import reads back. A filter at the size limit,
|
||||
/// whose id or region written out would make it longer, is left out of the SVG with the other pixel
|
||||
/// effects (exports that turn pixel effects into images draw it as an image), and the SVG opens
|
||||
/// without a filter that import can't read.
|
||||
#[test]
|
||||
fn an_exported_filter_is_one_import_reads_back() {
|
||||
let max = vectorcraft_doc::SvgFilter::MAX_XML;
|
||||
let close = r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#;
|
||||
let short_id = r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##;
|
||||
let measured =
|
||||
r##"<filter id="r" filterUnits="userSpaceOnUse" x="-10%" y="-10%" width="120%" height="120%"><feFlood flood-color="#ff0000" result=""##;
|
||||
for open in [short_id, measured] {
|
||||
let xml = format!("{open}{}{close}", "a".repeat(max - open.len() - close.len()));
|
||||
let d = filtered_rect(&xml);
|
||||
assert_eq!(effects(&d).len(), 1);
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(!svg.contains("<filter"), "{open}");
|
||||
let (_, w) = import_with_report(&svg).unwrap();
|
||||
assert!(w.is_empty(), "{open}: {w:?}");
|
||||
}
|
||||
// A shorter one is written and read back.
|
||||
let xml = format!("{short_id}{}{close}", "a".repeat(max - 64 - short_id.len() - close.len()));
|
||||
let svg = export(&filtered_rect(&xml), &ExportOptions::default());
|
||||
let (back, w) = import_with_report(&svg).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(effects(&back).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["svg.filter"]);
|
||||
}
|
||||
|
||||
/// With the Latin-1 encoding, export writes each character past U+00FF as a character reference,
|
||||
/// which import counts as text. A filter is measured with those references in either encoding: one
|
||||
/// that passes the limit with them is left out of both, and one that fits with them is written live
|
||||
/// in Latin-1 and read back.
|
||||
#[test]
|
||||
fn an_exported_filter_counts_the_references_latin1_writes() {
|
||||
let open = r##"<filter id="r"><feFlood flood-color="#ff0000" result=""##;
|
||||
let close = r#""/><feComposite in2="SourceGraphic" operator="in"/></filter>"#;
|
||||
let max = vectorcraft_doc::SvgFilter::MAX_XML;
|
||||
let latin1 = ExportOptions { encoding: vectorcraft_svg::Encoding::Latin1, ..ExportOptions::default() };
|
||||
// `国` takes three bytes as UTF-8 and eight as `国`.
|
||||
let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max / 2 - open.len() - close.len()) / 3));
|
||||
let d = filtered_rect(&xml);
|
||||
assert_eq!(effects(&d).len(), 1);
|
||||
assert!(!export(&d, &ExportOptions::default()).contains("<filter"), "its references pass the limit");
|
||||
let svg = export(&d, &latin1);
|
||||
assert!(!svg.contains("<filter"), "its references pass the limit");
|
||||
let (_, w) = import_with_report(&svg).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
// One that fits with its references.
|
||||
let xml = format!("{open}{}{close}", "\u{56FD}".repeat((max - 64 - open.len() - close.len()) / 8));
|
||||
let svg = export(&filtered_rect(&xml), &latin1);
|
||||
assert!(svg.contains("国"), "the references are written");
|
||||
let (back, w) = import_with_report(&svg).unwrap();
|
||||
assert!(w.is_empty(), "{w:?}");
|
||||
assert_eq!(effects(&back).iter().map(|e| e.id.as_str()).collect::<Vec<_>>(), ["svg.filter"]);
|
||||
}
|
||||
|
||||
/// SVG 1.1 has no `feDropShadow`: export writes the SVG 1.1 primitives it stands for, which draw
|
||||
/// as the canvas draws the shadow and import back as a filter that draws the same.
|
||||
#[test]
|
||||
|
||||
+2
-1
@@ -3230,7 +3230,8 @@ names no earlier result as the previous primitive's result;
|
||||
and an `feComposite` operator SVG 1.1 lacks, or `edgeMode` on a primitive other than `feConvolveMatrix`, left out, as
|
||||
the canvas draws them. A filter with an `feBlend` mode SVG 1.1 lacks (`overlay` and the others), an `feDropShadow`
|
||||
whose `flood-color` is `currentColor` or with `primitiveUnits="objectBoundingBox"`, or more than 64 primitives once
|
||||
its shadows are expanded, has no SVG 1.1 form. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object
|
||||
its shadows are expanded, has no SVG 1.1 form. Neither does a filter whose written form, with its region and id, is
|
||||
one SVG import would not read, such as one longer than 64 KB. In that case, otherwise, and with the SVG Tiny 1.2 profile, the object
|
||||
becomes an image. SVG import keeps a filter that the live effects (Drop Shadow, the
|
||||
glows, Gaussian Blur, Feather) can't express as an `svg.filter` effect holding the filter's XML, with `scale` and
|
||||
`origin` from the object's user space, unless that user space is turned, skewed or scaled past 0.001 to 1000 points
|
||||
|
||||
Reference in New Issue
Block a user