M10: a turned fill or clip covers the page only if its corners do (#2992)

Review on #2994: fill rectangles and the clip were kept as their page-space
bounding boxes, so a rectangle turned or sheared by the CTM could pass as
covering the page while leaving its corners out, and a following fill became
Word's page colour. Each rectangle is now kept as its four page-space
corners, the clip as the rectangles it intersects, and the crop box counts as
covered when its corners lie inside each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q
This commit is contained in:
mtlcl410
2026-10-20 16:55:20 +08:00
co-authored by Claude Opus 5.5
parent ca0e0b08f3
commit d5002807be
2 changed files with 66 additions and 26 deletions
+38 -26
View File
@@ -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<Vec<Quad>>,
}
/// 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<Option<Rgb>, EditE
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::<Vec<_>>());
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<State> = 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<Option<Rgb>, 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<Option<Rgb>, 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<Option<Rgb>, 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))
}
+28
View File
@@ -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");
}