From e30f4f64358553eddf2c56051ed7de281ca6d7d7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 7 Nov 2026 12:56:59 +0100 Subject: [PATCH 1/2] Vector footage: faster, budget-safe clipping in the SVG/PDF rasteriser Continuously rasterised vector footage (an A4 PDF scaled 200-400 %) is a page of millions of pixels, and the rasteriser did page-sized work per element: - Every path, run of text and image computed and walked a coverage mask the size of the whole page. Coverage and paint now run over the pixels the shape (fill, or stroke outline) or image can reach. A generated A4 page of 720 text-run paths and 60 strokes rasterises at 300 % in 0.18 s instead of 4.97 s (200 %: 0.13 s instead of 2.44 s), with the same pixels. - Every clipping group copied the whole backdrop and drew on a second page-size surface, so deeply nested clips ran past the 512 MB surface budget and their artwork was dropped (at 200 % from the eighth level, at 400 % from the second). A plain clipping group (no soft mask, blend mode, group opacity or knockout, Normal content throughout) is now drawn straight onto the page when every clip is an axis-aligned rectangle covering each pixel the content can reach, and otherwise on a surface the size of its clip. Both give the same pixels; groups with a blend mode or knockout inside keep the backdrop path. Never crash: drawing stops below 256 levels of groups, and the PDF/EPS builder keeps groups at most 200 deep (a crafted page nesting `q ... W n` twenty thousand deep overflowed the stack when the document was drawn or dropped). The 596 x 842 px import size of an A4 page is unchanged: the page in points at 72 dpi, rounded up, as After Effects takes a point as a pixel. Refs #2895 --- crates/host/tests/it/vector_import.rs | 75 +++++++ crates/pdf/src/build.rs | 22 +- crates/pdf/src/tests.rs | 29 +++ crates/svg/src/raster.rs | 290 ++++++++++++++++++++++++-- crates/svg/src/tests.rs | 190 +++++++++++++++++ 5 files changed, 583 insertions(+), 23 deletions(-) diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index badd60b66..c54c61550 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -596,3 +596,78 @@ fn ai_composition_retains_layer_sizes_and_positions() { assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels"); } } + +/// An A4 page (595.28 × 841.89 pt) whose artwork sits inside a page clip and ten nested +/// clipping groups, as PDFs exported from layout and office applications nest them: a black bar +/// whose left edge is at x = 30 pt, from 41.89 to 191.89 pt down the page. +fn a4_clipped_pdf() -> Vec { + let mut content = String::from( + "q 0 0 595.28 841.89 re W n +", + ); + for i in 0..10 { + content.push_str(&format!( + "q {} {} {} {} re W n +", + 20 + i, + 600 + i, + 300 - 2 * i, + 230 - 2 * i + )); + } + content.push_str( + "0 0 0 rg 30 650 40 150 re f +", + ); + content.push_str( + &"Q +" + .repeat(11), + ); + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << >> /Contents 4 0 R >>".into(), None), + (4, "<< >>".into(), Some(content.into_bytes())), + ], + 1, + ) +} + +/// #2895: an A4 PDF imports at its size in points, rounded up (596 × 842 px; After Effects +/// also takes a point as a pixel), and scaled 200 % with Continuously Rasterize on it is drawn +/// at that scale: the bar's left edge stays one pixel wide. It used to come out empty: at +/// 200 % (a 32 MB page) each nested clipping group copied the whole page and drew on a second +/// page-size surface, so the eighth level of clips ran past the rasteriser's 512 MB surface +/// budget. With the switch off the layer is the page's pixels enlarged, soft as before. +#[test] +fn a4_pdf_scaled_200_percent_continuously_rasterizes_sharp() { + let path = tmp("a4-clipped.pdf"); + std::fs::write(&path, a4_clipped_pdf()).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path]})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let item = r["items"][0].as_u64().unwrap(); + let effectcraft_project::ItemKind::Footage(f) = &s.project.item(ItemId(item)).unwrap().kind else { panic!("footage") }; + assert_eq!((f.codec.as_str(), f.width, f.height), ("PDF", 596, 842), "A4 in points"); + s.execute("comp.new", json!({"name": "A4", "width": 400, "height": 400, "frameRate": 30, "duration": 1})).unwrap(); + let lid = s.execute_checked("layer.addItem", json!({"item": item})).unwrap()["layer"].as_u64().unwrap(); + for (k, v) in [("transform/anchor", json!([0, 0])), ("transform/position", json!([0, 0])), ("transform/scale", json!([200, 200]))] { + s.execute("prop.set", json!({"layer": lid, "path": k, "value": v})).unwrap(); + } + let cid = s.active_comp_id().unwrap(); + let t = effectcraft_time::Tick::ZERO; + // Row 240 (120 pt down the page) crosses the bar's left edge at x = 60 (30 pt × 2). + let soft_pixels = |img: &Image| (50..70).filter(|&x| (0.05..0.95).contains(&img.get(x, 240)[3])).count(); + let off = s.render(cid, t, RenderOpts::default()); + assert!(off.get(100, 240)[3] > 0.99, "the bar shows with the switch off"); + assert!(soft_pixels(&off) >= 2, "off: the page's pixels enlarged, a soft edge ({} partial pixels)", soft_pixels(&off)); + s.execute("layer.setSwitch", json!({"layers": [lid], "switch": "collapse", "value": true})).unwrap(); + let on = s.render(cid, t, RenderOpts::default()); + assert!(on.get(100, 240)[3] > 0.99, "the clipped bar is drawn at 200 %: {:?}", on.get(100, 240)); + assert!(on.get(61, 240)[3] > 0.99 && on.get(58, 240)[3] < 0.01, "a hard edge at x = 60: {:?} {:?}", on.get(58, 240), on.get(61, 240)); + assert!(soft_pixels(&on) <= 1, "on: the edge is at most one pixel wide ({} partial pixels)", soft_pixels(&on)); + // Outside the clips (x = 10 pt) nothing is drawn. + assert!(on.get(20, 240)[3] < 0.01); +} diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 0b1586f89..497d567a6 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -7,6 +7,10 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape /// PostScript program, or the PDF content stream. pub(crate) const MAX_PATH: usize = 1 << 20; +/// The deepest groups (clips, layers, transparency groups) nest in the document; below the +/// renderer's own limit (256), so everything kept is drawn. +pub(crate) const MAX_GROUP_DEPTH: usize = 200; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, @@ -68,17 +72,29 @@ impl Builder { g.clip.push((path, rule)); // Clipping is not a transparency group: blend modes inside reach the backdrop. g.isolated = false; - self.stack.push((g, Kind::Clip)); + self.push_group(g, Kind::Clip); } /// Open an optional-content layer (or a named group). pub fn open(&mut self, name: &str, layer: bool) { - self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + self.push_group(group(name), if layer { Kind::Layer } else { Kind::Plain }); } /// Open a group (blend mode, soft mask) closed with the graphics state. pub fn open_group(&mut self, g: Group) { - self.stack.push((g, Kind::Plain)); + self.push_group(g, Kind::Plain); + } + + /// Open a group, unless groups are already nested [`MAX_GROUP_DEPTH`] deep: the document + /// is a tree that is drawn, measured, copied and dropped recursively, and a crafted file + /// can nest `q … W n` without end. Deeper groups are left out (their content is drawn + /// in the group around them) and the document says so. + fn push_group(&mut self, g: Group, kind: Kind) { + if self.stack.len() >= MAX_GROUP_DEPTH { + self.skip("groups nested deeper than 200 levels"); + return; + } + self.stack.push((g, kind)); } /// Add a finished node (an image, pattern tiles). diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2d09a7406..275e7021f 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1139,3 +1139,32 @@ fn cmap_entries_at_the_top_of_the_code_range_do_not_overflow() { // Code 0 is CID u32::MAX; code 2 would be past it, so it falls back to CID 0 like an unmapped code. assert_eq!((f.width(0), f.width(2)), (0.5, 0.7)); } + +/// Never crash: a crafted page nests `q … W n` twenty thousand deep. The document is a tree +/// that is drawn, measured and dropped recursively, so groups past the nesting limit are left +/// out (said in the skip notes) and the page parses, draws and drops on a thread with a small +/// stack; the artwork inside them still draws, clipped by the groups that were kept. +#[test] +fn clip_groups_nested_without_end_are_capped() { + let run = std::thread::Builder::new().stack_size(2 << 20).spawn(|| { + let n = 20_000; + let mut content = "q 0 0 50 50 re W n\n".repeat(n); + content.push_str("0 0 1 rg 0 0 50 50 re f\n"); + content.push_str(&"Q\n".repeat(n)); + let bytes = pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".to_string(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(), None), + (4, "<< >>".to_string(), Some(content.into_bytes())), + ], + 1, + ); + let doc = parse(&bytes).unwrap(); + (doc.skipped.clone(), px(&doc, 25, 75), px(&doc, 75, 25)) + }); + let (skipped, inside, outside) = run.unwrap().join().unwrap(); + assert!(skipped.iter().any(|s| s.contains("nested deeper")), "{skipped:?}"); + assert!(inside[2] > 0.99 && inside[3] > 0.99, "the artwork draws inside the clips: {inside:?}"); + assert!(outside[3] < 0.01, "and is clipped: {outside:?}"); +} diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 568b10b25..59deae964 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -20,7 +20,13 @@ pub fn rasterize(doc: &Doc, w: u32, h: u32, scale: f64) -> Image { /// Rasterise `doc` with `m` mapping document pixels to image pixels. pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image { - let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes: MAX_LIVE_SURFACE_BYTES }; + rasterize_with_budget(doc, w, h, m, MAX_LIVE_SURFACE_BYTES) +} + +/// [`rasterize_with`] with at most `limit_bytes` of image surfaces (the destination included) +/// live at once. +pub(crate) fn rasterize_with_budget(doc: &Doc, w: u32, h: u32, m: Affine, limit_bytes: usize) -> Image { + let mut budget = SurfaceBudget { live_bytes: 0, limit_bytes, depth: 0, plain: false }; let Some(mut img) = budget.image(w, h, None) else { return Image::default() }; draw_group(&doc.root, m, 1.0, &mut img, &mut budget); img @@ -29,9 +35,19 @@ pub fn rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Image { /// Bound the simultaneously live offscreen image surfaces created by nested groups. const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024; +/// The deepest group nesting drawn: drawing recurses, and a crafted document can nest groups +/// without end, so groups deeper than this are not drawn. +const MAX_GROUP_DEPTH: usize = 256; + +/// The state of one rasterisation: the surface budget and where the drawing is in the tree. struct SurfaceBudget { live_bytes: usize, limit_bytes: usize, + /// Groups entered on the way to the one being drawn. + depth: usize, + /// Every group being drawn below the current one composites Normal, without knockout + /// (known once for a whole subtree, so it isn't walked again at every level). + plain: bool, } impl SurfaceBudget { @@ -61,6 +77,15 @@ impl SurfaceBudget { } fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) { + if budget.depth >= MAX_GROUP_DEPTH { + return; + } + budget.depth += 1; + draw_group_entered(g, m, opacity, dst, budget); + budget.depth -= 1; +} + +fn draw_group_entered(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut SurfaceBudget) { let m = m * g.transform; let o = opacity * g.opacity; if o <= 0.0 { @@ -77,9 +102,192 @@ fn draw_group(g: &Group, m: Affine, opacity: f64, dst: &mut Image, budget: &mut } return; } + let only_clip = !g.clip.is_empty() && g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout; + let plain = only_clip && (budget.plain || !needs_backdrop(&g.children, MAX_GROUP_DEPTH.saturating_sub(budget.depth))); + if plain { + let outer = std::mem::replace(&mut budget.plain, true); + let left = MAX_GROUP_DEPTH.saturating_sub(budget.depth); + if clips_cover_content(g, m, dst.width, dst.height, left) { + for c in &g.children { + draw_node(c, m, 1.0, dst, budget); + } + } else { + draw_clip_local(g, m, dst, budget); + } + budget.plain = outer; + return; + } draw_offscreen_group(g, m, o, dst, budget); } +/// Whether every clip of `g` is a rectangle along the pixel grid's axes that fully covers each +/// pixel of a `w`×`h` image its children can draw on: the clips change nothing there, so the +/// children are drawn straight onto the image, without a surface (a page or artboard clip +/// around the artwork inside it, the outermost groups of most PDF pages). `levels`: how many +/// levels of groups below are drawn. +fn clips_cover_content(g: &Group, m: Affine, w: u32, h: u32, levels: usize) -> bool { + let Some(rects) = g.clip.iter().map(|(p, _)| axis_rect(p, m)).collect::>>() else { return false }; + // Children that draw nothing need no clip. + let Some(b) = draw_bounds(&g.children, m, levels) else { return true }; + let (fw, fh) = (f64::from(w), f64::from(h)); + // The pixels the children can touch (a pixel of margin for anti-aliasing), on the image. + let (bx0, by0) = ((b.x0.floor() - 1.0).max(0.0), (b.y0.floor() - 1.0).max(0.0)); + let (bx1, by1) = ((b.x1.ceil() + 1.0).min(fw), (b.y1.ceil() + 1.0).min(fh)); + if !(bx0 < bx1 && by0 < by1) { + // Nothing reaches the image (or the bounds aren't numbers): keep the clip. + return false; + } + rects.iter().all(|r| r.x0.ceil() <= bx0 && bx1 <= r.x1.floor() && r.y0.ceil() <= by0 && by1 <= r.y1.floor()) +} + +/// A device-space rectangle holding every pixel `nodes` drawn with `m` can cover, `None` when +/// they draw nothing. Cheap rather than tight: a stroke counts as its path's bounds grown by half +/// its width times its miter limit (at least √2, for square caps), and anything it can't +/// bound (more than `levels` levels deep, a transform that isn't finite) as the whole plane. +fn draw_bounds(nodes: &[Node], m: Affine, levels: usize) -> Option { + let everything = kurbo::Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY); + if !m.as_coeffs().iter().all(|v| v.is_finite()) { + return Some(everything); + } + let mut out: Option = None; + for n in nodes { + let r = match n { + Node::Group(g) if levels == 0 => (!g.children.is_empty()).then_some(everything), + Node::Group(g) => { + let gm = m * g.transform; + let mut r = draw_bounds(&g.children, gm, levels - 1); + for (clip, _) in &g.clip { + r = r.map(|r| r.intersect(gm.transform_rect_bbox(clip.bounding_box()))); + } + r + } + Node::Shape(s) => { + let sm = m * s.transform; + let b = s.geom.to_path().bounding_box(); + let fill = s.fill.as_ref().map(|_| sm.transform_rect_bbox(b)); + let stroke = s.stroke.as_ref().map(|st| { + let pad = (st.width.abs() * 0.5 * st.miter.max(std::f64::consts::SQRT_2)).max(0.0); + sm.transform_rect_bbox(b.inflate(pad, pad)) + }); + match (fill, stroke) { + (Some(a), Some(b)) => Some(a.union(b)), + (a, b) => a.or(b), + } + } + Node::Image(im) => Some((m * im.transform).transform_rect_bbox(kurbo::Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)))), + }; + if let Some(r) = r { + let r = if [r.x0, r.y0, r.x1, r.y1].iter().any(|v| v.is_nan()) { everything } else { r }; + out = Some(out.map_or(r, |o| o.union(r))); + } + } + out +} + +/// The device-space rectangle a clip path is when it is one along the pixel grid's axes: a +/// move, three or four lines and an optional close, its sides alternately horizontal and +/// vertical. +pub(crate) fn axis_rect(path: &kurbo::BezPath, m: Affine) -> Option { + use kurbo::PathEl; + let els = path.elements(); + let mut pts: Vec = Vec::with_capacity(5); + for (i, el) in els.iter().enumerate() { + match el { + PathEl::MoveTo(p) if i == 0 => pts.push(m * *p), + PathEl::LineTo(p) if i > 0 && pts.len() < 5 => pts.push(m * *p), + PathEl::ClosePath if i > 0 && i + 1 == els.len() => {} + _ => return None, + } + } + const EPS: f64 = 1e-6; + let same = |a: Point, b: Point| (a.x - b.x).abs() <= EPS && (a.y - b.y).abs() <= EPS; + if pts.len() == 5 && pts.first().zip(pts.last()).is_some_and(|(a, b)| same(*a, *b)) { + pts.pop(); + } + let p: [Point; 4] = pts.try_into().ok()?; + let sides = [(p[0], p[1]), (p[1], p[2]), (p[2], p[3]), (p[3], p[0])]; + // Each side horizontal or vertical (and not a point), the next one the other way. + let horizontal = |(a, b): (Point, Point)| -> Option { + let (dx, dy) = ((b.x - a.x).abs(), (b.y - a.y).abs()); + match (dx > EPS, dy > EPS) { + (true, false) => Some(true), + (false, true) => Some(false), + _ => None, + } + }; + let kinds = sides.map(horizontal); + let [Some(k0), Some(k1), Some(k2), Some(k3)] = kinds else { return None }; + if k0 == k1 || k1 == k2 || k2 == k3 || k3 == k0 { + return None; + } + let r = kurbo::Rect::from_points(p[0], p[2]); + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()).then_some(r) +} + +/// Whether drawing `nodes` reads what is below them beyond Normal compositing: a blend mode or +/// a knockout group anywhere inside. Past `levels` levels of groups the answer is yes. +fn needs_backdrop(nodes: &[Node], levels: usize) -> bool { + nodes.iter().any(|n| match n { + Node::Group(g) => levels == 0 || g.blend != BlendMode::Normal || g.knockout || needs_backdrop(&g.children, levels - 1), + Node::Shape(_) | Node::Image(_) => false, + }) +} + +/// A plain clipping group (no soft mask, blend mode, group opacity or knockout) whose content +/// composites Normal throughout: drawn into a surface the size of its clip's bounds and +/// composited over `dst`. With Normal content that is the same as drawing it over a copy of +/// the backdrop and keeping the clip (`(C over B)·c + B·(1 − c) = (C·c) over B`), without two +/// full-size copies per nesting level: a page continuously rasterised at 400 % lost its +/// nested clipped artwork to the surface budget (#2895). +fn draw_clip_local(g: &Group, m: Affine, dst: &mut Image, budget: &mut SurfaceBudget) { + let mut bounds: Option = None; + for (path, _) in &g.clip { + let b = m.transform_rect_bbox(path.bounding_box()); + bounds = Some(bounds.map_or(b, |a| a.intersect(b))); + } + let Some((x0, y0, w, h)) = bounds.and_then(|b| pixel_rect(b, dst.width, dst.height)) else { return }; + let Some(mut tmp) = budget.image(w, h, None) else { return }; + let local = to_local((x0, y0), m); + for c in &g.children { + draw_node(c, local, 1.0, &mut tmp, budget); + } + let cov = clip_coverage(g, local, w, h); + tmp.data.par_iter_mut().zip(cov.par_iter()).for_each(|(p, c)| { + for v in p.iter_mut() { + *v *= c; + } + }); + over_at(dst, &tmp, (x0, y0), 1.0); + budget.release(&tmp); +} + +/// The pixels of a `w`×`h` image that the device-space rectangle `r` touches, with a pixel of +/// margin for anti-aliasing, as `(x, y, width, height)`; `None` when it misses the image. A +/// rectangle that isn't finite stands for the whole image. +fn pixel_rect(r: kurbo::Rect, w: u32, h: u32) -> Option<(u32, u32, u32, u32)> { + if w == 0 || h == 0 { + return None; + } + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return Some((0, 0, w, h)); + } + let (fw, fh) = (f64::from(w), f64::from(h)); + let x0 = (r.x0.min(r.x1) - 1.0).floor().clamp(0.0, fw); + let y0 = (r.y0.min(r.y1) - 1.0).floor().clamp(0.0, fh); + let x1 = (r.x0.max(r.x1) + 1.0).ceil().clamp(0.0, fw); + let y1 = (r.y0.max(r.y1) + 1.0).ceil().clamp(0.0, fh); + if x1 <= x0 || y1 <= y0 { + return None; + } + Some((x0 as u32, y0 as u32, (x1 - x0) as u32, (y1 - y0) as u32)) +} + +/// A translation by `-origin`: maps device space onto a surface whose top-left pixel sits at +/// `origin` of the device image. +fn to_local(origin: (u32, u32), m: Affine) -> Affine { + Affine::translate((-f64::from(origin.0), -f64::from(origin.1))) * m +} + /// Draws a group through an offscreen buffer, then composites it onto `dst`. fn draw_offscreen_group(g: &Group, m: Affine, o: f64, dst: &mut Image, budget: &mut SurfaceBudget) { let only_clip = g.mask.is_none() && g.blend == BlendMode::Normal && o >= 1.0 && !g.knockout; @@ -191,7 +399,10 @@ fn apply_soft_mask(sm: &SoftMask, m: Affine, tmp: &mut Image, budget: &mut Surfa let Some(mut mk) = budget.image(tmp.width, tmp.height, None) else { return false; }; + // The mask's content is a tree of its own: what is known of the masked group's isn't. + let plain = std::mem::replace(&mut budget.plain, false); draw_group(&sm.content, m, 1.0, &mut mk, budget); + budget.plain = plain; let bc = sm.backdrop.map(|v| v as f32); let lum = sm.luminosity; tmp.data.par_iter_mut().zip(mk.data.par_iter()).for_each(|(p, q)| { @@ -421,12 +632,16 @@ fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) { if !inv.as_coeffs().iter().all(|v| v.is_finite()) { return; } - let quad = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64).to_path(0.01); - let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(full), dst.width, dst.height, effectcraft_path::FillRule::NonZero); - let w = dst.width as usize; - if w == 0 { + // Coverage over the pixels the image's quadrilateral reaches only (see `draw_shape`). + let rect = kurbo::Rect::new(0.0, 0.0, iw as f64, ih as f64); + let Some((x0, y0, rw, rh)) = pixel_rect(full.transform_rect_bbox(rect), dst.width, dst.height) else { return }; + let quad = rect.to_path(0.01); + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&quad), &mat3(to_local((x0, y0), full)), rw, rh, effectcraft_path::FillRule::NonZero); + let (w, cw) = (dst.width as usize, cov.width as usize); + if w == 0 || cw == 0 { return; } + let (ox, oy) = (x0 as usize, y0 as usize); let px = |x: usize, y: usize| -> [f32; 4] { let i = (y * iw + x) * 4; let a = im.rgba[i + 3] as f32 / 255.0; @@ -438,9 +653,10 @@ fn draw_image(im: &crate::Image, m: Affine, opacity: f64, dst: &mut Image) { let fy = (c[2] * c[2] + c[3] * c[3]).sqrt(); let taps = (fx.max(fy).ceil() as usize).clamp(1, 8); let k = opacity as f32; - dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - for (x, d) in row.iter_mut().enumerate() { - let cv = cov.data[y * w + x]; + dst.data.par_chunks_mut(w).enumerate().skip(oy).zip(cov.data.par_chunks(cw)).for_each(|((y, row), crow)| { + let Some(seg) = row.get_mut(ox..) else { return }; + for (lx, (d, &cv)) in seg.iter_mut().zip(crow).enumerate() { + let x = ox + lx; if cv <= 0.0 { continue; } @@ -486,6 +702,30 @@ fn over(dst: &mut Image, src: &Image, k: f32) { }); } +/// `dst = src·k over dst` for `src` placed with its top-left pixel at `origin` of `dst`. +fn over_at(dst: &mut Image, src: &Image, origin: (u32, u32), k: f32) { + let (w, sw) = (dst.width as usize, src.width as usize); + if w == 0 || sw == 0 { + return; + } + let (x0, y0) = (origin.0 as usize, origin.1 as usize); + dst.data.par_chunks_mut(w).skip(y0).zip(src.data.par_chunks(sw)).for_each(|(row, srow)| { + let Some(seg) = row.get_mut(x0..) else { return }; + for (d, s) in seg.iter_mut().zip(srow) { + let a = s[3] * k; + if a > 0.0 { + for c in 0..3 { + d[c] = s[c] * k + d[c] * (1.0 - a); + } + d[3] = a + d[3] * (1.0 - a); + } + } + }); +} + +/// Draw a shape. Its coverage is computed over the pixels its fill or stroke can reach only, +/// not the whole image: a page drawn at a large scale cost a full-size coverage pass per path +/// and per run of text (#2895). fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { let m = m * s.transform; let path = s.geom.to_path(); @@ -496,22 +736,32 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { FillRule::NonZero => effectcraft_path::FillRule::NonZero, FillRule::EvenOdd => effectcraft_path::FillRule::EvenOdd, }; - let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(m), w, h, rule); - paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox); + if let Some((x0, y0, rw, rh)) = pixel_rect(m.transform_rect_bbox(bbox), w, h) { + let cov = effectcraft_path::fill_coverage(std::slice::from_ref(&path), &mat3(to_local((x0, y0), m)), rw, rh, rule); + paint(dst, &cov, (x0, y0), fill, (opacity * s.fill_opacity) as f32, m, bbox); + } } if let Some(st) = &s.stroke { + // The outline as `effectcraft_path::stroke_coverage` makes it (the resolution scale of + // the whole transform, which a translation doesn't change). let style = st.style(); - let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h); - paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox); + if let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &style, mat3(m).mean_scale()) + && let Some((x0, y0, rw, rh)) = pixel_rect(m.transform_rect_bbox(outline.bounding_box()), w, h) + { + let cov = + effectcraft_path::fill_coverage(std::slice::from_ref(&outline), &mat3(to_local((x0, y0), m)), rw, rh, effectcraft_path::FillRule::NonZero); + paint(dst, &cov, (x0, y0), &st.paint, (opacity * st.opacity) as f32, m, bbox); + } } } -/// Composite paint through coverage. -fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) { - let w = dst.width as usize; - if w == 0 { +/// Composite paint through coverage `cov`, whose top-left pixel sits at `origin` of `dst`. +fn paint(dst: &mut Image, cov: &Mask, origin: (u32, u32), p: &Paint, opacity: f32, m: Affine, bbox: kurbo::Rect) { + let (w, cw) = (dst.width as usize, cov.width as usize); + if w == 0 || cw == 0 { return; } + let (x0, y0) = (origin.0 as usize, origin.1 as usize); let shader = match p { Paint::Color(c) => Shader::Solid([c[0] as f32, c[1] as f32, c[2] as f32, 1.0]), Paint::Gradient(g) => { @@ -523,13 +773,13 @@ fn paint(dst: &mut Image, cov: &Mask, p: &Paint, opacity: f32, m: Affine, bbox: Shader::Gradient(g, to_grad) } }; - dst.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| { - for (x, d) in row.iter_mut().enumerate() { - let c = cov.data[y * w + x]; + dst.data.par_chunks_mut(w).enumerate().skip(y0).zip(cov.data.par_chunks(cw)).for_each(|((y, row), crow)| { + let Some(seg) = row.get_mut(x0..) else { return }; + for (lx, (d, &c)) in seg.iter_mut().zip(crow).enumerate() { if c <= 0.0 { continue; } - let col = shader.at(x as f64 + 0.5, y as f64 + 0.5); + let col = shader.at((x0 + lx) as f64 + 0.5, y as f64 + 0.5); let a = col[3] * c * opacity; if a <= 0.0 { continue; diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 06dfbd333..0b4b02832 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -446,3 +446,193 @@ fn at_rule_blocks_are_skipped_by_brace_depth() { assert_eq!(sheet.rules.len(), 1); assert_eq!(sheet.rules[0].classes, vec!["e".to_string()]); } + +fn solid_rect(r: kurbo::Rect, c: [f64; 3]) -> Node { + Node::Shape(Shape { + name: "rect".into(), + geom: Geom::Rect { x: r.x0, y: r.y0, w: r.width(), h: r.height(), rx: 0.0, ry: 0.0 }, + transform: Affine::IDENTITY, + fill: Some(Paint::Color(c)), + fill_opacity: 1.0, + fill_rule: FillRule::NonZero, + stroke: None, + opacity: 1.0, + }) +} + +/// A PDF-style clipping group (not isolated: blend modes inside reach the backdrop). +fn clip_group(r: kurbo::Rect, children: Vec) -> Node { + let mut g = Group::new("Clip Group"); + g.clip.push((kurbo::Shape::to_path(&r, 0.1), FillRule::NonZero)); + g.isolated = false; + g.children = children; + Node::Group(g) +} + +fn doc_of(w: f64, h: f64, children: Vec) -> Doc { + let mut root = Group::new("page"); + root.children = children; + Doc { width: w, height: h, root, skipped: vec![] } +} + +/// Nested clipping groups over a backdrop: the content shows where every clip covers, half +/// where a clip edge halves a pixel, and the backdrop everywhere else (#2895: plain clipping +/// groups are drawn on surfaces the size of their clip). +#[test] +fn nested_clip_groups_keep_the_backdrop_outside_their_clips() { + let blue = [0.0, 0.0, 1.0]; + let red = [1.0, 0.0, 0.0]; + let inner = clip_group(kurbo::Rect::new(10.5, 0.0, 20.0, 40.0), vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 40.0, 40.0), red)]); + let doc = + doc_of(40.0, 40.0, vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 40.0, 40.0), blue), clip_group(kurbo::Rect::new(5.0, 5.0, 35.0, 35.0), vec![inner])]); + let img = rasterize(&doc, 40, 40, 1.0); + let (r, b) = ([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]); + for (x, y, want) in [(15, 20, r), (11, 6, r), (19, 34, r), (25, 20, b), (15, 2, b), (15, 36, b), (9, 20, b), (21, 20, b)] { + assert!(near(px(&img, x, y), want, 1e-3), "({x}, {y}): {:?}", px(&img, x, y)); + } + assert!(near(px(&img, 10, 20), [0.5, 0.0, 0.5, 1.0], 0.02), "half-covered edge pixel {:?}", px(&img, 10, 20)); + // Scaled 4×: the clip edge at 10.5 lands on a pixel boundary (42), hard on both sides. + let big = rasterize(&doc, 160, 160, 4.0); + assert!(near(px(&big, 41, 80), b, 0.01) && near(px(&big, 42, 80), r, 0.01), "{:?} {:?}", px(&big, 41, 80), px(&big, 42, 80)); +} + +/// A clipping group whose content blends (Multiply) still composites with the real backdrop: +/// red multiplied over blue is black inside the clip, blue outside. +#[test] +fn a_blend_mode_inside_a_clip_group_reaches_the_backdrop() { + let mut multiply = Group::new("Multiply"); + multiply.blend = BlendMode::Multiply; + multiply.children = vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [1.0, 0.0, 0.0])]; + let doc = doc_of( + 20.0, + 20.0, + vec![ + solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [0.0, 0.0, 1.0]), + clip_group(kurbo::Rect::new(5.0, 5.0, 15.0, 15.0), vec![Node::Group(multiply)]), + ], + ); + let img = rasterize(&doc, 20, 20, 1.0); + assert!(near(px(&img, 10, 10), [0.0, 0.0, 0.0, 1.0], 1e-3), "{:?}", px(&img, 10, 10)); + assert!(near(px(&img, 2, 10), [0.0, 0.0, 1.0, 1.0], 1e-3), "{:?}", px(&img, 2, 10)); +} + +/// A stroke reaches past its path's bounds by half its width: a horizontal line (a path +/// without height) stroked 4 px wide covers 4 rows, and its butt caps end at the path's ends. +#[test] +fn a_stroke_draws_its_full_width_past_the_path_bounds() { + let mut line = BezPath::new(); + line.move_to((5.0, 20.0)); + line.line_to((35.0, 20.0)); + let shape = Node::Shape(Shape { + name: "line".into(), + geom: Geom::Path(line), + transform: Affine::IDENTITY, + fill: None, + fill_opacity: 1.0, + fill_rule: FillRule::NonZero, + stroke: Some(Stroke { paint: Paint::Color([0.0, 0.0, 0.0]), opacity: 1.0, width: 4.0, cap: Cap::Butt, join: Join::Miter, miter: 4.0, dash: None }), + opacity: 1.0, + }); + let img = rasterize(&doc_of(40.0, 40.0, vec![shape]), 40, 40, 1.0); + for y in 18..22 { + assert!(px(&img, 20, y)[3] > 0.99, "row {y}: {:?}", px(&img, 20, y)); + } + for (x, y) in [(20, 17), (20, 22), (3, 20), (36, 20)] { + assert!(px(&img, x, y)[3] < 0.01, "({x}, {y}): {:?}", px(&img, x, y)); + } +} + +/// Bytes of `n` RGBA f32 surfaces of `w`×`h` pixels (a surface budget for the tests below). +fn surfaces(n: f64, w: u32, h: u32) -> usize { + (n * f64::from(w) * f64::from(h) * 16.0) as usize +} + +/// #2895: an A4 page continuously rasterised at 400 % is a 128 MB surface, and every nested +/// clipping group used to copy the backdrop and draw on a second surface of that size, so the +/// second level of clips ran past the 512 MB surface budget and its artwork was dropped. Clip +/// groups now draw on surfaces the size of their clip: with room for four canvas-size surfaces, +/// artwork inside four nested clips still draws (it used to need nine). +#[test] +fn deeply_clipped_artwork_draws_within_the_surface_budget() { + let red = [1.0, 0.0, 0.0]; + let mut node = solid_rect(kurbo::Rect::new(0.0, 0.0, 300.0, 300.0), red); + for i in 1..=4 { + let inset = 10.0 * f64::from(i); + node = clip_group(kurbo::Rect::new(inset, inset, 300.0 - inset, 300.0 - inset), vec![node]); + } + let img = crate::raster::rasterize_with_budget(&doc_of(300.0, 300.0, vec![node]), 300, 300, Affine::IDENTITY, surfaces(4.0, 300, 300)); + assert_eq!((img.width, img.height), (300, 300)); + assert!(near(px(&img, 150, 150), [1.0, 0.0, 0.0, 1.0], 1e-3), "centre {:?}", px(&img, 150, 150)); + assert!(near(px(&img, 45, 150), [1.0, 0.0, 0.0, 1.0], 1e-3), "just inside every clip {:?}", px(&img, 45, 150)); + assert!(px(&img, 35, 150)[3] < 0.01, "outside the outermost clip {:?}", px(&img, 35, 150)); +} + +/// #2895: clips that cover the whole canvas around the artwork inside them (a page clip, an +/// artboard clip, the outermost groups of most PDF pages) change no pixel, so they are drawn +/// without a surface: with room for two and a half canvas-size surfaces, the artwork inside two +/// of them still draws, and an inner clip still cuts it. +#[test] +fn canvas_covering_clips_draw_without_a_surface() { + let red = [1.0, 0.0, 0.0]; + let art = clip_group(kurbo::Rect::new(150.0, 0.0, 300.0, 350.0), vec![solid_rect(kurbo::Rect::new(100.0, 100.0, 250.0, 250.0), red)]); + let page = clip_group(kurbo::Rect::new(0.0, 0.0, 350.0, 350.0), vec![art]); + let outer = clip_group(kurbo::Rect::new(-1.0, -1.0, 351.0, 351.0), vec![page]); + let img = crate::raster::rasterize_with_budget(&doc_of(350.0, 350.0, vec![outer]), 350, 350, Affine::IDENTITY, surfaces(2.5, 350, 350)); + assert_eq!((img.width, img.height), (350, 350)); + for (x, y) in [(200, 200), (151, 101), (248, 248)] { + assert!(near(px(&img, x, y), [1.0, 0.0, 0.0, 1.0], 1e-3), "({x}, {y}): {:?}", px(&img, x, y)); + } + for (x, y) in [(120, 200), (148, 200), (200, 90), (260, 200)] { + assert!(px(&img, x, y)[3] < 0.01, "({x}, {y}): {:?}", px(&img, x, y)); + } +} + +/// Take a tree apart without recursing (a deep one would overflow the stack when dropped). +fn dismantle(mut nodes: Vec) { + while let Some(n) = nodes.pop() { + if let Node::Group(mut g) = n { + nodes.append(&mut g.children); + } + } +} + +/// Never crash: drawing recurses, and clipping groups nested twenty thousand deep (a crafted +/// file) are drawn without running out of stack, on a thread with a small one; groups past the +/// nesting limit are not drawn, shallower artwork is. +#[test] +fn clip_groups_nested_without_end_do_not_overflow_the_stack() { + let run = std::thread::Builder::new().stack_size(4 << 20).spawn(|| { + let mut node = solid_rect(kurbo::Rect::new(0.0, 0.0, 64.0, 64.0), [1.0, 0.0, 0.0]); + for _ in 0..20_000 { + node = clip_group(kurbo::Rect::new(0.0, 0.0, 64.0, 64.0), vec![node]); + } + let shallow = solid_rect(kurbo::Rect::new(0.0, 0.0, 8.0, 8.0), [0.0, 0.0, 1.0]); + let doc = doc_of(64.0, 64.0, vec![node, shallow]); + let img = rasterize(&doc, 64, 64, 1.0); + let out = (img.width, img.height, px(&img, 4, 4), px(&img, 40, 40)); + dismantle(vec![Node::Group(doc.root)]); + out + }); + let (w, h, shallow, deep) = run.unwrap().join().unwrap(); + assert_eq!((w, h), (64, 64)); + assert!(near(shallow, [0.0, 0.0, 1.0, 1.0], 1e-3), "{shallow:?}"); + assert!(deep[3] < 0.01, "artwork past the nesting limit is not drawn: {deep:?}"); +} + +/// Only a rectangle along the pixel grid's axes counts as a clip that can be skipped: a +/// rotated square or a path that folds back on itself does not. +#[test] +fn axis_rect_recognises_only_axis_aligned_rectangles() { + use crate::raster::axis_rect; + let rect = kurbo::Shape::to_path(&kurbo::Rect::new(1.0, 2.0, 5.0, 7.0), 0.1); + assert_eq!(axis_rect(&rect, Affine::IDENTITY), Some(kurbo::Rect::new(1.0, 2.0, 5.0, 7.0))); + assert_eq!(axis_rect(&rect, Affine::scale(2.0)), Some(kurbo::Rect::new(2.0, 4.0, 10.0, 14.0))); + assert_eq!(axis_rect(&rect, Affine::rotate(0.3)), None); + let mut folded = BezPath::new(); + folded.move_to((0.0, 0.0)); + folded.line_to((10.0, 0.0)); + folded.line_to((10.0, 10.0)); + folded.line_to((10.0, 0.0)); + folded.close_path(); + assert_eq!(axis_rect(&folded, Affine::IDENTITY), None); +} From 1241a00a5390687c4c821e75e89e053eb1fdbd4b Mon Sep 17 00:00:00 2001 From: vangel76 Date: Fri, 13 Nov 2026 20:14:33 +0100 Subject: [PATCH 2/2] Pattern tiles: analyse the backdrop afresh inside a plain clip group paint_pattern draws the tile content as a tree of its own on a fresh tile, so the plain-clip state inherited from an enclosing group must not apply there: an inner clip that blends against the tile was drawn as a plain clip and lost its backdrop. Save and clear SurfaceBudget::plain around the tile draw, as apply_soft_mask does. --- crates/svg/src/raster.rs | 4 ++++ crates/svg/src/tests.rs | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index 1b3ac396c..681489cc2 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -1372,7 +1372,11 @@ fn paint_pattern(dst: &mut Image, cov: &Mask, region: Region, pattern: &Pattern, fallback_paint(dst, budget); return; } + // The tile's content is a tree of its own, drawn on a fresh transparent tile: what is known + // of the enclosing groups (all Normal) says nothing about the backdrop inside it. + let plain = std::mem::replace(&mut budget.plain, false); draw_group(&pattern.content, content_to_tile, 1.0, &mut tile, budget); + budget.plain = plain; let shader = Shader::Pattern(pattern, &tile, device_to_pattern); paint_shader(dst, cov, region, &shader, opacity); budget.release(&tile); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index efc62203a..414a6cf5a 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -1220,6 +1220,40 @@ fn a_blend_mode_inside_a_clip_group_reaches_the_backdrop() { assert!(near(px(&img, 2, 10), [0.0, 0.0, 1.0, 1.0], 1e-3), "{:?}", px(&img, 2, 10)); } +/// A pattern tile is a tree of its own: a clip group inside it that blends (Multiply) against the +/// tile's backdrop must not be drawn as a plain clip just because the pattern fill sits inside an +/// outer plain clipping group. Red multiplied over the tile's blue is black inside the inner clip. +#[test] +fn a_pattern_inside_a_plain_clip_group_keeps_its_own_backdrop_analysis() { + let mut multiply = Group::new("Multiply"); + multiply.blend = BlendMode::Multiply; + multiply.children = vec![solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [1.0, 0.0, 0.0])]; + let mut content = Group::new("tile"); + content.children = vec![ + solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [0.0, 0.0, 1.0]), + clip_group(kurbo::Rect::new(8.0, 8.0, 12.0, 12.0), vec![Node::Group(multiply)]), + ]; + let pattern = Pattern { + x: 0.0, + y: 0.0, + width: 20.0, + height: 20.0, + pattern_units: PatternUnits::UserSpaceOnUse, + content_units: PatternUnits::UserSpaceOnUse, + transform: Affine::IDENTITY, + content: std::sync::Arc::new(content), + render_complexity: 4, + fallback: None, + }; + let Node::Shape(mut filled) = solid_rect(kurbo::Rect::new(0.0, 0.0, 20.0, 20.0), [0.0; 3]) else { return }; + filled.fill = Some(Paint::Pattern(pattern)); + let doc = doc_of(20.0, 20.0, vec![clip_group(kurbo::Rect::new(2.0, 2.0, 18.0, 18.0), vec![Node::Shape(filled)])]); + let img = rasterize(&doc, 20, 20, 1.0); + assert!(near(px(&img, 10, 10), [0.0, 0.0, 0.0, 1.0], 1e-3), "inner clip {:?}", px(&img, 10, 10)); + assert!(near(px(&img, 4, 10), [0.0, 0.0, 1.0, 1.0], 1e-3), "tile backdrop {:?}", px(&img, 4, 10)); + assert!(px(&img, 0, 10)[3] < 1e-3, "outside the outer clip {:?}", px(&img, 0, 10)); +} + /// A stroke reaches past its path's bounds by half its width: a horizontal line (a path /// without height) stroked 4 px wide covers 4 rows, and its butt caps end at the path's ends. #[test]