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
This commit is contained in:
vangel76
2026-11-07 12:56:59 +01:00
parent bb2d2005ce
commit e30f4f6435
5 changed files with 583 additions and 23 deletions
+75
View File
@@ -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<u8> {
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);
}
+19 -3
View File
@@ -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).
+29
View File
@@ -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:?}");
}
+270 -20
View File
@@ -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::<Option<Vec<_>>>() 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<kurbo::Rect> {
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<kurbo::Rect> = 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<kurbo::Rect> {
use kurbo::PathEl;
let els = path.elements();
let mut pts: Vec<Point> = 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<bool> {
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<kurbo::Rect> = 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;
+190
View File
@@ -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>) -> 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<Node>) -> 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<Node>) {
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);
}