mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-12 21:32:52 +00:00
SVG: support static pattern fills and strokes (#2732)
* fix(svg): support static SVG patterns * fix(svg): cache patterns per viewport * fix(svg): share cached pattern content * fix(svg): harden pattern cache keys * fix(svg): harden pattern inheritance and caching * Preserve SVG pattern fallback paint * Format SVG pattern fallback conversion * Harden SVG pattern resource bounds * Fix SVG pattern test formatting * fix(svg): bound hostile path and raster work * Bound SVG ellipse expansion * fix(ui-egui): suppress clippy warnings on wasm target Rename unused `frame` parameter to `_frame` in draw_inner since pointer_wrap::finish_native is cfg'd out on wasm32. Guard State::disabled field with #[cfg(any(not(target_arch = "wasm32"), test))] so it only exists where it's read (finish_frame on native + tests). * fix: resolve clippy warnings blocking CI - Rename unused `frame` parameter to `_frame` in draw_inner (wasm target) - Guard State::disabled field with cfg(native + test) (wasm target) - Replace chunks_exact(2) with as_chunks::<2>() in SVG polyline/polygon parsing * fix(svg): budget raster work by affected region and report refusals Address review on #2732: the finite-work guards charged the whole canvas, dropping valid sparse art (8x8 image scaled to 1px on 2049x2049, 2x2 rect under a 1,028-element clip on 512x512, one of six 2x2 rects sharing a 1x1 pattern on 3840x2160). - Build fill/stroke/image/clip coverage over the device region only and charge that region; remaining whole-canvas passes are charged to a render-wide pixel budget (caps are not raised). - Add try_rasterize/try_rasterize_with returning RasterError; rasterize keeps its signature. media layered decode now surfaces refusals. - Pattern content reached through href inherits styles from the owning pattern; device_region falls back to the full canvas on non-finite input. - Document new items and the limits in the svg README; regression tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fbfeed2974
commit
4035d18132
13 files changed
+2486
-232
No files matched your search
@@ -8,6 +8,7 @@ use effectcraft_project::{Comp, ItemId, Layer, LayerSource, MaskMode, Project, P
|
||||
use effectcraft_svg::{Affine, Doc, Geom, GradientKind, Node, Paint};
|
||||
use kurbo::{BezPath, Point};
|
||||
|
||||
/// Convert a kurbo path into shape path property groups.
|
||||
fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
|
||||
effectcraft_path::from_kurbo(path)
|
||||
.into_iter()
|
||||
@@ -20,6 +21,7 @@ fn shape_paths(ids: &mut Ids, path: &BezPath) -> Vec<PropGroup> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Uniform scale factor of a transform (square root of its determinant magnitude).
|
||||
fn det_scale(m: Affine) -> f64 {
|
||||
let c = m.as_coeffs();
|
||||
(c[0] * c[3] - c[1] * c[2]).abs().sqrt()
|
||||
@@ -31,6 +33,7 @@ fn axis_aligned(m: Affine) -> Option<(f64, f64, f64, f64)> {
|
||||
(c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && c[0] > 0.0 && c[3] > 0.0).then_some((c[0], c[3], c[4], c[5]))
|
||||
}
|
||||
|
||||
/// Convert an SVG gradient into the engine's gradient value.
|
||||
fn gradient_value(g: &effectcraft_svg::Gradient) -> Gradient {
|
||||
Gradient {
|
||||
colors: g.stops.iter().map(|(o, c)| (*o, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0])).collect(),
|
||||
@@ -52,6 +55,7 @@ fn gradient_points(g: &effectcraft_svg::Gradient, m: Affine, bbox: kurbo::Rect)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert one SVG shape under transform `m` into shape-layer items.
|
||||
fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<PropGroup> {
|
||||
use kurbo::Shape as _;
|
||||
let mut items = vec![];
|
||||
@@ -72,48 +76,60 @@ fn shape_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine) -> Vec<Prop
|
||||
let bbox = s.geom.to_path().bounding_box();
|
||||
if let Some(st) = &s.stroke {
|
||||
let width = st.width * det_scale(m);
|
||||
let mut g = match &st.paint {
|
||||
Paint::Color(c) => build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width),
|
||||
let paint = match &st.paint {
|
||||
Paint::Color(c) => Some(build::shape_stroke(ids, [c[0], c[1], c[2], 1.0], width)),
|
||||
Paint::Gradient(gr) => {
|
||||
let (radial, a, b) = gradient_points(gr, m, bbox);
|
||||
build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width)
|
||||
Some(build::shape_gradient_stroke(ids, radial, a, b, gradient_value(gr), width))
|
||||
}
|
||||
// Editable shape layers do not yet have a pattern paint operator. Keep the geometry
|
||||
// and any retained fallback as solid paint; SVG footage retains the rasterised pattern.
|
||||
Paint::Pattern(pattern) => pattern.fallback.map(|c| build::shape_stroke(ids, [c[0], c[1], c[2], c[3]], width)),
|
||||
};
|
||||
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
|
||||
set(&mut g, "cap", Value::Enum(st.cap as u32));
|
||||
set(&mut g, "join", Value::Enum(st.join as u32));
|
||||
set(&mut g, "miter", Value::Scalar(st.miter));
|
||||
if let Some((d, off)) = &st.dash
|
||||
&& let Some(dg) = g.sub_mut("dashes")
|
||||
{
|
||||
let k = det_scale(m);
|
||||
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
|
||||
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
|
||||
set(dg, "offset", Value::Scalar(off * k));
|
||||
if let Some(mut g) = paint {
|
||||
set(&mut g, "opacity", Value::Scalar(st.opacity * 100.0));
|
||||
set(&mut g, "cap", Value::Enum(st.cap as u32));
|
||||
set(&mut g, "join", Value::Enum(st.join as u32));
|
||||
set(&mut g, "miter", Value::Scalar(st.miter));
|
||||
if let Some((d, off)) = &st.dash
|
||||
&& let Some(dg) = g.sub_mut("dashes")
|
||||
{
|
||||
let k = det_scale(m);
|
||||
set(dg, "dash", Value::Scalar(d.first().copied().unwrap_or(0.0) * k));
|
||||
set(dg, "gap", Value::Scalar(d.get(1).or(d.first()).copied().unwrap_or(0.0) * k));
|
||||
set(dg, "offset", Value::Scalar(off * k));
|
||||
}
|
||||
items.push(g);
|
||||
}
|
||||
items.push(g);
|
||||
}
|
||||
if let Some(f) = &s.fill {
|
||||
let mut g = match f {
|
||||
Paint::Color(c) => build::shape_fill(ids, [c[0], c[1], c[2], 1.0]),
|
||||
let paint = match f {
|
||||
Paint::Color(c) => Some(build::shape_fill(ids, [c[0], c[1], c[2], 1.0])),
|
||||
Paint::Gradient(gr) => {
|
||||
let (radial, a, b) = gradient_points(gr, m, bbox);
|
||||
build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr))
|
||||
Some(build::shape_gradient_fill(ids, radial, a, b, gradient_value(gr)))
|
||||
}
|
||||
// Pattern artwork is preserved in rasterised SVG footage; editable shapes keep only
|
||||
// any retained fallback as solid paint.
|
||||
Paint::Pattern(pattern) => pattern.fallback.map(|c| build::shape_fill(ids, [c[0], c[1], c[2], c[3]])),
|
||||
};
|
||||
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
|
||||
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
|
||||
items.push(g);
|
||||
if let Some(mut g) = paint {
|
||||
set(&mut g, "opacity", Value::Scalar(s.fill_opacity * 100.0));
|
||||
set(&mut g, "rule", Value::Enum(u32::from(s.fill_rule == effectcraft_svg::FillRule::EvenOdd)));
|
||||
items.push(g);
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Set a child property's value if present.
|
||||
fn set(g: &mut PropGroup, k: &str, v: Value) {
|
||||
if let Some(p) = g.get_mut(k) {
|
||||
p.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap items in a named shape group with the given opacity (0..1).
|
||||
fn group(ids: &mut Ids, name: &str, items: Vec<PropGroup>, opacity: f64) -> PropGroup {
|
||||
let mut g = build::shape_group(ids, name, items);
|
||||
if let Some(p) = g.prop_mut("transform/opacity") {
|
||||
@@ -165,6 +181,7 @@ fn clipped_items(ids: &mut Ids, s: &effectcraft_svg::Shape, m: Affine, clips: &[
|
||||
/// [`shapes_from_vector`]). Images are skipped (Layer ▸ Create makes them footage layers, see
|
||||
/// [`split_at_images`]); soft masks are not converted.
|
||||
pub fn svg_contents(ids: &mut Ids, doc: &Doc) -> Vec<PropGroup> {
|
||||
/// Convert nodes to property groups, applying transform `m` and clip paths.
|
||||
fn walk(ids: &mut Ids, nodes: &[Node], m: Affine, clips: &[BezPath]) -> Vec<PropGroup> {
|
||||
let mut out = vec![];
|
||||
for n in nodes.iter().rev() {
|
||||
@@ -406,6 +423,7 @@ fn copy_transform(proj: &mut Project, src: &Layer, dst: &mut Layer) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy timing and parenting from `src` to `dst`.
|
||||
fn like_source(dst: &mut Layer, src: &Layer) {
|
||||
dst.start_time = src.start_time;
|
||||
dst.in_point = src.in_point;
|
||||
@@ -418,13 +436,17 @@ fn like_source(dst: &mut Layer, src: &Layer) {
|
||||
/// A raster image of a vector document (Create Shapes from Vector Layer makes it a footage
|
||||
/// layer: shape layers have no images).
|
||||
pub struct VectorImage {
|
||||
/// Image name.
|
||||
pub name: String,
|
||||
/// Pixel width.
|
||||
pub width: u32,
|
||||
/// Pixel height.
|
||||
pub height: u32,
|
||||
/// Straight RGBA8.
|
||||
pub rgba: std::sync::Arc<Vec<u8>>,
|
||||
/// Image pixels → document pixels.
|
||||
pub transform: Affine,
|
||||
/// Opacity, 0..1.
|
||||
pub opacity: f64,
|
||||
/// Inside a clipping group or under a soft mask (not carried over).
|
||||
pub clipped: bool,
|
||||
@@ -432,6 +454,7 @@ pub struct VectorImage {
|
||||
|
||||
/// The document's images, bottom first, and how many soft-masked groups it has.
|
||||
pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
|
||||
/// Collect images from a group tree, counting soft-masked groups.
|
||||
fn walk(g: &effectcraft_svg::Group, m: Affine, opacity: f64, clipped: bool, out: &mut Vec<VectorImage>, masks: &mut usize) {
|
||||
let m = m * g.transform;
|
||||
let opacity = opacity * g.opacity;
|
||||
@@ -467,6 +490,7 @@ pub fn vector_images(doc: &Doc) -> (Vec<VectorImage>, usize) {
|
||||
/// layers and image footage layers can stack in the document's order. One more segment than
|
||||
/// images.
|
||||
pub fn split_at_images(doc: &Doc) -> Vec<Doc> {
|
||||
/// Count the images in a group tree.
|
||||
fn count(g: &effectcraft_svg::Group) -> usize {
|
||||
g.children
|
||||
.iter()
|
||||
|
||||
@@ -320,6 +320,41 @@ fn paste_svg_markup_as_a_shape_layer() {
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_svg_pattern_keeps_geometry_and_editable_fallback_paint() {
|
||||
let (mut s, _) = text_session();
|
||||
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="4" height="4">
|
||||
<circle cx="2" cy="2" r="1" fill="red"/>
|
||||
</pattern></defs>
|
||||
<rect id="patterned" width="16" height="6" fill="url(#p) rgba(255, 0, 0, 0.5)"/>
|
||||
<path id="patterned-stroke" d="M0 8 H16" fill="none" stroke="url(#p) rgba(0, 0, 255, 0.25)" stroke-width="2"/>
|
||||
<rect id="patterned-no-fallback" y="10" width="16" height="6" fill="url(#p)"/>
|
||||
<path id="patterned-stroke-no-fallback" d="M0 15 H16" fill="none" stroke="url(#p)" stroke-width="2"/>
|
||||
</svg>"##;
|
||||
let result = s.execute_checked("edit.pasteSvg", json!({"svg": svg})).unwrap();
|
||||
let layer_id = effectcraft_project::LayerId(result["layer"].as_u64().unwrap());
|
||||
let layer = s.active_comp().unwrap().layer(layer_id).unwrap();
|
||||
let shape = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned").unwrap();
|
||||
let items: Vec<_> = shape.sub("contents").unwrap().groups().collect();
|
||||
assert!(items.iter().any(|item| item.match_id == "rect"), "the editable geometry remains");
|
||||
let fill = items.iter().find(|item| item.match_id == "fill").expect("fallback fill remains editable");
|
||||
assert_eq!(fill.get("color").unwrap().value, KV::Color([1.0, 0.0, 0.0, 0.5]));
|
||||
let stroke = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-stroke").unwrap();
|
||||
let items: Vec<_> = stroke.sub("contents").unwrap().groups().collect();
|
||||
assert!(items.iter().any(|item| item.match_id == "path"), "the editable stroke path remains");
|
||||
let stroke = items.iter().find(|item| item.match_id == "stroke").expect("fallback stroke remains editable");
|
||||
assert_eq!(stroke.get("color").unwrap().value, KV::Color([0.0, 0.0, 1.0, 0.25]));
|
||||
let no_fallback = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-no-fallback").unwrap();
|
||||
let items: Vec<_> = no_fallback.sub("contents").unwrap().groups().collect();
|
||||
assert!(items.iter().any(|item| item.match_id == "rect"), "the editable geometry remains without a fallback");
|
||||
assert!(!items.iter().any(|item| item.match_id == "fill"), "pattern paint without a fallback is omitted");
|
||||
let no_fallback_stroke = layer.props.sub("contents").unwrap().groups().find(|g| g.name == "patterned-stroke-no-fallback").unwrap();
|
||||
let items: Vec<_> = no_fallback_stroke.sub("contents").unwrap().groups().collect();
|
||||
assert!(items.iter().any(|item| item.match_id == "path"), "the editable stroke geometry remains without a fallback");
|
||||
assert!(!items.iter().any(|item| item.match_id == "stroke"), "pattern stroke without a fallback is omitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_svg_rejects_text_that_is_not_usable_svg() {
|
||||
let (mut s, _) = text_session();
|
||||
|
||||
@@ -119,7 +119,7 @@ pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -
|
||||
if u64::from(w) * u64::from(h) > u64::from(MAX_VECTOR_SIDE) * u64::from(MAX_VECTOR_SIDE) {
|
||||
return Err(MediaError::Decode(format!("{path}: {w}×{h} pixels is larger than vector footage can be drawn ({MAX_VECTOR_SIDE}×{MAX_VECTOR_SIDE})")));
|
||||
}
|
||||
let img = effectcraft_svg::rasterize_with(&doc, w, h, to_image);
|
||||
let img = effectcraft_svg::try_rasterize_with(&doc, w, h, to_image).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
|
||||
// The rasteriser produces premultiplied pixels already.
|
||||
let _ = op;
|
||||
return Ok(Some(img));
|
||||
@@ -132,10 +132,15 @@ const MAX_VECTOR_SIDE: u32 = 16384;
|
||||
|
||||
/// The vector footage `footage` (of the file `path` with these `bytes`) rasterised at `scale` ×
|
||||
/// its size as [`effectcraft_render::vector_fit`] draws it (Continuously Rasterize).
|
||||
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option<Image> {
|
||||
///
|
||||
/// `None` when the file cannot be read as a vector document. `Some(Err(_))` when a render budget
|
||||
/// refused part of the drawing (for example a layer scaled past the surface limit), so the caller
|
||||
/// can report it instead of showing a partial or empty image as if it were complete.
|
||||
pub(crate) fn rasterize_vector(path: &str, bytes: &[u8], footage: &Footage, scale: f64) -> Option<Result<Image>> {
|
||||
let doc = footage_doc(path, bytes, footage)?.ok()?;
|
||||
let ((sw, sh), to_image) = fit(&doc, footage, scale);
|
||||
Some(effectcraft_svg::rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image))
|
||||
let drawn = effectcraft_svg::try_rasterize_with(&doc, sw.clamp(1, MAX_VECTOR_SIDE), sh.clamp(1, MAX_VECTOR_SIDE), to_image);
|
||||
Some(drawn.map_err(|e| MediaError::Decode(format!("{path}: {e}"))))
|
||||
}
|
||||
|
||||
/// The page framing of vector footage: as After Effects places it for footage from an After
|
||||
@@ -173,6 +178,18 @@ mod tests {
|
||||
assert_eq!((img.width, img.height), (40, 20));
|
||||
}
|
||||
|
||||
/// Continuously Rasterize reports a layer scaled past the render budget as an error instead of
|
||||
/// returning a partial or empty image, and still draws the same layer at an ordinary scale.
|
||||
#[test]
|
||||
fn rasterizing_vector_footage_past_the_budget_is_reported() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect width="100" height="100"/></svg>"#;
|
||||
let footage = probe("scaled.svg", svg).unwrap().unwrap();
|
||||
let ok = rasterize_vector("scaled.svg", svg, &footage, 1.0).unwrap().unwrap();
|
||||
assert_eq!((ok.width, ok.height), (100, 100));
|
||||
let e = rasterize_vector("scaled.svg", svg, &footage, 1000.0).unwrap().err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("scaled.svg"), "{e}");
|
||||
}
|
||||
|
||||
/// A 200 × 100 pt page: red left half, blue right half.
|
||||
fn halves_pdf() -> Vec<u8> {
|
||||
effectcraft_pdf::write::pdf(
|
||||
@@ -214,7 +231,7 @@ mod tests {
|
||||
(footage.width, footage.height) = (100, 80);
|
||||
let unplaced = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
|
||||
assert!(unplaced == own, "not placed: drawn as before");
|
||||
assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).map(|i| (i.width, i.height)), Some((100, 50)));
|
||||
assert_eq!(rasterize_vector("art.ai", &pdf, &footage, 0.5).and_then(|r| r.ok()).map(|i| (i.width, i.height)), Some((100, 50)));
|
||||
|
||||
footage.placed_page = true;
|
||||
let img = decode("art.ai", &pdf, &footage, op).unwrap().unwrap();
|
||||
@@ -226,7 +243,7 @@ mod tests {
|
||||
assert!(is_blue(img.get(x, y)), "blue at ({x}, {y}): {:?}", img.get(x, y));
|
||||
}
|
||||
|
||||
let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap();
|
||||
let sharp = rasterize_vector("art.ai", &pdf, &footage, 2.0).unwrap().unwrap();
|
||||
assert_eq!((sharp.width, sharp.height), (200, 160));
|
||||
assert!(is_red(sharp.get(50, 155)) && is_blue(sharp.get(150, 155)));
|
||||
// Placed at the page's own size: 1:1.
|
||||
|
||||
@@ -1110,7 +1110,7 @@ impl FootageSource for MediaPool {
|
||||
}
|
||||
|
||||
fn problem(&self, footage: &Footage) -> Option<String> {
|
||||
if footage.missing || footage.kind != FootageKind::Video {
|
||||
if footage.missing || (footage.kind != FootageKind::Video && !effectcraft_render::is_vector_footage(footage)) {
|
||||
return None;
|
||||
}
|
||||
lock(&self.inner.problems).get(footage.path.as_str()).cloned()
|
||||
@@ -1121,7 +1121,19 @@ impl FootageSource for MediaPool {
|
||||
return None;
|
||||
}
|
||||
let bytes = self.inner.read(&footage.path).ok()?;
|
||||
crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale).map(Arc::new)
|
||||
let path: Arc<str> = footage.path.as_str().into();
|
||||
match crate::layered::rasterize_vector(&footage.path, &bytes, footage, scale)? {
|
||||
Ok(img) => {
|
||||
lock(&self.inner.problems).remove(&path);
|
||||
Some(Arc::new(img))
|
||||
}
|
||||
// Refused by a render budget (a layer scaled too far): no frame, with the reason
|
||||
// reported through `problem` rather than showing a partial image as complete.
|
||||
Err(e) => {
|
||||
self.inner.note_problem(&path, "", &e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_aux_footage(&self, paths: &[String]) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "effectcraft-svg"
|
||||
description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid and gradient fills/strokes, opacity) and rasterises it at any scale"
|
||||
description = "SVG import: parses SVG 1.1 / SVG 2 static vector content (paths, basic shapes, groups, transforms, use, CSS styles, solid, gradient and pattern fills/strokes, opacity) and rasterises it at any scale"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
+23
-1
@@ -18,9 +18,31 @@ here, plus **CSS Color Module Level 3** for colour keywords and functions:
|
||||
`stroke-opacity`, `opacity`, `display`, `visibility`, `color`/`currentColor`;
|
||||
- `linearGradient` and `radialGradient` (with `fx`/`fy` focal point), `gradientUnits`,
|
||||
`gradientTransform`, `spreadMethod`, `href` inheritance of stops and attributes;
|
||||
- static (non-animated) `<pattern>` fill/stroke with `patternUnits`, `patternContentUnits`, `patternTransform`,
|
||||
tile `x`/`y`/`width`/`height`, local `href`/`xlink:href` inheritance and solid paints on
|
||||
supported shape/group children;
|
||||
- styling through presentation attributes, the `style` attribute and `<style>` sheets with
|
||||
type, class, id and universal selectors (simple selectors and selector lists).
|
||||
|
||||
Not supported (ignored): text, images, filters, clip paths, masks, patterns, markers, animation.
|
||||
Pattern `viewBox`, external pattern references, nested paint servers and non-shape pattern content
|
||||
are unsupported; invalid patterns use a `url(...)` fallback when present. Local pattern `href`
|
||||
chains are depth-limited and cycles are rejected. Tiles render once and are sampled across the
|
||||
painted area; both live surfaces and aggregate tile work are bounded. Pattern paints are
|
||||
rasterised in SVG footage; editable shape layers retain geometry and convert only a supplied
|
||||
`url(...)` fallback to solid fill/stroke.
|
||||
Path expansion and gradient resolution have per-import work limits. The rasterizer also caps live
|
||||
surface memory and cumulative pattern, clip-coverage, and source-image work. Over-budget imports
|
||||
return an error; over-budget raster operations are skipped, using a paint fallback where available.
|
||||
|
||||
`rasterize`/`rasterize_with` drop content refused by a budget and return the image drawn so far
|
||||
(an empty image if the canvas itself is refused). `try_rasterize`/`try_rasterize_with` return
|
||||
`Err(RasterError)` instead (`SurfaceLimit` for memory, `WorkLimit` for work). Budgets per render:
|
||||
512 MiB of live offscreen surfaces, 512 MiB of pattern tiles, 256 Mi units each of pattern
|
||||
render work, clip-path rasterisation work and image sampling work, and 2^31 pixels touched across
|
||||
all passes.
|
||||
|
||||
For SVG input, text, images, filters, clip paths, masks, markers and animation (including pattern
|
||||
animation) are unsupported and ignored. The PDF importers' documents do use clip, mask, blend and
|
||||
image nodes, which the rasterizer renders under the same budgets.
|
||||
XML parsing uses `roxmltree` (MIT/Apache-2.0). Test fixtures are original work (see
|
||||
`tests/fixtures/*.attribution`).
|
||||
@@ -251,6 +251,7 @@ mod tests {
|
||||
use super::parse_color;
|
||||
|
||||
#[test]
|
||||
/// Non-finite channel values are rejected.
|
||||
fn non_finite_rejected() {
|
||||
for s in [
|
||||
"rgb(nan,0,0)",
|
||||
|
||||
+561
-48
@@ -11,12 +11,12 @@ mod color;
|
||||
mod pathdata;
|
||||
mod raster;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::{collections::HashMap, sync::Arc};
|
||||
|
||||
pub use color::parse_color;
|
||||
pub use kurbo::{Affine, BezPath};
|
||||
pub use pathdata::parse_path_data;
|
||||
pub use raster::{blend_pixel, rasterize, rasterize_with};
|
||||
pub use raster::{RasterError, blend_pixel, rasterize, rasterize_with, try_rasterize, try_rasterize_with};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Error {
|
||||
@@ -73,6 +73,15 @@ pub enum Spread {
|
||||
Repeat,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
/// How pattern tile coordinates are interpreted.
|
||||
pub enum PatternUnits {
|
||||
/// Coordinates are fractions of the painted shape's bounding box.
|
||||
ObjectBoundingBox,
|
||||
/// Coordinates are expressed in the current user space.
|
||||
UserSpaceOnUse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum GradientKind {
|
||||
Linear { x1: f64, y1: f64, x2: f64, y2: f64 },
|
||||
@@ -83,7 +92,7 @@ pub enum GradientKind {
|
||||
pub struct Gradient {
|
||||
pub kind: GradientKind,
|
||||
/// (offset 0..1, straight RGBA 0..1), sorted.
|
||||
pub stops: Vec<(f64, [f64; 4])>,
|
||||
pub stops: Arc<[(f64, [f64; 4])]>,
|
||||
/// `gradientUnits="objectBoundingBox"` (the default): coordinates are fractions of the
|
||||
/// shape's bounding box.
|
||||
pub bbox_units: bool,
|
||||
@@ -91,10 +100,36 @@ pub struct Gradient {
|
||||
pub spread: Spread,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
/// A resolved `<pattern>` paint: tile geometry, coordinate units and shared content.
|
||||
pub struct Pattern {
|
||||
/// Tile origin and size in `pattern_units` coordinates.
|
||||
pub x: f64,
|
||||
/// Tile origin y in `pattern_units` coordinates.
|
||||
pub y: f64,
|
||||
/// Tile width in `pattern_units` coordinates.
|
||||
pub width: f64,
|
||||
/// Tile height in `pattern_units` coordinates.
|
||||
pub height: f64,
|
||||
/// Coordinate system used for the tile origin and size.
|
||||
pub pattern_units: PatternUnits,
|
||||
/// Coordinate system used by the tile's child shapes.
|
||||
pub content_units: PatternUnits,
|
||||
/// Transform applied to the repeating tile.
|
||||
pub transform: Affine,
|
||||
/// Immutable tile content shared by every use of the resolved pattern.
|
||||
pub content: Arc<Group>,
|
||||
/// Conservative shape/path traversal cost per tile pixel, charged during rasterisation.
|
||||
pub render_complexity: usize,
|
||||
/// The fallback from `url(#id) fallback`, used if the target bbox or tile is invalid.
|
||||
pub fallback: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Paint {
|
||||
Color([f64; 3]),
|
||||
Gradient(Gradient),
|
||||
Pattern(Pattern),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -418,17 +453,24 @@ struct Rule {
|
||||
/// must have (id, else first class, else type) so an element only checks rules that can match.
|
||||
#[derive(Default, Debug)]
|
||||
struct StyleSheet {
|
||||
/// Distinct selector rules in source order.
|
||||
rules: Vec<Rule>,
|
||||
/// Declaration blocks; comma-list selectors share one block.
|
||||
blocks: Vec<Vec<(String, String)>>,
|
||||
/// Declarations over all blocks.
|
||||
decls: usize,
|
||||
/// Rules keyed by their id selector.
|
||||
by_id: HashMap<String, Vec<usize>>,
|
||||
/// Rules keyed by their first class selector.
|
||||
by_class: HashMap<String, Vec<usize>>,
|
||||
/// Rules keyed by their type selector.
|
||||
by_tag: HashMap<String, Vec<usize>>,
|
||||
/// Rules with no id, class or type to index on.
|
||||
universal: Vec<usize>,
|
||||
}
|
||||
|
||||
impl StyleSheet {
|
||||
/// Build the id, class, tag and universal lookup buckets from `rules`.
|
||||
fn index(&mut self) {
|
||||
for (i, r) in self.rules.iter().enumerate() {
|
||||
let bucket = if let Some(id) = &r.id {
|
||||
@@ -591,6 +633,7 @@ const PROPS: &[&str] = &[
|
||||
|
||||
struct Parser<'a, 'i> {
|
||||
ids: HashMap<String, roxmltree::Node<'a, 'i>>,
|
||||
/// The parsed `<style>` sheets.
|
||||
css: StyleSheet,
|
||||
/// CSS matching work done so far; past [`MAX_CSS_WORK`] rules stop applying and the import errs.
|
||||
css_work: std::cell::Cell<usize>,
|
||||
@@ -603,6 +646,32 @@ struct Parser<'a, 'i> {
|
||||
/// The `<use>` targets being expanded, outermost first: a reference back into one is a
|
||||
/// cycle and is skipped.
|
||||
using: Vec<roxmltree::NodeId>,
|
||||
/// Pattern contents may use only solid paints; nested paint servers invalidate that pattern.
|
||||
in_pattern_content: bool,
|
||||
/// Set once pattern content uses a paint server, invalidating that pattern.
|
||||
invalid_pattern_content: bool,
|
||||
/// Work revisited while resolving patterns, bounded across all references.
|
||||
pattern_work: usize,
|
||||
/// Maximum pattern resolution work allowed for this import.
|
||||
pattern_work_budget: usize,
|
||||
/// Set when pattern resolution work passed its budget.
|
||||
pattern_work_exceeded: bool,
|
||||
/// Path data bytes (weighted) parsed so far.
|
||||
path_data_work: usize,
|
||||
/// Path elements produced so far across all shapes.
|
||||
path_element_work: usize,
|
||||
/// Maximum weighted path data work allowed for this import.
|
||||
path_work_budget: usize,
|
||||
/// Set when path data or element work passed its budget.
|
||||
path_work_exceeded: bool,
|
||||
/// Parsed pattern content is viewport-dependent because lengths may be percentages; the cache is bounded.
|
||||
pattern_cache: HashMap<(roxmltree::NodeId, u64, u64), Option<Pattern>>,
|
||||
/// Resolved gradients share their stop arrays across repeated paint references.
|
||||
gradient_cache: HashMap<(roxmltree::NodeId, u64, u64), Gradient>,
|
||||
/// Gradient resolution work done so far.
|
||||
gradient_work: usize,
|
||||
/// Set when gradient work passed [`MAX_GRADIENT_WORK`].
|
||||
gradient_work_exceeded: bool,
|
||||
/// Path elements and gradient stops kept so far, `<use>` copies and repeated gradient
|
||||
/// references included; past [`MAX_RETAINED`] the import errs.
|
||||
retained: std::cell::Cell<usize>,
|
||||
@@ -649,6 +718,53 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
w <= MAX_CSS_WORK
|
||||
}
|
||||
|
||||
/// Charge work while resolving pattern references. Attribute bytes and each traversed child
|
||||
/// both count so a cache miss cannot repeatedly scan an enormous descriptive-only pattern.
|
||||
fn charge_pattern_work(&mut self, work: usize) -> bool {
|
||||
self.pattern_work = self.pattern_work.saturating_add(work);
|
||||
if self.pattern_work > self.pattern_work_budget {
|
||||
self.pattern_work_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge gradient resolution work; false once the budget is exceeded.
|
||||
fn charge_gradient_work(&mut self, work: usize) -> bool {
|
||||
self.gradient_work = self.gradient_work.saturating_add(work);
|
||||
if self.gradient_work > MAX_GRADIENT_WORK {
|
||||
self.gradient_work_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge path data bytes against the path work budget; false once exceeded.
|
||||
fn charge_path_data(&mut self, bytes: usize) -> bool {
|
||||
self.path_data_work = self.path_data_work.saturating_add(bytes.saturating_mul(PATH_DATA_WORK_FACTOR));
|
||||
if self.path_data_work > self.path_work_budget {
|
||||
self.path_work_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge produced path elements against the per-import cap; false once exceeded.
|
||||
fn charge_path_elements(&mut self, elements: usize) -> bool {
|
||||
self.path_element_work = self.path_element_work.saturating_add(elements);
|
||||
if self.path_element_work > MAX_PATH_ELEMENTS_PER_IMPORT {
|
||||
self.path_work_exceeded = true;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge a pattern node's attributes and traversal while resolving a pattern reference.
|
||||
fn charge_pattern_attributes(&mut self, n: roxmltree::Node) -> bool {
|
||||
let bytes = n.attributes().map(|attribute| attribute.name().len().saturating_add(attribute.value().len())).fold(0usize, usize::saturating_add);
|
||||
self.charge_pattern_work(bytes.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Apply the matching `<style>` rules to `out`, by specificity then source order.
|
||||
fn apply_rules(&self, n: roxmltree::Node, out: &mut HashMap<String, String>) {
|
||||
let css = &self.css;
|
||||
@@ -705,7 +821,7 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
unit = &s[num_end - 1..];
|
||||
}
|
||||
let v: f64 = num.parse().ok()?;
|
||||
Some(match unit.trim() {
|
||||
let value = match unit.trim() {
|
||||
"" | "px" => v,
|
||||
"pt" => v * 4.0 / 3.0,
|
||||
"pc" => v * 16.0,
|
||||
@@ -723,7 +839,8 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
v / 100.0 * base
|
||||
}
|
||||
_ => v,
|
||||
})
|
||||
};
|
||||
value.is_finite().then_some(value)
|
||||
}
|
||||
|
||||
fn attr_len(&self, n: roxmltree::Node, name: &str, axis: char, st: &Style) -> f64 {
|
||||
@@ -740,7 +857,8 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
}
|
||||
if let Some(rest) = v.strip_prefix("url(") {
|
||||
let (url, fallback) = rest.split_once(')').unwrap_or((rest, ""));
|
||||
let id = url.trim().trim_matches(['"', '\'']).trim_start_matches('#').to_string();
|
||||
let url = url.trim().trim_matches(['"', '\'']);
|
||||
let id = url.strip_prefix('#').filter(|id| !id.is_empty()).unwrap_or_default().to_string();
|
||||
let fb = fallback.trim();
|
||||
let fb = if fb.is_empty() || fb == "none" { None } else { parse_color(fb) };
|
||||
return Some(Some(PaintRef::Url(id, fb)));
|
||||
@@ -754,8 +872,11 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
/// Apply an element's declared inherited properties to `st`. Returns (opacity, display).
|
||||
fn cascade(&self, n: roxmltree::Node, st: &mut Style) -> (f64, bool) {
|
||||
let d = self.declared(n);
|
||||
let num =
|
||||
|k: &str| d.get(k).and_then(|v| v.trim().trim_end_matches('%').parse::<f64>().ok().map(|x| if v.trim().ends_with('%') { x / 100.0 } else { x }));
|
||||
let num = |k: &str| {
|
||||
d.get(k)
|
||||
.and_then(|v| v.trim().trim_end_matches('%').parse::<f64>().ok().map(|x| if v.trim().ends_with('%') { x / 100.0 } else { x }))
|
||||
.filter(|x| x.is_finite())
|
||||
};
|
||||
if let Some(c) = d.get("color").and_then(|v| parse_color(v)) {
|
||||
st.color = c;
|
||||
}
|
||||
@@ -820,29 +941,72 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
(num("opacity").unwrap_or(1.0).clamp(0.0, 1.0), display)
|
||||
}
|
||||
|
||||
fn resolve_paint(&self, p: &PaintRef, st: &Style) -> Option<(Paint, f64)> {
|
||||
/// Resolve a paint reference, retaining a per-reference fallback for patterns.
|
||||
fn resolve_paint(&mut self, p: &PaintRef, st: &Style) -> Option<(Paint, f64)> {
|
||||
if self.in_pattern_content && matches!(p, PaintRef::Url(..)) {
|
||||
self.invalid_pattern_content = true;
|
||||
return None;
|
||||
}
|
||||
match p {
|
||||
PaintRef::Color(c) => Some((Paint::Color([c[0], c[1], c[2]]), c[3])),
|
||||
PaintRef::Current => Some((Paint::Color([st.color[0], st.color[1], st.color[2]]), st.color[3])),
|
||||
PaintRef::Url(id, fb) => match self.gradient(id, 0) {
|
||||
PaintRef::Url(id, fallback) => match self.gradient(id, 0) {
|
||||
Some(g) if g.stops.len() == 1 => {
|
||||
let c = g.stops[0].1;
|
||||
let (_, c) = g.stops.first()?;
|
||||
Some((Paint::Color([c[0], c[1], c[2]]), c[3]))
|
||||
}
|
||||
Some(g) if !g.stops.is_empty() => Some((Paint::Gradient(g), 1.0)),
|
||||
Some(_) => None,
|
||||
None => fb.map(|c| (Paint::Color([c[0], c[1], c[2]]), c[3])),
|
||||
None => match self.pattern(id) {
|
||||
Some(mut pattern) => {
|
||||
pattern.fallback = *fallback;
|
||||
Some((Paint::Pattern(pattern), 1.0))
|
||||
}
|
||||
None => fallback.map(|c| (Paint::Color([c[0], c[1], c[2]]), c[3])),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn href(&self, n: roxmltree::Node<'a, 'i>) -> Option<roxmltree::Node<'a, 'i>> {
|
||||
let h = n.attributes().find(|a| a.name() == "href").map(|a| a.value())?;
|
||||
self.ids.get(h.trim().trim_start_matches('#')).copied()
|
||||
/// Read a local `href` or namespaced `xlink:href` value from an SVG element.
|
||||
fn href_value(n: roxmltree::Node<'a, 'i>) -> Option<String> {
|
||||
/// XML namespace of `xlink:href`.
|
||||
const XLINK: &str = "http://www.w3.org/1999/xlink";
|
||||
let href = n
|
||||
.attributes()
|
||||
.find(|a| a.name() == "href" && a.namespace().is_none())
|
||||
.or_else(|| n.attributes().find(|a| a.name() == "href" && a.namespace() == Some(XLINK)))?;
|
||||
Some(href.value().to_string())
|
||||
}
|
||||
|
||||
/// A gradient by id, with `href` inheritance of attributes and stops.
|
||||
fn gradient(&self, id: &str, depth: usize) -> Option<Gradient> {
|
||||
fn href(&self, n: roxmltree::Node<'a, 'i>) -> Option<roxmltree::Node<'a, 'i>> {
|
||||
let h = Self::href_value(n)?;
|
||||
let id = h.trim().strip_prefix('#')?;
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.ids.get(id).copied()
|
||||
}
|
||||
|
||||
/// A gradient by id, with a viewport-specific cache for its resolved attributes and stops.
|
||||
fn gradient(&mut self, id: &str, depth: usize) -> Option<Gradient> {
|
||||
let node = *self.ids.get(id)?;
|
||||
if !matches!(node.tag_name().name(), "linearGradient" | "radialGradient") || depth > 16 {
|
||||
return None;
|
||||
}
|
||||
let key = (node.id(), self.vw.to_bits(), self.vh.to_bits());
|
||||
if let Some(gradient) = self.gradient_cache.get(&key) {
|
||||
return Some(gradient.clone());
|
||||
}
|
||||
let gradient = self.parse_gradient(id, depth)?;
|
||||
if self.gradient_cache.len() < MAX_GRADIENT_CACHE_ENTRIES {
|
||||
self.gradient_cache.insert(key, gradient.clone());
|
||||
}
|
||||
Some(gradient)
|
||||
}
|
||||
|
||||
/// Parse a gradient by id, charging reference-chain and stop work before retaining the result.
|
||||
fn parse_gradient(&mut self, id: &str, depth: usize) -> Option<Gradient> {
|
||||
let n = *self.ids.get(id)?;
|
||||
let tag = n.tag_name().name();
|
||||
if !matches!(tag, "linearGradient" | "radialGradient") || depth > 16 {
|
||||
@@ -851,24 +1015,31 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
// Chain: this element, then what it references.
|
||||
let mut chain = vec![n];
|
||||
let mut cur = n;
|
||||
if !self.charge_gradient_work(cur.attributes().map(|a| a.name().len().saturating_add(a.value().len())).fold(1usize, usize::saturating_add)) {
|
||||
return None;
|
||||
}
|
||||
while let Some(next) = self.href(cur) {
|
||||
if chain.len() > 16 || chain.contains(&next) {
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
cur = next;
|
||||
if !self.charge_gradient_work(cur.attributes().map(|a| a.name().len().saturating_add(a.value().len())).fold(1usize, usize::saturating_add)) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let attr = |name: &str| chain.iter().find_map(|e| e.attribute(name));
|
||||
let bbox_units = attr("gradientUnits") != Some("userSpaceOnUse");
|
||||
let st = Style::default();
|
||||
let len = |name: &str, axis: char, def: &str| -> f64 {
|
||||
let v = attr(name).unwrap_or(def);
|
||||
if bbox_units {
|
||||
let value = if bbox_units {
|
||||
let v = v.trim();
|
||||
if let Some(p) = v.strip_suffix('%') { p.parse::<f64>().unwrap_or(0.0) / 100.0 } else { v.parse::<f64>().unwrap_or(0.0) }
|
||||
} else {
|
||||
self.length(v, axis, st.font_size).unwrap_or(0.0)
|
||||
}
|
||||
};
|
||||
if value.is_finite() { value } else { 0.0 }
|
||||
};
|
||||
let kind = if tag == "linearGradient" {
|
||||
GradientKind::Linear { x1: len("x1", 'x', "0%"), y1: len("y1", 'y', "0%"), x2: len("x2", 'x', "100%"), y2: len("y2", 'y', "0%") }
|
||||
@@ -888,13 +1059,29 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
};
|
||||
// Stops: from the first element in the chain that has any.
|
||||
let mut stops = vec![];
|
||||
if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) {
|
||||
let mut source = None;
|
||||
'source: for candidate in &chain {
|
||||
for child in candidate.children().filter(|child| child.is_element()) {
|
||||
if !self.charge_gradient_work(1) {
|
||||
return None;
|
||||
}
|
||||
if child.tag_name().name() == "stop" {
|
||||
source = Some(*candidate);
|
||||
break 'source;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(src) = source {
|
||||
// Charged before the stops are read: every reference reads and keeps them again.
|
||||
if !self.charge_retained(src.children().filter(|c| c.is_element()).count()) {
|
||||
return None;
|
||||
}
|
||||
let mut last = 0.0f64;
|
||||
for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") {
|
||||
let stop_work = s.attributes().map(|a| a.name().len().saturating_add(a.value().len())).fold(1usize, usize::saturating_add);
|
||||
if !self.charge_gradient_work(stop_work) {
|
||||
return None;
|
||||
}
|
||||
let d = self.declared(s);
|
||||
let off = s
|
||||
.attribute("offset")
|
||||
@@ -910,11 +1097,207 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
.get("stop-color")
|
||||
.and_then(|c| if c.trim() == "currentColor" { d.get("color").and_then(|x| parse_color(x)) } else { parse_color(c) })
|
||||
.unwrap_or([0.0, 0.0, 0.0, 1.0]);
|
||||
let o = d.get("stop-opacity").and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let o = d.get("stop-opacity").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
stops.push((off, [c[0], c[1], c[2], c[3] * o]));
|
||||
}
|
||||
}
|
||||
Some(Gradient { kind, stops, bbox_units, transform, spread })
|
||||
Some(Gradient { kind, stops: Arc::from(stops.into_boxed_slice()), bbox_units, transform, spread })
|
||||
}
|
||||
|
||||
/// Parse a pattern length using the coordinate system selected by its units attribute.
|
||||
fn pattern_length(&self, value: &str, axis: char, font: f64, units: PatternUnits) -> Option<f64> {
|
||||
let value = value.trim();
|
||||
let length = match units {
|
||||
PatternUnits::ObjectBoundingBox => match value.strip_suffix('%') {
|
||||
Some(percent) => percent.trim().parse::<f64>().ok()? / 100.0,
|
||||
None => value.parse::<f64>().ok()?,
|
||||
},
|
||||
PatternUnits::UserSpaceOnUse => {
|
||||
let number_end = value.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))).unwrap_or(value.len());
|
||||
let (mut number, mut unit) = (&value[..number_end], &value[number_end..]);
|
||||
// The length scanner admits `e` for exponents; back it off when it starts `em` or
|
||||
// `ex`, or follows a complete exponent (for example `1e2em`).
|
||||
if number.parse::<f64>().is_err() && (number.ends_with('e') || number.ends_with('E')) {
|
||||
number = &number[..number.len().saturating_sub(1)];
|
||||
unit = &value[number_end.saturating_sub(1)..];
|
||||
}
|
||||
number.parse::<f64>().ok()?;
|
||||
let unit = unit.trim();
|
||||
if !matches!(unit, "" | "px" | "pt" | "pc" | "mm" | "cm" | "in" | "em" | "ex" | "%") {
|
||||
return None;
|
||||
}
|
||||
self.length(value, axis, font)?
|
||||
}
|
||||
};
|
||||
length.is_finite().then_some(length)
|
||||
}
|
||||
|
||||
/// Resolve a local static pattern. The nearest chain member supplies each attribute;
|
||||
/// child content comes from the first member with non-descriptive element children.
|
||||
fn pattern(&mut self, id: &str) -> Option<Pattern> {
|
||||
let target = *self.ids.get(id)?;
|
||||
if target.tag_name().name() != "pattern" {
|
||||
return None;
|
||||
}
|
||||
let key = (target.id(), self.vw.to_bits(), self.vh.to_bits());
|
||||
if let Some(pattern) = self.pattern_cache.get(&key) {
|
||||
return pattern.clone();
|
||||
}
|
||||
let pattern = self.parse_pattern(id);
|
||||
if pattern.is_some() && self.pattern_cache.len() < MAX_PATTERN_CACHE_ENTRIES {
|
||||
self.pattern_cache.insert(key, pattern.clone());
|
||||
}
|
||||
pattern
|
||||
}
|
||||
|
||||
/// Parse one pattern and account for all work done while resolving its inheritance chain.
|
||||
fn parse_pattern(&mut self, id: &str) -> Option<Pattern> {
|
||||
/// Longest `href` inheritance chain followed when resolving a pattern.
|
||||
const MAX_PATTERN_HREF_DEPTH: usize = 16;
|
||||
let first = *self.ids.get(id)?;
|
||||
if first.tag_name().name() != "pattern" {
|
||||
return None;
|
||||
}
|
||||
let mut chain = vec![first];
|
||||
loop {
|
||||
let current = *chain.last()?;
|
||||
if !self.charge_pattern_attributes(current) {
|
||||
return None;
|
||||
}
|
||||
let Some(href) = Self::href_value(current) else { break };
|
||||
if chain.len() >= MAX_PATTERN_HREF_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let target_id = href.trim().strip_prefix('#')?;
|
||||
if target_id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let target = *self.ids.get(target_id)?;
|
||||
if target.tag_name().name() != "pattern" || chain.iter().any(|seen| seen.id() == target.id()) {
|
||||
return None;
|
||||
}
|
||||
chain.push(target);
|
||||
}
|
||||
// This implementation has no viewBox mapper for pattern contents; don't silently paint
|
||||
// them in the wrong coordinate system.
|
||||
if chain.iter().any(|node| node.attribute("viewBox").is_some()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let attr = |name: &str| chain.iter().find_map(|node| node.attribute(name).map(str::to_string));
|
||||
let mut content_style = Style::default();
|
||||
let mut content_opacity = 1.0;
|
||||
let ancestors: Vec<_> = first.ancestors().filter(|node| node.is_element()).collect();
|
||||
for ancestor in ancestors.into_iter().rev() {
|
||||
if ancestor.id() != first.id() && !self.charge_pattern_attributes(ancestor) {
|
||||
return None;
|
||||
}
|
||||
let (opacity, _) = self.cascade(ancestor, &mut content_style);
|
||||
if ancestor.id() == first.id() {
|
||||
content_opacity = opacity;
|
||||
}
|
||||
}
|
||||
let font_size = content_style.font_size;
|
||||
let units = match attr("patternUnits").as_deref() {
|
||||
None | Some("objectBoundingBox") => PatternUnits::ObjectBoundingBox,
|
||||
Some("userSpaceOnUse") => PatternUnits::UserSpaceOnUse,
|
||||
Some(_) => return None,
|
||||
};
|
||||
let content_units = match attr("patternContentUnits").as_deref() {
|
||||
None | Some("userSpaceOnUse") => PatternUnits::UserSpaceOnUse,
|
||||
Some("objectBoundingBox") => PatternUnits::ObjectBoundingBox,
|
||||
Some(_) => return None,
|
||||
};
|
||||
let x = match attr("x").as_deref() {
|
||||
Some(value) => self.pattern_length(value, 'x', font_size, units)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let y = match attr("y").as_deref() {
|
||||
Some(value) => self.pattern_length(value, 'y', font_size, units)?,
|
||||
None => 0.0,
|
||||
};
|
||||
let width = self.pattern_length(attr("width")?.as_str(), 'x', font_size, units)?;
|
||||
let height = self.pattern_length(attr("height")?.as_str(), 'y', font_size, units)?;
|
||||
if width <= 0.0 || height <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let transform = attr("patternTransform").as_deref().map(parse_transform).unwrap_or(Affine::IDENTITY);
|
||||
let coeffs = transform.as_coeffs();
|
||||
let determinant = coeffs[0] * coeffs[3] - coeffs[1] * coeffs[2];
|
||||
if !coeffs.iter().all(|v| v.is_finite())
|
||||
|| !determinant.is_finite()
|
||||
|| determinant == 0.0
|
||||
|| !transform.inverse().as_coeffs().iter().all(|v| v.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (old_pattern_content, old_invalid) = (self.in_pattern_content, self.invalid_pattern_content);
|
||||
let (old_vw, old_vh) = (self.vw, self.vh);
|
||||
self.in_pattern_content = true;
|
||||
self.invalid_pattern_content = false;
|
||||
if content_units == PatternUnits::ObjectBoundingBox {
|
||||
self.vw = 1.0;
|
||||
self.vh = 1.0;
|
||||
}
|
||||
let source = chain.iter().copied().find(|node| {
|
||||
node.children().any(|child| {
|
||||
if !self.charge_pattern_work(1) {
|
||||
// Stop the scan immediately; the exceeded flag below rejects this pattern.
|
||||
return true;
|
||||
}
|
||||
child.is_element() && !matches!(child.tag_name().name(), "title" | "desc" | "metadata")
|
||||
})
|
||||
});
|
||||
if self.pattern_work_exceeded {
|
||||
return None;
|
||||
}
|
||||
// Content inherits from the element that actually owns it, which differs from `first`
|
||||
// when the content comes through an `href` chain.
|
||||
if let Some(source) = source.filter(|s| s.id() != first.id()) {
|
||||
let mut inherited = Style::default();
|
||||
let owners: Vec<_> = source.ancestors().filter(|node| node.is_element()).collect();
|
||||
for owner in owners.into_iter().rev() {
|
||||
if !self.charge_pattern_attributes(owner) {
|
||||
self.in_pattern_content = old_pattern_content;
|
||||
self.invalid_pattern_content = old_invalid;
|
||||
self.vw = old_vw;
|
||||
self.vh = old_vh;
|
||||
return None;
|
||||
}
|
||||
self.cascade(owner, &mut inherited);
|
||||
}
|
||||
content_style = inherited;
|
||||
}
|
||||
let mut children = Vec::new();
|
||||
if let Some(source) = source {
|
||||
for child in source.children() {
|
||||
self.node(child, &content_style, &mut children, 1);
|
||||
if self.pattern_work_exceeded {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let invalid = self.invalid_pattern_content;
|
||||
self.in_pattern_content = old_pattern_content;
|
||||
self.invalid_pattern_content = old_invalid;
|
||||
self.vw = old_vw;
|
||||
self.vh = old_vh;
|
||||
if invalid || self.pattern_work_exceeded {
|
||||
return None;
|
||||
}
|
||||
let render_complexity = pattern_render_complexity(&children);
|
||||
let content = Group {
|
||||
name: source.map_or_else(|| Self::element_name(first), Self::element_name),
|
||||
transform: Affine::IDENTITY,
|
||||
opacity: content_opacity,
|
||||
children,
|
||||
clip: vec![],
|
||||
blend: BlendMode::Normal,
|
||||
mask: None,
|
||||
isolated: true,
|
||||
knockout: false,
|
||||
};
|
||||
Some(Pattern { x, y, width, height, pattern_units: units, content_units, transform, content: Arc::new(content), render_complexity, fallback: None })
|
||||
}
|
||||
|
||||
fn element_name(n: roxmltree::Node) -> String {
|
||||
@@ -926,10 +1309,19 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
if depth > 64 || !is_svg_ns(&n) || self.work > self.budget {
|
||||
return;
|
||||
}
|
||||
if self.in_pattern_content && !self.charge_pattern_attributes(n) {
|
||||
return;
|
||||
}
|
||||
// Every element converted counts, each `<use>` copy again: a few references to groups
|
||||
// that reference each other several times can multiply into billions (#1236).
|
||||
self.work += 1;
|
||||
let tag = n.tag_name().name();
|
||||
if self.in_pattern_content
|
||||
&& !matches!(tag, "g" | "rect" | "circle" | "ellipse" | "path" | "line" | "polyline" | "polygon" | "defs" | "title" | "desc" | "metadata" | "style")
|
||||
{
|
||||
self.invalid_pattern_content = true;
|
||||
return;
|
||||
}
|
||||
match tag {
|
||||
"defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker"
|
||||
| "script" => return,
|
||||
@@ -1009,9 +1401,12 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
let mut inner = vec![];
|
||||
let mut sst = st.clone();
|
||||
let (o, _) = self.cascade(target, &mut sst);
|
||||
let saved_viewport = (self.vw, self.vh);
|
||||
(self.vw, self.vh) = viewbox(target).map_or((w, h), |(_, vw, vh)| (vw, vh));
|
||||
for c in target.children() {
|
||||
self.node(c, &sst, &mut inner, depth + 1);
|
||||
}
|
||||
(self.vw, self.vh) = saved_viewport;
|
||||
children.push(Node::Group(Group {
|
||||
name: Self::element_name(target),
|
||||
transform: vb,
|
||||
@@ -1043,7 +1438,40 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
if !st.visible {
|
||||
return;
|
||||
}
|
||||
let Some(geom) = self.geometry(n, &st) else { return };
|
||||
let (path_data, points) = match tag {
|
||||
"path" => {
|
||||
let Some(data) = n.attribute("d") else { return };
|
||||
if !self.charge_path_data(data.len()) {
|
||||
return;
|
||||
}
|
||||
let (path, exceeded) = pathdata::parse_path_data_bounded(data, MAX_PATH_ELEMENTS_PER_IMPORT_ELEMENT);
|
||||
if exceeded || !self.charge_path_elements(path.elements().len()) {
|
||||
self.path_work_exceeded = true;
|
||||
return;
|
||||
}
|
||||
(Some(path), None)
|
||||
}
|
||||
"polyline" | "polygon" => {
|
||||
let Some(data) = n.attribute("points") else { return };
|
||||
if !self.charge_path_data(data.len()) {
|
||||
return;
|
||||
}
|
||||
let max_values = MAX_PATH_ELEMENTS_PER_IMPORT_ELEMENT.saturating_sub(1).saturating_mul(2);
|
||||
let (points, exceeded) = pathdata::numbers_bounded(data, max_values);
|
||||
if exceeded {
|
||||
self.path_work_exceeded = true;
|
||||
return;
|
||||
}
|
||||
let point_count = points.len() / 2;
|
||||
let elements = point_count.saturating_add(usize::from(tag == "polygon"));
|
||||
if points.len() >= 4 && !self.charge_path_elements(elements) {
|
||||
return;
|
||||
}
|
||||
(None, Some(points))
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
let Some(geom) = self.geometry(n, &st, path_data, points) else { return };
|
||||
if let Geom::Path(p) = &geom
|
||||
&& !self.charge_retained(p.elements().len())
|
||||
{
|
||||
@@ -1099,11 +1527,12 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(&self, n: roxmltree::Node, st: &Style) -> Option<Geom> {
|
||||
/// Resolve an element's geometry from its attributes, or from pre-parsed path data / points.
|
||||
fn geometry(&self, n: roxmltree::Node, st: &Style, path_data: Option<BezPath>, points: Option<Vec<f64>>) -> Option<Geom> {
|
||||
let l = |name: &str, axis: char| self.attr_len(n, name, axis, st);
|
||||
Some(match n.tag_name().name() {
|
||||
"path" => {
|
||||
let p = parse_path_data(n.attribute("d")?);
|
||||
let p = path_data?;
|
||||
if p.elements().is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -1114,6 +1543,10 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
if !(w > 0.0 && h > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (l("x", 'x'), l("y", 'y'));
|
||||
if !(x + w).is_finite() || !(y + h).is_finite() {
|
||||
return None;
|
||||
}
|
||||
let rx_a = n.attribute("rx").and_then(|v| self.length(v, 'x', st.font_size));
|
||||
let ry_a = n.attribute("ry").and_then(|v| self.length(v, 'y', st.font_size));
|
||||
let (rx, ry) = match (rx_a, ry_a) {
|
||||
@@ -1122,21 +1555,29 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
(None, Some(y)) => (y, y),
|
||||
(None, None) => (0.0, 0.0),
|
||||
};
|
||||
Geom::Rect { x: l("x", 'x'), y: l("y", 'y'), w, h, rx: rx.clamp(0.0, w / 2.0), ry: ry.clamp(0.0, h / 2.0) }
|
||||
Geom::Rect { x, y, w, h, rx: rx.clamp(0.0, w / 2.0), ry: ry.clamp(0.0, h / 2.0) }
|
||||
}
|
||||
"circle" => {
|
||||
let r = l("r", 'o');
|
||||
if r <= 0.0 {
|
||||
if !(r > 0.0 && r <= MAX_ELLIPSE_RADIUS) {
|
||||
return None;
|
||||
}
|
||||
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx: r, ry: r }
|
||||
let (cx, cy) = (l("cx", 'x'), l("cy", 'y'));
|
||||
if ![cx - r, cx + r, cy - r, cy + r].iter().all(|value| value.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Geom::Ellipse { cx, cy, rx: r, ry: r }
|
||||
}
|
||||
"ellipse" => {
|
||||
let (rx, ry) = (l("rx", 'x'), l("ry", 'y'));
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
if !(rx > 0.0 && rx <= MAX_ELLIPSE_RADIUS && ry > 0.0 && ry <= MAX_ELLIPSE_RADIUS) {
|
||||
return None;
|
||||
}
|
||||
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx, ry }
|
||||
let (cx, cy) = (l("cx", 'x'), l("cy", 'y'));
|
||||
if ![cx - rx, cx + rx, cy - ry, cy + ry].iter().all(|value| value.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Geom::Ellipse { cx, cy, rx, ry }
|
||||
}
|
||||
"line" => {
|
||||
let mut p = BezPath::new();
|
||||
@@ -1145,14 +1586,16 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
Geom::Path(p)
|
||||
}
|
||||
"polyline" | "polygon" => {
|
||||
let nums = pathdata::numbers(n.attribute("points")?);
|
||||
let nums = points?;
|
||||
if nums.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((nums[0], nums[1]));
|
||||
for c in nums[2..].as_chunks::<2>().0 {
|
||||
p.line_to((c[0], c[1]));
|
||||
let first_pair = nums.get(..2)?;
|
||||
let (Some(&first_x), Some(&first_y)) = (first_pair.first(), first_pair.get(1)) else { return None };
|
||||
p.move_to((first_x, first_y));
|
||||
for [x, y] in nums.get(2..).unwrap_or_default().as_chunks::<2>().0 {
|
||||
p.line_to((*x, *y));
|
||||
}
|
||||
if n.tag_name().name() == "polygon" {
|
||||
p.close_path();
|
||||
@@ -1164,9 +1607,32 @@ impl<'a, 'i> Parser<'a, 'i> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Estimate tile raster work from group traversal and the shape/path operations it contains.
|
||||
fn pattern_render_complexity(nodes: &[Node]) -> usize {
|
||||
nodes.iter().fold(0usize, |total, node| {
|
||||
let cost = match node {
|
||||
Node::Group(group) => 4usize.saturating_add(pattern_render_complexity(&group.children)),
|
||||
Node::Shape(shape) => {
|
||||
let segments = match &shape.geom {
|
||||
Geom::Path(path) => path.elements().len().max(1),
|
||||
Geom::Rect { .. } | Geom::Ellipse { .. } => 8,
|
||||
};
|
||||
let paints = usize::from(shape.fill.is_some()).saturating_add(usize::from(shape.stroke.is_some()));
|
||||
1usize.saturating_add(segments.saturating_mul(paints))
|
||||
}
|
||||
Node::Image(_) => 1,
|
||||
};
|
||||
total.saturating_add(cost)
|
||||
})
|
||||
}
|
||||
|
||||
fn viewbox(n: roxmltree::Node) -> Option<([f64; 2], f64, f64)> {
|
||||
let v = pathdata::numbers(n.attribute("viewBox")?);
|
||||
(v.len() == 4 && v.iter().all(|x| x.is_finite()) && v[2] > 0.0 && v[3] > 0.0).then(|| ([v[0], v[1]], v[2], v[3]))
|
||||
let (v, exceeded) = pathdata::numbers_bounded(n.attribute("viewBox")?, 5);
|
||||
if exceeded || !v.iter().all(|x| x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let [x, y, width, height] = v.as_slice() else { return None };
|
||||
(*width > 0.0 && *height > 0.0).then_some(([*x, *y], *width, *height))
|
||||
}
|
||||
|
||||
/// The viewBox → viewport transform (`preserveAspectRatio`, default `xMidYMid meet`).
|
||||
@@ -1210,19 +1676,23 @@ pub fn parse_transform(s: &str) -> Affine {
|
||||
while let Some(open) = rest.find('(') {
|
||||
let name = rest[..open].trim().trim_start_matches(',').trim();
|
||||
let Some(close) = rest[open..].find(')') else { break };
|
||||
let a = pathdata::numbers(&rest[open + 1..open + close]);
|
||||
let (a, too_many) = pathdata::numbers_bounded(&rest[open + 1..open + close], 6);
|
||||
rest = &rest[open + close + 1..];
|
||||
let t = match (name, a.len()) {
|
||||
("matrix", 6) => Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]]),
|
||||
("translate", 1) => Affine::translate((a[0], 0.0)),
|
||||
("translate", 2) => Affine::translate((a[0], a[1])),
|
||||
("scale", 1) => Affine::scale(a[0]),
|
||||
("scale", 2) => Affine::scale_non_uniform(a[0], a[1]),
|
||||
("rotate", 1) => Affine::rotate(a[0].to_radians()),
|
||||
("rotate", 3) => Affine::translate((a[1], a[2])) * Affine::rotate(a[0].to_radians()) * Affine::translate((-a[1], -a[2])),
|
||||
("skewX", 1) => Affine::new([1.0, 0.0, a[0].to_radians().tan(), 1.0, 0.0, 0.0]),
|
||||
("skewY", 1) => Affine::new([1.0, a[0].to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
|
||||
_ => Affine::IDENTITY,
|
||||
let t = if too_many {
|
||||
Affine::IDENTITY
|
||||
} else {
|
||||
match (name, a.as_slice()) {
|
||||
("matrix", [a0, a1, a2, a3, a4, a5]) => Affine::new([*a0, *a1, *a2, *a3, *a4, *a5]),
|
||||
("translate", [x]) => Affine::translate((*x, 0.0)),
|
||||
("translate", [x, y]) => Affine::translate((*x, *y)),
|
||||
("scale", [scale]) => Affine::scale(*scale),
|
||||
("scale", [x, y]) => Affine::scale_non_uniform(*x, *y),
|
||||
("rotate", [angle]) => Affine::rotate(angle.to_radians()),
|
||||
("rotate", [angle, x, y]) => Affine::translate((*x, *y)) * Affine::rotate(angle.to_radians()) * Affine::translate((-*x, -*y)),
|
||||
("skewX", [angle]) => Affine::new([1.0, 0.0, angle.to_radians().tan(), 1.0, 0.0, 0.0]),
|
||||
("skewY", [angle]) => Affine::new([1.0, angle.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
|
||||
_ => Affine::IDENTITY,
|
||||
}
|
||||
};
|
||||
m *= t;
|
||||
}
|
||||
@@ -1236,6 +1706,25 @@ const MAX_XML_DEPTH: usize = 256;
|
||||
/// The most elements an import converts, `<use>` copies included, unless the file itself has
|
||||
/// more than half as many (#1236).
|
||||
const MAX_EXPANDED_ELEMENTS: usize = 500_000;
|
||||
/// Bound path geometry expanded from a single path or point-list element.
|
||||
const MAX_PATH_ELEMENTS_PER_IMPORT_ELEMENT: usize = pathdata::MAX_PATH_ELEMENTS;
|
||||
/// Bound the path geometry retained across all path and point-list elements, including `<use>` copies.
|
||||
const MAX_PATH_ELEMENTS_PER_IMPORT: usize = 1_000_000;
|
||||
/// Bound repeated gradient-chain and stop scanning across the import.
|
||||
const MAX_GRADIENT_WORK: usize = 64 * 1024 * 1024;
|
||||
/// Bound viewport-specific resolved gradients; stop data is shared between clones.
|
||||
const MAX_GRADIENT_CACHE_ENTRIES: usize = 256;
|
||||
/// Bound ellipse expansion before kurbo approximates it with tolerance-dependent path segments.
|
||||
const MAX_ELLIPSE_RADIUS: f64 = 1.0e9;
|
||||
/// Weight applied to each path data byte when charging path work.
|
||||
const PATH_DATA_WORK_FACTOR: usize = 8;
|
||||
/// Aggregate work that pattern references may revisit, including attributes and scanned children,
|
||||
/// even when many shapes share one large pattern definition.
|
||||
const MAX_PATTERN_WORK_BYTES: usize = 256 * 1024 * 1024;
|
||||
/// Lower bound of the pattern work budget regardless of document size.
|
||||
const MIN_PATTERN_WORK_BYTES: usize = 8 * 1024 * 1024;
|
||||
/// Bound viewport-specific pattern results; uncached resolutions remain covered by the work budget.
|
||||
const MAX_PATTERN_CACHE_ENTRIES: usize = 256;
|
||||
|
||||
/// The deepest element nesting of `text` (a quick scan that skips comments, CDATA, processing
|
||||
/// instructions, declarations and quoted attribute values).
|
||||
@@ -1317,6 +1806,8 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
let vb = viewbox(root);
|
||||
// Room for generous reuse, and always for every element of the file itself.
|
||||
let budget = MAX_EXPANDED_ELEMENTS.max(root.descendants().filter(|n| n.is_element()).count().saturating_mul(2));
|
||||
let pattern_work_budget = bytes.len().saturating_mul(8).clamp(MIN_PATTERN_WORK_BYTES, MAX_PATTERN_WORK_BYTES);
|
||||
let path_work_budget = pattern_work_budget;
|
||||
let mut p = Parser {
|
||||
ids,
|
||||
css,
|
||||
@@ -1327,6 +1818,19 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
work: 0,
|
||||
budget,
|
||||
using: vec![],
|
||||
in_pattern_content: false,
|
||||
invalid_pattern_content: false,
|
||||
pattern_work: 0,
|
||||
pattern_work_budget,
|
||||
pattern_work_exceeded: false,
|
||||
path_data_work: 0,
|
||||
path_element_work: 0,
|
||||
path_work_budget,
|
||||
path_work_exceeded: false,
|
||||
pattern_cache: HashMap::new(),
|
||||
gradient_cache: HashMap::new(),
|
||||
gradient_work: 0,
|
||||
gradient_work_exceeded: false,
|
||||
retained: std::cell::Cell::new(0),
|
||||
};
|
||||
let st = Style::default();
|
||||
@@ -1368,6 +1872,15 @@ pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
|
||||
// Never a silently partial drawing.
|
||||
return Err(Error::Xml(format!("its <use> references expand to more than {} elements", p.budget)));
|
||||
}
|
||||
if p.pattern_work_exceeded {
|
||||
return Err(Error::Xml(format!("resolving its <pattern> references scans more than {} work units", p.pattern_work_budget)));
|
||||
}
|
||||
if p.path_work_exceeded {
|
||||
return Err(Error::Xml("its SVG paths expand beyond the bounded path-data work budget".to_string()));
|
||||
}
|
||||
if p.gradient_work_exceeded {
|
||||
return Err(Error::Xml(format!("resolving its gradients exceeds the {MAX_GRADIENT_WORK}-unit work budget")));
|
||||
}
|
||||
if p.retained.get() > MAX_RETAINED {
|
||||
return Err(Error::Xml(format!("its paths and gradients, with their copies, hold more than {MAX_RETAINED} elements")));
|
||||
}
|
||||
|
||||
+180
-75
@@ -2,7 +2,10 @@
|
||||
//! coordinates, smooth curves and elliptical arcs (converted to cubics through kurbo's `SvgArc`).
|
||||
//! Parsing stops at the first error, keeping what was read (as the specification asks).
|
||||
|
||||
use kurbo::{BezPath, Point, SvgArc, Vec2};
|
||||
use kurbo::{BezPath, PathEl, Point, SvgArc, Vec2};
|
||||
|
||||
/// Maximum path elements retained from one SVG path or point list.
|
||||
pub(crate) const MAX_PATH_ELEMENTS: usize = 100_000;
|
||||
|
||||
/// Tokenizer over numbers and command letters.
|
||||
struct Lex<'a> {
|
||||
@@ -66,7 +69,8 @@ impl Lex<'_> {
|
||||
}
|
||||
}
|
||||
self.i = i;
|
||||
std::str::from_utf8(&b[start..i]).ok()?.parse().ok()
|
||||
let number = std::str::from_utf8(&b[start..i]).ok()?.parse::<f64>().ok()?;
|
||||
number.is_finite().then_some(number)
|
||||
}
|
||||
/// Arc flags may be written without separators ("a1 1 0 00 1 1").
|
||||
fn flag(&mut self) -> Option<bool> {
|
||||
@@ -81,31 +85,63 @@ impl Lex<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// All numbers in a list (`points`, `viewBox`, transform arguments).
|
||||
pub fn numbers(s: &str) -> Vec<f64> {
|
||||
/// Parse no more than `max_values`; the flag reports whether another valid value was present.
|
||||
pub(crate) fn numbers_bounded(s: &str, max_values: usize) -> (Vec<f64>, bool) {
|
||||
let mut l = Lex { b: s.as_bytes(), i: 0 };
|
||||
let mut v = vec![];
|
||||
while l.at_number() {
|
||||
if v.len() >= max_values {
|
||||
return (v, true);
|
||||
}
|
||||
match l.number() {
|
||||
Some(x) => v.push(x),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
v
|
||||
(v, false)
|
||||
}
|
||||
|
||||
/// Parse path data into a kurbo path.
|
||||
/// Parse path data into a kurbo path, retaining at most [`MAX_PATH_ELEMENTS`] elements.
|
||||
/// Malformed or overlong input returns the prefix parsed before the problem.
|
||||
pub fn parse_path_data(d: &str) -> BezPath {
|
||||
parse_path_data_bounded(d, MAX_PATH_ELEMENTS).0
|
||||
}
|
||||
|
||||
/// Pen position and reflection state while parsing path commands.
|
||||
struct PathState {
|
||||
/// Current point.
|
||||
current: Point,
|
||||
/// Start of the current subpath.
|
||||
start: Point,
|
||||
/// Last cubic control point, for smooth curves.
|
||||
last_cubic: Option<Point>,
|
||||
/// Last quadratic control point, for smooth curves.
|
||||
last_quad: Option<Point>,
|
||||
/// Whether a subpath is open.
|
||||
open: bool,
|
||||
}
|
||||
|
||||
/// Cap on path elements and whether it has been hit.
|
||||
struct PathLimit {
|
||||
/// Maximum number of path elements.
|
||||
max_elements: usize,
|
||||
/// Set when the cap or a non-finite coordinate stopped output.
|
||||
exceeded: bool,
|
||||
}
|
||||
|
||||
/// Parse at most `max_elements`, reporting when more path geometry was present.
|
||||
pub(crate) fn parse_path_data_bounded(d: &str, max_elements: usize) -> (BezPath, bool) {
|
||||
let mut l = Lex { b: d.as_bytes(), i: 0 };
|
||||
let mut p = BezPath::new();
|
||||
let mut cur = Point::ZERO;
|
||||
let mut start = Point::ZERO;
|
||||
// Last control point of the previous C/S (cubic) or Q/T (quad) command.
|
||||
let mut last_cubic: Option<Point> = None;
|
||||
let mut last_quad: Option<Point> = None;
|
||||
let mut state = PathState { current: Point::ZERO, start: Point::ZERO, last_cubic: None, last_quad: None, open: false };
|
||||
let mut limit = PathLimit { max_elements, exceeded: false };
|
||||
let mut cmd: Option<u8> = None;
|
||||
let mut open = false;
|
||||
loop {
|
||||
if p.elements().len() >= limit.max_elements {
|
||||
l.skip_sep();
|
||||
limit.exceeded = l.i < l.b.len();
|
||||
break;
|
||||
}
|
||||
let c = match l.command() {
|
||||
Some(c) => c,
|
||||
None => match cmd {
|
||||
@@ -120,103 +156,146 @@ pub fn parse_path_data(d: &str) -> BezPath {
|
||||
},
|
||||
};
|
||||
let rel = c.is_ascii_lowercase();
|
||||
let base = if rel { cur.to_vec2() } else { Vec2::ZERO };
|
||||
let base = if rel { state.current.to_vec2() } else { Vec2::ZERO };
|
||||
let pt = |l: &mut Lex| -> Option<Point> { Some(Point::new(l.number()?, l.number()?) + base) };
|
||||
let ok = (|| -> Option<()> {
|
||||
match c.to_ascii_uppercase() {
|
||||
b'M' => {
|
||||
cur = pt(&mut l)?;
|
||||
p.move_to(cur);
|
||||
start = cur;
|
||||
open = true;
|
||||
last_cubic = None;
|
||||
last_quad = None;
|
||||
state.current = pt(&mut l)?;
|
||||
if !push_element(&mut p, PathEl::MoveTo(state.current), &mut limit) {
|
||||
return None;
|
||||
}
|
||||
state.start = state.current;
|
||||
state.open = true;
|
||||
state.last_cubic = None;
|
||||
state.last_quad = None;
|
||||
}
|
||||
b'Z' => {
|
||||
if open {
|
||||
p.close_path();
|
||||
if state.open && !push_element(&mut p, PathEl::ClosePath, &mut limit) {
|
||||
return None;
|
||||
}
|
||||
cur = start;
|
||||
last_cubic = None;
|
||||
last_quad = None;
|
||||
state.current = state.start;
|
||||
state.last_cubic = None;
|
||||
state.last_quad = None;
|
||||
}
|
||||
_ if !open => {
|
||||
_ if !state.open => {
|
||||
// A segment without a preceding moveto starts at the current point.
|
||||
p.move_to(cur);
|
||||
open = true;
|
||||
return segment(&mut l, c, base, &mut p, &mut cur, &mut last_cubic, &mut last_quad);
|
||||
if !push_element(&mut p, PathEl::MoveTo(state.current), &mut limit) {
|
||||
return None;
|
||||
}
|
||||
state.open = true;
|
||||
return segment(&mut l, c, base, &mut p, &mut state, &mut limit);
|
||||
}
|
||||
_ => return segment(&mut l, c, base, &mut p, &mut cur, &mut last_cubic, &mut last_quad),
|
||||
_ => return segment(&mut l, c, base, &mut p, &mut state, &mut limit),
|
||||
}
|
||||
Some(())
|
||||
})();
|
||||
if ok.is_none() {
|
||||
break;
|
||||
}
|
||||
if limit.exceeded {
|
||||
break;
|
||||
}
|
||||
cmd = Some(c);
|
||||
if c.eq_ignore_ascii_case(&b'z') {
|
||||
// After Z a new segment starts from the subpath start (an unused move is dropped
|
||||
// below).
|
||||
p.move_to(start);
|
||||
open = true;
|
||||
if !push_element(&mut p, PathEl::MoveTo(state.start), &mut limit) {
|
||||
break;
|
||||
}
|
||||
state.open = true;
|
||||
}
|
||||
}
|
||||
strip_trailing_moves(p)
|
||||
(strip_trailing_moves(p), limit.exceeded)
|
||||
}
|
||||
|
||||
fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, last_cubic: &mut Option<Point>, last_quad: &mut Option<Point>) -> Option<()> {
|
||||
/// Push an element if finite and under the cap; otherwise mark the limit exceeded and return false.
|
||||
fn push_element(p: &mut BezPath, element: PathEl, limit: &mut PathLimit) -> bool {
|
||||
let finite = match element {
|
||||
PathEl::MoveTo(point) | PathEl::LineTo(point) => point.x.is_finite() && point.y.is_finite(),
|
||||
PathEl::QuadTo(control, point) => [control.x, control.y, point.x, point.y].iter().all(|value| value.is_finite()),
|
||||
PathEl::CurveTo(first, second, point) => [first.x, first.y, second.x, second.y, point.x, point.y].iter().all(|value| value.is_finite()),
|
||||
PathEl::ClosePath => true,
|
||||
};
|
||||
if p.elements().len() >= limit.max_elements || !finite {
|
||||
limit.exceeded = true;
|
||||
return false;
|
||||
}
|
||||
p.push(element);
|
||||
true
|
||||
}
|
||||
|
||||
/// Parse one path command's arguments into `p`, returning `None` on malformed or over-limit input.
|
||||
fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, state: &mut PathState, limit: &mut PathLimit) -> Option<()> {
|
||||
let num = |l: &mut Lex| l.number();
|
||||
let pt = |l: &mut Lex| -> Option<Point> { Some(Point::new(l.number()?, l.number()?) + base) };
|
||||
match c.to_ascii_uppercase() {
|
||||
b'L' => {
|
||||
*cur = pt(l)?;
|
||||
p.line_to(*cur);
|
||||
*last_cubic = None;
|
||||
*last_quad = None;
|
||||
let current = pt(l)?;
|
||||
if !push_element(p, PathEl::LineTo(current), limit) {
|
||||
return None;
|
||||
}
|
||||
state.current = current;
|
||||
state.last_cubic = None;
|
||||
state.last_quad = None;
|
||||
}
|
||||
b'H' => {
|
||||
let x = num(l)? + base.x;
|
||||
*cur = Point::new(x, cur.y);
|
||||
p.line_to(*cur);
|
||||
*last_cubic = None;
|
||||
*last_quad = None;
|
||||
let current = Point::new(x, state.current.y);
|
||||
if !push_element(p, PathEl::LineTo(current), limit) {
|
||||
return None;
|
||||
}
|
||||
state.current = current;
|
||||
state.last_cubic = None;
|
||||
state.last_quad = None;
|
||||
}
|
||||
b'V' => {
|
||||
let y = num(l)? + base.y;
|
||||
*cur = Point::new(cur.x, y);
|
||||
p.line_to(*cur);
|
||||
*last_cubic = None;
|
||||
*last_quad = None;
|
||||
let current = Point::new(state.current.x, y);
|
||||
if !push_element(p, PathEl::LineTo(current), limit) {
|
||||
return None;
|
||||
}
|
||||
state.current = current;
|
||||
state.last_cubic = None;
|
||||
state.last_quad = None;
|
||||
}
|
||||
b'C' => {
|
||||
let (c1, c2, e) = (pt(l)?, pt(l)?, pt(l)?);
|
||||
p.curve_to(c1, c2, e);
|
||||
*last_cubic = Some(c2);
|
||||
*last_quad = None;
|
||||
*cur = e;
|
||||
if !push_element(p, PathEl::CurveTo(c1, c2, e), limit) {
|
||||
return None;
|
||||
}
|
||||
state.last_cubic = Some(c2);
|
||||
state.last_quad = None;
|
||||
state.current = e;
|
||||
}
|
||||
b'S' => {
|
||||
let c1 = last_cubic.map_or(*cur, |c| *cur + (*cur - c));
|
||||
let c1 = state.last_cubic.map_or(state.current, |c| state.current + (state.current - c));
|
||||
let (c2, e) = (pt(l)?, pt(l)?);
|
||||
p.curve_to(c1, c2, e);
|
||||
*last_cubic = Some(c2);
|
||||
*last_quad = None;
|
||||
*cur = e;
|
||||
if !push_element(p, PathEl::CurveTo(c1, c2, e), limit) {
|
||||
return None;
|
||||
}
|
||||
state.last_cubic = Some(c2);
|
||||
state.last_quad = None;
|
||||
state.current = e;
|
||||
}
|
||||
b'Q' => {
|
||||
let (q, e) = (pt(l)?, pt(l)?);
|
||||
p.quad_to(q, e);
|
||||
*last_quad = Some(q);
|
||||
*last_cubic = None;
|
||||
*cur = e;
|
||||
if !push_element(p, PathEl::QuadTo(q, e), limit) {
|
||||
return None;
|
||||
}
|
||||
state.last_quad = Some(q);
|
||||
state.last_cubic = None;
|
||||
state.current = e;
|
||||
}
|
||||
b'T' => {
|
||||
let q = last_quad.map_or(*cur, |c| *cur + (*cur - c));
|
||||
let q = state.last_quad.map_or(state.current, |c| state.current + (state.current - c));
|
||||
let e = pt(l)?;
|
||||
p.quad_to(q, e);
|
||||
*last_quad = Some(q);
|
||||
*last_cubic = None;
|
||||
*cur = e;
|
||||
if !push_element(p, PathEl::QuadTo(q, e), limit) {
|
||||
return None;
|
||||
}
|
||||
state.last_quad = Some(q);
|
||||
state.last_cubic = None;
|
||||
state.current = e;
|
||||
}
|
||||
b'A' => {
|
||||
let rx = num(l)?.abs();
|
||||
@@ -225,23 +304,30 @@ fn segment(l: &mut Lex, c: u8, base: Vec2, p: &mut BezPath, cur: &mut Point, las
|
||||
let large = l.flag()?;
|
||||
let sweep = l.flag()?;
|
||||
let e = pt(l)?;
|
||||
let arc = SvgArc { from: *cur, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep };
|
||||
let arc = SvgArc { from: state.current, to: e, radii: Vec2::new(rx, ry), x_rotation: rot.to_radians(), large_arc: large, sweep };
|
||||
let mut last_end = None;
|
||||
if let Some(a) = kurbo::Arc::from_svg_arc(&arc) {
|
||||
// Scale tolerance with the radius so the segment count stays bounded.
|
||||
let tol = 0.1_f64.max(a.radii.x.max(a.radii.y) * 1e-4);
|
||||
a.to_cubic_beziers(tol, |c1, c2, end| {
|
||||
p.curve_to(c1, c2, end);
|
||||
last_end = Some(end);
|
||||
if limit.exceeded {
|
||||
return;
|
||||
}
|
||||
if push_element(p, PathEl::CurveTo(c1, c2, end), limit) {
|
||||
last_end = Some(end);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point.
|
||||
if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) {
|
||||
p.line_to(e);
|
||||
if limit.exceeded {
|
||||
return None;
|
||||
}
|
||||
*cur = e;
|
||||
*last_cubic = None;
|
||||
*last_quad = None;
|
||||
// Degenerate arcs (e.g. huge radii) may emit nothing or stop short of the end point.
|
||||
if last_end.is_none_or(|q| (q - e).hypot() > 1e-3 * (1.0 + e.to_vec2().hypot())) && !push_element(p, PathEl::LineTo(e), limit) {
|
||||
return None;
|
||||
}
|
||||
state.current = e;
|
||||
state.last_cubic = None;
|
||||
state.last_quad = None;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
@@ -271,6 +357,7 @@ mod tests {
|
||||
use kurbo::PathEl;
|
||||
|
||||
#[test]
|
||||
/// Huge arc radii stay bounded.
|
||||
fn huge_arc_radius_stays_bounded() {
|
||||
let p = parse_path_data("M0 0A1e36 1e36 0 1 1 1e30 0");
|
||||
assert!(p.elements().len() < 1000, "got {} elements", p.elements().len());
|
||||
@@ -310,6 +397,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Huge-radius arcs still reach their end point.
|
||||
fn huge_radius_arc_still_reaches_end_point() {
|
||||
for d in ["M0 0 A1e20 1e20 0 0 1 10 0 L10 10", "M0 0 A1e20 1e20 0 1 1 10 0 L10 10", "M0 0 a1e20 1e20 0 0 1 10 0 L10 10"] {
|
||||
let p = parse_path_data(d);
|
||||
@@ -323,8 +411,25 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn number_lists() {
|
||||
assert_eq!(numbers("0 0,100 50"), vec![0.0, 0.0, 100.0, 50.0]);
|
||||
assert_eq!(numbers("-1-2.5.5"), vec![-1.0, -2.5, 0.5]);
|
||||
assert_eq!(numbers("1e2,2E-1"), vec![100.0, 0.2]);
|
||||
assert_eq!(numbers_bounded("0 0,100 50", 8).0, vec![0.0, 0.0, 100.0, 50.0]);
|
||||
assert_eq!(numbers_bounded("-1-2.5.5", 8).0, vec![-1.0, -2.5, 0.5]);
|
||||
assert_eq!(numbers_bounded("1e2,2E-1", 8).0, vec![100.0, 0.2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The element cap stops expansion and reports truncation.
|
||||
fn path_element_limit_stops_expansion_and_reports_truncation() {
|
||||
let data = format!("M0 0{}", "h1".repeat(MAX_PATH_ELEMENTS + 1));
|
||||
let (path, exceeded) = parse_path_data_bounded(&data, 32);
|
||||
assert!(exceeded);
|
||||
assert!(path.elements().len() <= 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// The number list cap stops retaining values.
|
||||
fn number_list_limit_stops_retaining_values() {
|
||||
let (values, exceeded) = numbers_bounded("1 2 3 4", 3);
|
||||
assert_eq!(values, [1.0, 2.0, 3.0]);
|
||||
assert!(exceeded);
|
||||
}
|
||||
}
|
||||
+928
-73
File diff suppressed because it is too large.
Load diff
+668
-3
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use kurbo::Shape as _;
|
||||
|
||||
const BASIC: &[u8] = include_bytes!("../tests/fixtures/basic-shapes.svg");
|
||||
const PATHS: &[u8] = include_bytes!("../tests/fixtures/paths-gradients.svg");
|
||||
@@ -28,6 +29,426 @@ fn raster_surface_budget_rejects_oversized_destination_before_allocation() {
|
||||
let img = rasterize(&doc, 16_384, 16_384, 1.0);
|
||||
assert!(img.data.is_empty());
|
||||
assert_eq!((img.width, img.height), (0, 0));
|
||||
assert!(matches!(try_rasterize(&doc, 16_384, 16_384, 1.0), Err(RasterError::SurfaceLimit(_))), "the refusal is reported, not silent");
|
||||
}
|
||||
|
||||
/// Pattern fills and strokes repeat from the tile origin.
|
||||
#[test]
|
||||
fn static_pattern_fill_and_stroke_repeat_with_tile_origin() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="4">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" x="1" width="2" height="2">
|
||||
<rect x="1" width="1" height="2" fill="red"/>
|
||||
<rect x="2" width="1" height="2" fill="blue"/>
|
||||
</pattern></defs>
|
||||
<rect id="filled" width="8" height="4" fill="url(#p)"/>
|
||||
<path d="M0 3 H8" fill="none" stroke="url(#p)" stroke-width="2"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
assert!(matches!(shapes[0].2.fill.as_ref(), Some(Paint::Pattern(_))));
|
||||
assert!(matches!(shapes[1].2.stroke.as_ref().map(|s| &s.paint), Some(Paint::Pattern(_))));
|
||||
|
||||
let img = rasterize(&doc, 8, 4, 1.0);
|
||||
// The tile occupies [1, 3): blue wraps into x=0, red starts at x=1.
|
||||
assert!(near(px(&img, 0, 1), [0.0, 0.0, 1.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 1, 1), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 2, 1), [0.0, 0.0, 1.0, 1.0], 1e-3));
|
||||
// The same pattern server paints the stroke.
|
||||
assert!(near(px(&img, 1, 2), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// objectBoundingBox pattern units map tile and content coordinates to the shape's box.
|
||||
#[test]
|
||||
fn object_bounding_box_pattern_units_map_tile_and_content_coordinates() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="8">
|
||||
<defs><pattern id="p" patternUnits="objectBoundingBox" width="0.5" height="1" patternContentUnits="objectBoundingBox">
|
||||
<rect width="25%" height="100%" fill="green"/>
|
||||
</pattern></defs>
|
||||
<rect x="10" y="2" width="8" height="4" fill="url(#p)" stroke="url(#p)" stroke-width="1"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
assert!(matches!(shapes[0].2.fill.as_ref(), Some(Paint::Pattern(_))));
|
||||
assert!(matches!(shapes[0].2.stroke.as_ref().map(|stroke| &stroke.paint), Some(Paint::Pattern(_))));
|
||||
let img = rasterize(&doc, 20, 8, 1.0);
|
||||
assert!(near(px(&img, 10, 3), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 12, 3)[3], 0.0);
|
||||
assert!(near(px(&img, 14, 3), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 16, 3)[3], 0.0);
|
||||
}
|
||||
|
||||
/// patternTransform scales tile spacing and content together.
|
||||
#[test]
|
||||
fn pattern_transform_scales_tile_spacing_and_its_content_together() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="8" height="2">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="2" height="2" patternTransform="scale(2)">
|
||||
<rect width="1" height="2" fill="red"/>
|
||||
</pattern></defs>
|
||||
<rect width="8" height="2" fill="url(#p)"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 8, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 1, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 2, 0)[3], 0.0);
|
||||
assert_eq!(px(&img, 3, 0)[3], 0.0);
|
||||
assert!(near(px(&img, 4, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// A pattern href inherits units and content, and plain `href` wins over `xlink:href`.
|
||||
#[test]
|
||||
fn pattern_href_inherits_units_and_content_and_prefers_plain_href() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="8" height="2">
|
||||
<defs>
|
||||
<pattern id="base" patternUnits="userSpaceOnUse" width="2" height="2">
|
||||
<rect width="1" height="2" fill="red"/>
|
||||
</pattern>
|
||||
<pattern id="other" patternUnits="userSpaceOnUse" width="2" height="2">
|
||||
<rect width="2" height="2" fill="blue"/>
|
||||
</pattern>
|
||||
<pattern id="derived" href="#base" xlink:href="#other"/>
|
||||
<pattern id="xlink-derived" xlink:href="#base"/>
|
||||
</defs>
|
||||
<rect width="8" height="2" fill="url(#derived)"/>
|
||||
<rect x="8" width="8" height="2" fill="url(#xlink-derived)"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 16, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 1, 0)[3], 0.0);
|
||||
assert!(near(px(&img, 2, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 8, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 9, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
/// Pattern content inherits styles from the pattern's ancestors.
|
||||
#[test]
|
||||
fn pattern_content_inherits_styles_from_its_ancestors() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2" fill="red">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="2" height="2">
|
||||
<rect width="2" height="2"/>
|
||||
</pattern></defs>
|
||||
<rect width="2" height="2" fill="url(#p) blue"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 2, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// Content reached through `href` inherits styles from the pattern that owns it, not the referrer.
|
||||
#[test]
|
||||
fn pattern_href_content_inherits_styles_from_the_owning_pattern() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
|
||||
<defs>
|
||||
<g fill="red"><pattern id="base" patternUnits="userSpaceOnUse" width="2" height="2">
|
||||
<rect width="2" height="2"/>
|
||||
</pattern></g>
|
||||
<pattern id="derived" href="#base"/>
|
||||
</defs>
|
||||
<rect width="2" height="2" fill="url(#derived)"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 2, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// Descriptive children (title, desc) do not count as inheritable pattern content.
|
||||
#[test]
|
||||
fn pattern_href_ignores_descriptive_children_when_inheriting_content() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
|
||||
<defs>
|
||||
<pattern id="base" patternUnits="userSpaceOnUse" width="2" height="2">
|
||||
<rect width="2" height="2" fill="red"/>
|
||||
</pattern>
|
||||
<pattern id="derived" href="#base">
|
||||
<title>Documentation only</title>
|
||||
<desc>Still documentation only</desc>
|
||||
<metadata><author>Test</author></metadata>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="2" height="2" fill="url(#derived)"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 2, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// The pattern cache is shared for equal viewports but split for different percentage-sized ones.
|
||||
#[test]
|
||||
fn cached_patterns_reuse_same_viewport_but_separate_percentage_sized_viewports() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="120" height="10" viewBox="0 0 120 10">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
|
||||
<rect width="4" height="100%" fill="red"/>
|
||||
</pattern></defs>
|
||||
<svg width="40" height="10" viewBox="0 0 40 10">
|
||||
<rect id="small-one" width="20" height="10" fill="url(#p) blue"/>
|
||||
<rect id="small-two" x="20" width="20" height="10" fill="url(#p) green"/>
|
||||
</svg>
|
||||
<svg x="40" width="80" height="10" viewBox="0 0 80 10">
|
||||
<rect id="large" width="80" height="10" fill="url(#p) yellow"/>
|
||||
</svg>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
let Some((_, _, small_one)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-one") else { panic!("small-one expected") };
|
||||
let Some(Paint::Pattern(small_one)) = small_one.fill.as_ref() else { panic!("small-one pattern expected") };
|
||||
let Some((_, _, small_two)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-two") else { panic!("small-two expected") };
|
||||
let Some(Paint::Pattern(small_two)) = small_two.fill.as_ref() else { panic!("small-two pattern expected") };
|
||||
let Some((_, _, large)) = shapes.iter().find(|(_, _, shape)| shape.name == "large") else { panic!("large expected") };
|
||||
let Some(Paint::Pattern(large)) = large.fill.as_ref() else { panic!("large pattern expected") };
|
||||
assert_eq!(small_one.width, 20.0);
|
||||
assert_eq!(small_two.width, 20.0);
|
||||
assert_eq!(large.width, 40.0);
|
||||
assert_eq!(small_one.fallback, Some([0.0, 0.0, 1.0, 1.0]));
|
||||
assert_eq!(small_two.fallback, Some([0.0, 128.0 / 255.0, 0.0, 1.0]));
|
||||
assert_eq!(large.fallback, Some([1.0, 1.0, 0.0, 1.0]));
|
||||
assert!(std::sync::Arc::ptr_eq(&small_one.content, &small_two.content));
|
||||
assert!(!std::sync::Arc::ptr_eq(&small_one.content, &large.content));
|
||||
|
||||
let img = rasterize(&doc, 120, 10, 1.0);
|
||||
for x in [1, 21, 41, 81] {
|
||||
assert!(near(px(&img, x, 5), [1.0, 0.0, 0.0, 1.0], 1e-3), "x={x}");
|
||||
}
|
||||
for x in [5, 25, 45, 85] {
|
||||
assert_eq!(px(&img, x, 5)[3], 0.0, "x={x}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Different symbol viewports use distinct pattern cache entries for percentage sizes.
|
||||
#[test]
|
||||
fn symbol_viewports_select_distinct_percentage_sized_pattern_cache_entries() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="60" height="10">
|
||||
<defs>
|
||||
<pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
|
||||
<rect width="4" height="100%" fill="red"/>
|
||||
</pattern>
|
||||
<symbol id="small-symbol" viewBox="0 0 20 10">
|
||||
<rect id="small-symbol-shape" width="20" height="10" fill="url(#p) blue"/>
|
||||
</symbol>
|
||||
<symbol id="large-symbol" viewBox="0 0 40 10">
|
||||
<rect id="large-symbol-shape" width="40" height="10" fill="url(#p) green"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
<use href="#small-symbol" width="20" height="10"/>
|
||||
<use href="#large-symbol" x="20" width="40" height="10"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
let Some((_, _, small)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-symbol-shape") else { panic!("small symbol shape expected") };
|
||||
let Some(Paint::Pattern(small)) = small.fill.as_ref() else { panic!("small symbol pattern expected") };
|
||||
let Some((_, _, large)) = shapes.iter().find(|(_, _, shape)| shape.name == "large-symbol-shape") else { panic!("large symbol shape expected") };
|
||||
let Some(Paint::Pattern(large)) = large.fill.as_ref() else { panic!("large symbol pattern expected") };
|
||||
assert_eq!(small.width, 10.0);
|
||||
assert_eq!(large.width, 20.0);
|
||||
|
||||
let img = rasterize(&doc, 60, 10, 1.0);
|
||||
assert!(near(px(&img, 1, 5), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 5, 5)[3], 0.0);
|
||||
assert!(near(px(&img, 21, 5), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert_eq!(px(&img, 25, 5)[3], 0.0);
|
||||
assert!(near(px(&img, 41, 5), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// Bare `url()` and href values without `#` do not resolve to local pattern ids.
|
||||
#[test]
|
||||
fn bare_urls_and_hrefs_do_not_resolve_as_local_pattern_ids() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="6" height="2">
|
||||
<defs>
|
||||
<pattern id="target" patternUnits="userSpaceOnUse" width="2" height="2">
|
||||
<rect width="2" height="2" fill="red"/>
|
||||
</pattern>
|
||||
<pattern id="bare-href" href="target" width="2" height="2"/>
|
||||
</defs>
|
||||
<rect width="2" height="2" fill="url(target) blue"/>
|
||||
<rect x="2" width="2" height="2" fill="url(#bare-href) green"/>
|
||||
<rect x="4" width="2" height="2" fill="url(#target)"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
assert_eq!(shapes[0].2.fill, Some(Paint::Color([0.0, 0.0, 1.0])));
|
||||
assert_eq!(shapes[1].2.fill, Some(Paint::Color([0.0, 128.0 / 255.0, 0.0])));
|
||||
assert!(matches!(shapes[2].2.fill.as_ref(), Some(Paint::Pattern(_))));
|
||||
let img = rasterize(&doc, 6, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [0.0, 0.0, 1.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 2, 0), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 4, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// A valid but empty pattern paints nothing rather than its fallback.
|
||||
#[test]
|
||||
fn empty_valid_pattern_is_transparent_instead_of_using_its_fallback() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="2">
|
||||
<defs><pattern id="empty" patternUnits="userSpaceOnUse" width="2" height="2"/></defs>
|
||||
<rect width="4" height="2" fill="url(#empty) red"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let Some(Paint::Pattern(pattern)) = doc.flatten()[0].2.fill.as_ref() else { panic!("valid pattern expected") };
|
||||
assert_eq!(pattern.fallback, Some([1.0, 0.0, 0.0, 1.0]));
|
||||
let img = rasterize(&doc, 4, 2, 1.0);
|
||||
assert_eq!(px(&img, 0, 0)[3], 0.0);
|
||||
assert_eq!(px(&img, 3, 0)[3], 0.0);
|
||||
}
|
||||
|
||||
/// Pattern content attribute work stays bounded across many distinct viewports.
|
||||
#[test]
|
||||
fn pattern_content_attribute_work_is_bounded_across_distinct_viewports() {
|
||||
let large_attribute = "x".repeat(2 * 1024 * 1024);
|
||||
let shapes: String = (0..12)
|
||||
.map(|index| format!(r##"<svg x="{index}" width="1" height="1" viewBox="0 0 {} 1"><rect width="1" height="1" fill="url(#p)"/></svg>"##, index + 1))
|
||||
.collect();
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="1"><defs>
|
||||
<pattern id="p" data-repeat="{large_attribute}" patternUnits="userSpaceOnUse" width="50%" height="100%">
|
||||
<rect width="1" height="1" fill="red"/>
|
||||
</pattern></defs>{shapes}</svg>"##
|
||||
);
|
||||
let error = parse(svg.as_bytes()).unwrap_err();
|
||||
assert!(error.to_string().contains("<pattern> references scans more than"), "{error}");
|
||||
}
|
||||
|
||||
/// Exceeding the pattern href depth limit uses the paint fallback.
|
||||
#[test]
|
||||
fn pattern_href_depth_limit_uses_paint_fallback() {
|
||||
let patterns: String = (0..=16)
|
||||
.map(|index| {
|
||||
if index == 16 {
|
||||
format!(r#"<pattern id="p{index}" patternUnits="userSpaceOnUse" width="1" height="1"/>"#)
|
||||
} else {
|
||||
format!(r##"<pattern id="p{index}" href="#p{}"/>"##, index + 1)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><defs>{patterns}</defs><rect width="1" height="1" fill="url(#p0) blue"/></svg>"##
|
||||
);
|
||||
let doc = parse(svg.as_bytes()).unwrap();
|
||||
assert_eq!(doc.flatten()[0].2.fill, Some(Paint::Color([0.0, 0.0, 1.0])));
|
||||
}
|
||||
|
||||
/// Ancestor attribute work for patterns stays bounded across many distinct viewports.
|
||||
#[test]
|
||||
fn pattern_ancestor_attribute_work_is_bounded_across_distinct_viewports() {
|
||||
let large_attribute = "x".repeat(2 * 1024 * 1024);
|
||||
let shapes: String = (0..12)
|
||||
.map(|index| format!(r##"<svg x="{index}" width="1" height="1" viewBox="0 0 {} 1"><rect width="1" height="1" fill="url(#p)"/></svg>"##, index + 1))
|
||||
.collect();
|
||||
let svg = format!(
|
||||
r##"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="1"><defs data-repeat="{large_attribute}">
|
||||
<pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
|
||||
<rect width="1" height="1" fill="red"/>
|
||||
</pattern></defs>{shapes}</svg>"##
|
||||
);
|
||||
let error = parse(svg.as_bytes()).unwrap_err();
|
||||
assert!(error.to_string().contains("<pattern> references scans more than"), "{error}");
|
||||
}
|
||||
|
||||
/// Pattern tile alpha composites as premultiplied color.
|
||||
#[test]
|
||||
fn pattern_tile_alpha_composites_as_premultiplied_color() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1">
|
||||
<rect width="1" height="1" fill="red" fill-opacity="0.5"/>
|
||||
</pattern></defs>
|
||||
<rect width="2" height="2" fill="url(#p)"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 2, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [0.5, 0.0, 0.0, 0.5], 1e-3));
|
||||
assert!(near(px(&img, 1, 1), [0.5, 0.0, 0.0, 0.5], 1e-3));
|
||||
}
|
||||
|
||||
/// An oversized pattern tile uses the fallback without allocating it.
|
||||
#[test]
|
||||
fn oversized_pattern_tile_falls_back_without_allocating() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1">
|
||||
<defs><pattern id="huge" patternUnits="userSpaceOnUse" width="1e300" height="1e300">
|
||||
<rect width="1" height="1" fill="red"/>
|
||||
</pattern></defs>
|
||||
<rect width="1" height="1" fill="url(#huge) gold"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 1, 1, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [1.0, 215.0 / 255.0, 0.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// Invalid or cyclic patterns use the paint fallback without being expanded.
|
||||
#[test]
|
||||
fn invalid_or_cyclic_pattern_uses_paint_fallback_without_expanding() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="2">
|
||||
<defs>
|
||||
<pattern id="bad" width="0" height="1"/>
|
||||
<pattern id="viewbox" width="1" height="1" viewBox="0 0 1 1"/>
|
||||
<pattern id="collapsed" patternUnits="userSpaceOnUse" width="1" height="1" patternTransform="scale(0)"/>
|
||||
<pattern id="a" href="#b"/>
|
||||
<pattern id="b" href="#a"/>
|
||||
<pattern id="self" href="#self"/>
|
||||
</defs>
|
||||
<rect width="2" height="2" fill="url(#bad) blue"/>
|
||||
<rect x="2" width="2" height="2" fill="url(#viewbox) red"/>
|
||||
<rect x="4" width="2" height="2" fill="url(#collapsed) yellow"/>
|
||||
<rect x="6" width="2" height="2" fill="url(#a) green"/>
|
||||
<rect x="8" width="2" height="2" fill="url(#self) cyan"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
assert_eq!(shapes[0].2.fill, Some(Paint::Color([0.0, 0.0, 1.0])));
|
||||
assert_eq!(shapes[1].2.fill, Some(Paint::Color([1.0, 0.0, 0.0])));
|
||||
assert_eq!(shapes[2].2.fill, Some(Paint::Color([1.0, 1.0, 0.0])));
|
||||
assert_eq!(shapes[3].2.fill, Some(Paint::Color([0.0, 128.0 / 255.0, 0.0])));
|
||||
assert_eq!(shapes[4].2.fill, Some(Paint::Color([0.0, 1.0, 1.0])));
|
||||
let img = rasterize(&doc, 10, 2, 1.0);
|
||||
assert!(near(px(&img, 0, 0), [0.0, 0.0, 1.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 3, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 5, 0), [1.0, 1.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 7, 0), [0.0, 128.0 / 255.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 9, 0), [0.0, 1.0, 1.0, 1.0], 1e-3));
|
||||
}
|
||||
|
||||
/// A one-pixel pattern tiles a large rectangle from a single surface.
|
||||
#[test]
|
||||
fn one_pixel_pattern_tiles_a_large_rectangle_from_one_surface() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1">
|
||||
<rect width="1" height="1" fill="red"/>
|
||||
</pattern></defs>
|
||||
<rect width="1024" height="1024" fill="url(#p)"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 1024, 1024, 1.0);
|
||||
assert_eq!((img.width, img.height), (1024, 1024));
|
||||
for (x, y) in [(0, 0), (511, 511), (1023, 1023)] {
|
||||
assert!(near(px(&img, x, y), [1.0, 0.0, 0.0, 1.0], 1e-3), "({x}, {y})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -105,6 +526,41 @@ fn paths_gradients_css_use() {
|
||||
assert!(c[0] > 0.9 && c[3] > 0.9, "{c:?}");
|
||||
}
|
||||
|
||||
/// Repeated gradient paints share one cached stop list.
|
||||
#[test]
|
||||
fn repeated_gradient_paints_share_their_cached_stop_storage() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="2">
|
||||
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0" stop-color="red"/><stop offset="1" stop-color="blue"/>
|
||||
</linearGradient></defs>
|
||||
<rect id="first" width="2" height="2" fill="url(#g)"/>
|
||||
<rect id="second" x="2" width="2" height="2" fill="url(#g)"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
let Some((_, _, first)) = shapes.iter().find(|(_, _, shape)| shape.name == "first") else { panic!("first shape expected") };
|
||||
let Some(Paint::Gradient(first_gradient)) = first.fill.as_ref() else { panic!("first gradient expected") };
|
||||
let Some((_, _, second)) = shapes.iter().find(|(_, _, shape)| shape.name == "second") else { panic!("second shape expected") };
|
||||
let Some(Paint::Gradient(second_gradient)) = second.fill.as_ref() else { panic!("second gradient expected") };
|
||||
assert!(std::sync::Arc::ptr_eq(&first_gradient.stops, &second_gradient.stops));
|
||||
}
|
||||
|
||||
/// Oversized path geometry fails with a bounded import error.
|
||||
#[test]
|
||||
fn oversized_svg_path_geometry_fails_with_a_bounded_import_error() {
|
||||
let data = format!("M0 0{}", "h1".repeat(pathdata::MAX_PATH_ELEMENTS + 1));
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="{data}"/></svg>"#);
|
||||
let error = parse(svg.as_bytes()).unwrap_err();
|
||||
assert!(error.to_string().contains("bounded path-data work budget"), "{error}");
|
||||
|
||||
let points = (0..=pathdata::MAX_PATH_ELEMENTS).map(|x| format!("{x} 0")).collect::<Vec<_>>().join(" ");
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><polyline points="{points}"/></svg>"#);
|
||||
let error = parse(svg.as_bytes()).unwrap_err();
|
||||
assert!(error.to_string().contains("bounded path-data work budget"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_svg_percentages_use_the_document_viewport() {
|
||||
for (attrs, width, height) in [
|
||||
@@ -160,6 +616,72 @@ fn nested_svg_percentages_use_the_nearest_viewport() {
|
||||
assert_eq!(px(&img, 55, 65)[3], 0.0);
|
||||
}
|
||||
|
||||
/// A symbol viewport sets percentage pattern units and the cache key.
|
||||
#[test]
|
||||
fn symbol_viewport_sets_percentage_pattern_units_and_cache_key() {
|
||||
let doc = parse(
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="60" height="10">
|
||||
<defs>
|
||||
<pattern id="p" patternUnits="userSpaceOnUse" width="50%" height="100%">
|
||||
<rect width="4" height="100%" fill="red"/>
|
||||
</pattern>
|
||||
<symbol id="small" viewBox="0 0 20 10">
|
||||
<rect id="small-shape" width="20" height="10" fill="url(#p)"/>
|
||||
</symbol>
|
||||
<symbol id="large" viewBox="0 0 40 10">
|
||||
<rect id="large-shape" width="40" height="10" fill="url(#p)"/>
|
||||
</symbol>
|
||||
</defs>
|
||||
<use href="#small" width="20" height="10"/>
|
||||
<use href="#large" x="20" width="40" height="10"/>
|
||||
</svg>"##,
|
||||
)
|
||||
.unwrap();
|
||||
let shapes = doc.flatten();
|
||||
let Some((_, _, small)) = shapes.iter().find(|(_, _, shape)| shape.name == "small-shape") else { panic!("small symbol shape expected") };
|
||||
let Some(Paint::Pattern(small)) = small.fill.as_ref() else { panic!("small symbol pattern expected") };
|
||||
let Some((_, _, large)) = shapes.iter().find(|(_, _, shape)| shape.name == "large-shape") else { panic!("large symbol shape expected") };
|
||||
let Some(Paint::Pattern(large)) = large.fill.as_ref() else { panic!("large symbol pattern expected") };
|
||||
assert_eq!(small.width, 10.0);
|
||||
assert_eq!(large.width, 20.0);
|
||||
}
|
||||
|
||||
/// Non-finite opacity values never write NaN pixels.
|
||||
#[test]
|
||||
fn non_finite_opacity_values_do_not_write_nan_pixels() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="6" height="2">
|
||||
<defs><linearGradient id="g"><stop offset="0" stop-color="red" stop-opacity="NaN"/><stop offset="1" stop-color="blue"/></linearGradient></defs>
|
||||
<rect width="2" height="2" fill="red" fill-opacity="NaN"/>
|
||||
<rect x="2" width="2" height="2" fill="blue" opacity="1e400"/>
|
||||
<rect x="4" width="2" height="2" fill="url(#g)"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let img = rasterize(&doc, 6, 2, 1.0);
|
||||
assert!(img.data.iter().flatten().all(|channel| channel.is_finite()));
|
||||
assert!(near(px(&img, 0, 0), [1.0, 0.0, 0.0, 1.0], 1e-3));
|
||||
assert!(near(px(&img, 3, 1), [0.0, 0.0, 1.0, 1.0], 1e-3));
|
||||
assert!(px(&img, 4, 1)[3].is_finite());
|
||||
}
|
||||
|
||||
/// Basic shapes whose derived bounds overflow are skipped.
|
||||
#[test]
|
||||
fn basic_shapes_with_overflowing_derived_bounds_are_skipped() {
|
||||
let doc = parse(
|
||||
br#"<svg xmlns="http://www.w3.org/2000/svg" width="2" height="2">
|
||||
<rect x="1e308" y="0" width="1e308" height="1" fill="red"/>
|
||||
<circle cx="1e308" cy="0" r="1e308" fill="blue"/>
|
||||
<circle cx="0" cy="0" r="1e300" fill="green"/>
|
||||
</svg>"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(doc.flatten().is_empty());
|
||||
let image = rasterize(&doc, 2, 2, 1.0);
|
||||
assert!(image.data.iter().flatten().all(|channel| channel.is_finite()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_viewboxes_do_not_panic_or_produce_nan_geometry() {
|
||||
for attrs in ["viewBox=\"0 0 1e400 100\"", "viewBox=\"0 0 100 1e400\"", "viewBox=\"-1e400 0 100 100\""] {
|
||||
@@ -447,13 +969,156 @@ fn at_rule_blocks_are_skipped_by_brace_depth() {
|
||||
assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]);
|
||||
}
|
||||
|
||||
/// Build a `w` x `h` document from `nodes` for sparse-content render tests.
|
||||
fn sparse_doc(w: f64, h: f64, nodes: Vec<Node>) -> Doc {
|
||||
let mut root = Group::new("root");
|
||||
root.children = nodes;
|
||||
Doc { width: w, height: h, root, skipped: Vec::new() }
|
||||
}
|
||||
|
||||
/// A tiny image on a large canvas is still drawn.
|
||||
#[test]
|
||||
fn gradient_stops_copied_past_the_budget_are_refused() {
|
||||
// 4,000 stops referenced by 2,100 shapes would keep 8.4 million stops.
|
||||
fn sparse_minified_image_on_a_large_canvas_is_still_drawn() {
|
||||
let image = Image {
|
||||
name: "i".into(),
|
||||
transform: Affine::scale(1.0 / 8.0),
|
||||
width: 8,
|
||||
height: 8,
|
||||
rgba: std::sync::Arc::new([255u8, 0, 0, 255].repeat(64)),
|
||||
opacity: 1.0,
|
||||
};
|
||||
let doc = sparse_doc(2049.0, 2049.0, vec![Node::Image(image)]);
|
||||
let img = try_rasterize(&doc, 2049, 2049, 1.0).expect("sparse image is within budget");
|
||||
assert!(px(&img, 0, 0)[3] > 0.99, "the covered pixel must not be dropped by whole-canvas budgeting");
|
||||
}
|
||||
|
||||
/// A small shape with a many-element clip is still drawn.
|
||||
#[test]
|
||||
fn sparse_shape_with_a_many_element_clip_is_still_drawn() {
|
||||
let mut clip = kurbo::BezPath::new();
|
||||
clip.move_to((0.0, 0.0));
|
||||
for i in 0..1026 {
|
||||
clip.line_to((2.0 * (i % 2) as f64, 2.0 * ((i + 1) % 2) as f64));
|
||||
}
|
||||
clip.line_to((2.0, 2.0));
|
||||
clip.line_to((0.0, 2.0));
|
||||
clip.close_path();
|
||||
assert!(clip.elements().len() > 1000);
|
||||
let mut g = Group::new("clipped");
|
||||
g.clip = vec![(clip, FillRule::NonZero)];
|
||||
let doc = parse(br#"<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512"><rect width="2" height="2" fill="red"/></svg>"#).unwrap();
|
||||
g.children = doc.root.children.clone();
|
||||
let doc = sparse_doc(512.0, 512.0, vec![Node::Group(g)]);
|
||||
let img = try_rasterize(&doc, 512, 512, 1.0).expect("sparse clip is within budget");
|
||||
assert!(img.data.iter().any(|p| p[3] > 0.5), "clip work is charged by clip area, not canvas area");
|
||||
}
|
||||
|
||||
/// Sparse pattern fills on a 4K canvas all render.
|
||||
#[test]
|
||||
fn sparse_pattern_fills_on_a_4k_canvas_all_render() {
|
||||
let rects: String = (0..6).map(|i| format!(r#"<rect x="{}" y="0" width="2" height="2" fill="url(#p)"/>"#, i * 4)).collect();
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="3840" height="2160">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1"><rect width="1" height="1" fill="red"/></pattern></defs>
|
||||
{rects}</svg>"#
|
||||
);
|
||||
let doc = parse(svg.as_bytes()).unwrap();
|
||||
let img = try_rasterize(&doc, 3840, 2160, 1.0).expect("sparse patterns are within budget");
|
||||
for i in 0..6 {
|
||||
assert!(px(&img, i * 4, 0)[3] > 0.99, "rect {i} must be painted");
|
||||
}
|
||||
}
|
||||
|
||||
/// A genuinely huge covered area is still refused.
|
||||
#[test]
|
||||
fn genuinely_huge_covered_area_is_still_refused() {
|
||||
// A 16392x1 strip squeezed 8:1 across the whole canvas needs 8x8 taps per pixel everywhere.
|
||||
let image = Image {
|
||||
name: "i".into(),
|
||||
transform: Affine::scale_non_uniform(1.0 / 8.0, 2049.0),
|
||||
width: 16392,
|
||||
height: 1,
|
||||
rgba: std::sync::Arc::new([255u8, 0, 0, 255].repeat(16392)),
|
||||
opacity: 1.0,
|
||||
};
|
||||
let doc = sparse_doc(2049.0, 2049.0, vec![Node::Image(image)]);
|
||||
assert_eq!(
|
||||
try_rasterize(&doc, 2049, 2049, 1.0).err(),
|
||||
Some(RasterError::WorkLimit("image sampling")),
|
||||
"a fully covered 64-tap canvas exceeds the work cap"
|
||||
);
|
||||
let img = rasterize(&doc, 2049, 2049, 1.0);
|
||||
assert!(img.data.iter().all(|p| p[3] == 0.0), "the lossy wrapper drops the refused image");
|
||||
}
|
||||
|
||||
/// A group of `count` 1x1 red rects, each inside its own tiny clip, spread over a large canvas.
|
||||
fn tiny_clipped_rects(count: usize) -> Vec<Node> {
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % 100) as f64 * 4.0, (i / 100) as f64 * 4.0);
|
||||
let mut g = Group::new("clipped");
|
||||
g.clip = vec![(kurbo::Rect::new(x, y, x + 2.0, y + 2.0).to_path(0.01), FillRule::NonZero)];
|
||||
let mut rect = Group::new("r");
|
||||
rect.children = parse(
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect x="{x}" y="{y}" width="1" height="1" fill="red"/></svg>"#)
|
||||
.as_bytes(),
|
||||
)
|
||||
.map(|d| d.root.children)
|
||||
.unwrap_or_default();
|
||||
g.children = rect.children;
|
||||
Node::Group(g)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Thousands of tiny clipped items on a big canvas cost region-proportional work: a clipped
|
||||
/// group works on a crop of its clip region, so the render finishes quickly and completely.
|
||||
#[test]
|
||||
fn thousands_of_tiny_clips_on_a_large_canvas_finish_quickly() {
|
||||
let doc = sparse_doc(2048.0, 2048.0, tiny_clipped_rects(3000));
|
||||
let start = std::time::Instant::now();
|
||||
let img = try_rasterize(&doc, 2048, 2048, 1.0).expect("tiny clips stay within every budget");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20), "region-bounded work: {:?}", start.elapsed());
|
||||
assert!(px(&img, 0, 0)[3] > 0.99 && px(&img, 396, 28 * 4)[3] > 0.99);
|
||||
}
|
||||
|
||||
/// Thousands of tiny pattern-filled shapes and tiny images on a big canvas finish quickly because
|
||||
/// masks and scans cover only each item's region.
|
||||
#[test]
|
||||
fn thousands_of_tiny_pattern_and_image_items_on_a_large_canvas_finish_quickly() {
|
||||
let mut nodes = Vec::new();
|
||||
let rects: String = (0..3000).map(|i| format!(r#"<rect x="{}" y="{}" width="2" height="2" fill="url(#p)"/>"#, (i % 100) * 4, (i / 100) * 4)).collect();
|
||||
let svg = format!(
|
||||
r#"<svg xmlns="http://www.w3.org/2000/svg" width="3840" height="2160">
|
||||
<defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1"><rect width="1" height="1" fill="red"/></pattern></defs>
|
||||
{rects}</svg>"#
|
||||
);
|
||||
nodes.extend(parse(svg.as_bytes()).unwrap().root.children);
|
||||
for i in 0..3000 {
|
||||
nodes.push(Node::Image(Image {
|
||||
name: "i".into(),
|
||||
transform: Affine::translate(((i % 100) as f64 * 4.0, 100.0 + (i / 100) as f64 * 4.0)),
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba: std::sync::Arc::new(vec![0, 255, 0, 255]),
|
||||
opacity: 1.0,
|
||||
}));
|
||||
}
|
||||
let doc = sparse_doc(3840.0, 2160.0, nodes);
|
||||
let start = std::time::Instant::now();
|
||||
let img = try_rasterize(&doc, 3840, 2160, 1.0).expect("tiny items stay within every budget");
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(20), "region-bounded work: {:?}", start.elapsed());
|
||||
assert!(px(&img, 396, 0)[3] > 0.99 && px(&img, 396, 100)[3] > 0.99);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_shared_across_references_are_not_charged_per_copy() {
|
||||
// 4,000 stops referenced by 2,100 shapes would be 8.4 million stops if copied, but resolved
|
||||
// gradients share one stop array, so the import keeps only the 4,000 and succeeds.
|
||||
let stops: String = (0..4000).map(|i| format!("<stop offset=\"{}\" stop-color=\"red\"/>", i as f64 / 4000.0)).collect();
|
||||
let rects = "<rect width=\"1\" height=\"1\" fill=\"url(#g)\"/>".repeat(2100);
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><defs><linearGradient id=\"g\">{stops}</linearGradient></defs>{rects}</svg>");
|
||||
assert!(matches!(parse(svg.as_bytes()), Err(Error::Xml(e)) if e.contains("gradients")));
|
||||
assert!(parse(svg.as_bytes()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -390,7 +390,7 @@ impl EffectcraftApp {
|
||||
/// fonts are ready, draw the frame, finish pointer wrapping, pump queued wasm frames, record input activity,
|
||||
/// publish the completed pass's geometry and pass sequence, acknowledge applied synthetic input and answer
|
||||
/// `AfterInput` replies only after a non-discarded pass. Requests repaints while frames are in flight.
|
||||
fn draw_inner(&mut self, ui: &mut egui::Ui, frame: &eframe::Frame) {
|
||||
fn draw_inner(&mut self, ui: &mut egui::Ui, _frame: &eframe::Frame) {
|
||||
let ctx = ui.ctx().clone();
|
||||
if self.gpu_failure_applied == Some(true) {
|
||||
self.close_after_device_loss(&ctx);
|
||||
@@ -408,7 +408,7 @@ impl EffectcraftApp {
|
||||
self.frame(ui);
|
||||
panels::crypto_pick::upkeep(self, &ctx);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pointer_wrap::finish_native(&ctx, frame);
|
||||
pointer_wrap::finish_native(&ctx, _frame);
|
||||
// wasm32: no frame threads; render queued frames now, between UI frames.
|
||||
if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) {
|
||||
ctx.request_repaint();
|
||||
|
||||
@@ -9,6 +9,7 @@ struct State {
|
||||
numeric: Option<(Id, u64)>,
|
||||
offset: Vec2,
|
||||
pending: Option<(Pos2, Pos2)>,
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
@@ -55,7 +56,11 @@ pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput, injected: bool) -
|
||||
|| raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. }))
|
||||
{
|
||||
let ended = state.numeric.is_some();
|
||||
*state = State { disabled: touch, ..Default::default() };
|
||||
*state = State {
|
||||
#[cfg(any(not(target_arch = "wasm32"), test))]
|
||||
disabled: touch,
|
||||
..Default::default()
|
||||
};
|
||||
return ended;
|
||||
}
|
||||
if state.numeric.is_none() {
|
||||
|
||||
Reference in new issue
Block a user