mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 06:42:31 +00:00
Merge pull request #3217 from vangel76/pdf-continuous-raster-2895
Vector footage: faster, budget-safe clipping in the SVG/PDF rasteriser (refs #2895)
This commit is contained in:
4 files changed
+519
No files matched your search
@@ -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<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);
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -69,15 +69,34 @@ pub fn try_rasterize_with(doc: &Doc, w: u32, h: u32, m: Affine) -> Result<Image,
|
||||
|
||||
/// Render `doc`, returning the image drawn so far and the first budget refusal, if any.
|
||||
fn render(doc: &Doc, w: u32, h: u32, m: Affine) -> (Image, Option<RasterError>) {
|
||||
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<RasterError>) {
|
||||
// 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<RasterError>,
|
||||
/// 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::<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 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);
|
||||
|
||||
@@ -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>) -> 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 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<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);
|
||||
}
|
||||
Reference in new issue
Block a user