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:
BFlat
2026-11-09 05:42:18 +08:00
committed by GitHub
5 changed files with 179 additions and 17 deletions
+60 -13
View File
@@ -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
+42 -3
View File
@@ -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
+19
View File
@@ -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]
+56
View File
@@ -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 `&#x56FD;`.
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("&#x56FD;"), "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
View File
@@ -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