diff --git a/crates/host/tests/it/vector_import.rs b/crates/host/tests/it/vector_import.rs index eff5b4b4b..8487c45b1 100644 --- a/crates/host/tests/it/vector_import.rs +++ b/crates/host/tests/it/vector_import.rs @@ -950,3 +950,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/tests.rs b/crates/pdf/src/tests.rs index b4b04d2ac..66b5c79a2 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1305,3 +1305,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("nesting limit")), "{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 6b365050b..32f8348f2 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -69,15 +69,34 @@ pub fn try_rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Result (Image, Option) { + render_with_limit(doc, w, h, m, MAX_LIVE_SURFACE_BYTES) +} + +/// [`rasterize_with`] with at most `limit_bytes` of image surfaces (the destination included) +/// live at once. +#[cfg(test)] +pub(crate) fn rasterize_with_budget(doc: &Doc, w: u32, h: u32, m: Affine, limit_bytes: usize) -> Image { + render_with_limit(doc, w, h, m, limit_bytes).0 +} + +/// [`render`] with the live surface limit set to `limit_bytes`. +fn render_with_limit(doc: &Doc, w: u32, h: u32, m: Affine, limit_bytes: usize) -> (Image, Option) { // Nested knockout and non-isolated groups draw their children more than once; the number of // node draws is bounded by a multiple of the tree's size, past which drawing stops. let mut budget = SurfaceBudget::new(); + budget.limit_bytes = limit_bytes; budget.draws_left = node_count(&doc.root).saturating_mul(MAX_DRAWS_PER_NODE).max(MIN_DRAWS); let Some(mut img) = budget.image(w, h, None) else { return (Image::default(), budget.refusal) }; draw_group(&doc.root, m, 1.0, &mut img, &mut budget); (img, budget.refusal) } +/// The deepest group nesting drawn: drawing recurses, and a crafted document can nest groups +/// without end, so groups deeper than this are not drawn. Above the 256 levels the SVG parser +/// and the PDF builder let a document nest, so everything they keep is drawn (the root group +/// and wrappers come on top). +const MAX_GROUP_DEPTH: usize = 288; + /// Bound the simultaneously live offscreen image surfaces created by nested groups. const MAX_LIVE_SURFACE_BYTES: usize = 512 * 1024 * 1024; /// Bound tile allocation and rasterisation work across one SVG render, even though each tile is @@ -248,6 +267,11 @@ struct SurfaceBudget { refusal: Option, /// Node draws left (see [`MAX_DRAWS_PER_NODE`]). draws_left: 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 { @@ -268,6 +292,8 @@ impl SurfaceBudget { limit_bytes: MAX_LIVE_SURFACE_BYTES, refusal: None, draws_left: usize::MAX, + depth: 0, + plain: false, } } @@ -420,6 +446,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 { @@ -436,9 +471,158 @@ 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 region 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 Some(region) = clip_region(g, m, dst.width, dst.height) else { return }; + let Some(mut tmp) = budget.image(region.width(), region.height(), None) else { return }; + let local = region.origin_shift() * m; + for c in &g.children { + draw_node(c, local, 1.0, &mut tmp, budget); + } + if let Some(clip) = clip_coverage(g, local, tmp.width, tmp.height, budget) { + apply_clip(&mut tmp, &clip); + budget.release_coverage(clip.reserved); + over_region(dst, &tmp, region, 1.0); + } + budget.release(&tmp); +} + /// Draws a group through an offscreen buffer, then composites it onto `dst`. A clipped group can /// only change the region its clips touch, so it works on a region-sized crop of `dst` (with the /// transform shifted to match) instead of a whole-canvas layer; the crop is written back after. @@ -611,7 +795,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)| { @@ -1185,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 47be3d896..c1f0e10d1 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -1152,3 +1152,227 @@ fn nested_knockout_groups_stop_at_the_draw_budget() { std::thread::spawn(move || tx.send(rasterize(&doc, 4, 4, 1.0).width).ok()); assert_eq!(rx.recv_timeout(std::time::Duration::from_secs(120)).expect("rendering finishes"), 4); } + +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 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] +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); +}