diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs index 2772d14ab7..d1d50e4502 100644 --- a/crates/edit/src/background.rs +++ b/crates/edit/src/background.rs @@ -9,6 +9,13 @@ use crate::{EditError, Rgb}; /// How deep `q` may nest while looking for the background (deeper saves are not tracked). const MAX_SAVES: usize = 64; +/// How many clipping rectangles are kept; past that the clip counts as unknown. +const MAX_CLIPS: usize = 64; + +/// A rectangle as it lands on the page: its four corners in page space, in order. Under a +/// rotating or shearing CTM it is a parallelogram, so its bounding box says too much (#2994). +type Quad = [(f64, f64); 4]; + /// The colour space a fill colour is given in. #[derive(Clone, Copy, PartialEq)] enum Space { @@ -19,7 +26,7 @@ enum Space { Other, } -#[derive(Clone, Copy)] +#[derive(Clone)] struct State { ctm: Matrix, space: Space, @@ -28,9 +35,9 @@ struct State { /// plain paint. alpha: f64, plain: bool, - /// The clip, as a rectangle in page space; `None` when a clip that isn't one rectangle was - /// set (its area can't be told). - clip: Option<[f64; 4]>, + /// The clip, as the rectangles it is the intersection of, in page space; `None` when a clip + /// that isn't one rectangle was set (its area can't be told). + clip: Option>, } /// The page's background colour: the colour of the first thing its content paints, when that is @@ -47,20 +54,20 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(crop) }; + let mut st = State { ctm: Matrix::IDENTITY, space: Space::Gray, fill: Some([0.0; 3]), alpha: 1.0, plain: true, clip: Some(Vec::new()) }; let mut saved: Vec = Vec::new(); let mut lost_saves = 0usize; // The path being built: its rectangles in page space, and whether it has anything else. // Each rectangle of the current path: its box on the page, and whether it winds // counterclockwise there (its width and height and the CTM's determinant agree in sign). - let mut rects: Vec<([f64; 4], bool)> = Vec::new(); + let mut rects: Vec<(Quad, bool)> = Vec::new(); let mut curved = false; let mut clip_pending = false; for op in &ops { match op.op.as_slice() { b"q" => { if saved.len() < MAX_SAVES { - saved.push(st); + saved.push(st.clone()); } else { lost_saves = lost_saves.saturating_add(1); } @@ -98,7 +105,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE b"re" => match op.nums::<4>() { Some([x, y, w, h]) => { let [a, b, c, d, _, _] = st.ctm.0; - rects.push((bbox(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); + rects.push((quad(&st.ctm, [x, y, x + w, y + h]), (w * h) * (a * d - b * c) > 0.0)); } None => curved = true, }, @@ -106,8 +113,11 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE b"W" | b"W*" => clip_pending = true, b"n" => { if clip_pending { - st.clip = match (st.clip, rects.as_slice(), curved) { - (Some(c), [(r, _)], false) => Some(intersect(c, *r)), + st.clip = match (st.clip.take(), rects.as_slice(), curved) { + (Some(mut c), [(r, _)], false) if c.len() < MAX_CLIPS => { + c.push(*r); + Some(c) + } _ => None, }; } @@ -124,7 +134,10 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE [(_, first), rest @ ..] => !even_odd && rest.iter().all(|(_, ccw)| ccw == first), [] => false, }; - let covers = !curved && solid && st.clip.is_some_and(|c| covers(&c, &crop)) && rects.iter().any(|(r, _)| covers(r, &crop)); + let covers = !curved + && solid + && st.clip.as_ref().is_some_and(|c| c.iter().all(|q| covers(q, &crop))) + && rects.iter().any(|(r, _)| covers(r, &crop)); return Ok(st.fill.filter(|_| opaque && covers && !clip_pending).map(|c| c.map(|v| v.clamp(0.0, 1.0)))); } // Anything else painted first: no page background. @@ -167,23 +180,22 @@ fn apply_gstate(doc: &Document, g: &Dict, st: &mut State) { } } -/// The page-space bounding box of a user-space rectangle under `ctm`. -fn bbox(ctm: &Matrix, r: [f64; 4]) -> [f64; 4] { - let pts = [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[0], r[3]), ctm.apply(r[2], r[3])]; - pts.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]) +/// The page-space corners of a user-space rectangle under `ctm`, in order around it. +fn quad(ctm: &Matrix, r: [f64; 4]) -> Quad { + [ctm.apply(r[0], r[1]), ctm.apply(r[2], r[1]), ctm.apply(r[2], r[3]), ctm.apply(r[0], r[3])] } -fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { - [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])] -} - -/// Whether `outer` covers `page`, give or take half a percent of its size. -fn covers(outer: &[f64; 4], page: &[f64; 4]) -> bool { +/// Whether `outer` covers `page`, give or take half a percent of its size: each corner of the +/// page, pulled in by that much, lies inside the parallelogram `outer`. +fn covers(outer: &Quad, page: &[f64; 4]) -> bool { let (w, h) = ((page[2] - page[0]).abs(), (page[3] - page[1]).abs()); let (tx, ty) = (w * 0.005, h * 0.005); - outer.iter().all(|v| v.is_finite()) - && outer[0] <= page[0] + tx - && outer[1] <= page[1] + ty - && outer[2] >= page[2] - tx - && outer[3] >= page[3] - ty + let (x0, y0, x1, y1) = (page[0].min(page[2]) + tx, page[1].min(page[3]) + ty, page[0].max(page[2]) - tx, page[1].max(page[3]) - ty); + let cross = |a: (f64, f64), b: (f64, f64), p: (f64, f64)| (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0); + // Twice the signed area: its sign says which way the corners run. + let area: f64 = (0..4).map(|i| cross((0.0, 0.0), outer[i], outer[(i + 1) % 4])).sum(); + if !area.is_finite() || area.abs() < f64::EPSILON || outer.iter().any(|p| !(p.0.is_finite() && p.1.is_finite())) { + return false; + } + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)].iter().all(|&p| (0..4).all(|i| cross(outer[i], outer[(i + 1) % 4], p) * area.signum() >= 0.0)) } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index edf5d308b2..be4e67d032 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5278,4 +5278,32 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() { assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); + assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); + // The same for a clip. + assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); + assert_eq!( + page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); + assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); }