pdfrum_page/state/clip.rs
1//! The clipping path (ISO 32000-1 §8.5.4).
2//!
3//! Two things live here that a naive stack would not have:
4//!
5//! - **An auto-merge.** When the previously pushed clip is a rectangle that
6//! *contains* the new path's bounding box, the old entry is popped before
7//! the new one is pushed. It changes no pixels, but it changes clip counts
8//! in a dump, so it is not an optimization to skip.
9//! - **A text-clip batch is all-or-nothing at 1024 objects.** Adding a batch
10//! that would take the total past the cap **drops the whole batch**
11//! silently, rather than truncating it.
12
13use kurbo::{BezPath, Rect, Shape};
14use std::sync::Arc;
15
16/// The most text objects a clipping path may accumulate.
17pub const MAX_TEXT_OBJECTS: usize = 1024;
18
19/// A text-clip batch that would take the clip past [`MAX_TEXT_OBJECTS`].
20#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
21#[error("text clip batch of {adding} would exceed the {limit}-object cap ({have} already held)")]
22pub struct TextClipLimit {
23 /// How many text objects the stack already holds.
24 pub have: usize,
25 /// How many the refused batch wanted to add.
26 pub adding: usize,
27 /// The cap, [`MAX_TEXT_OBJECTS`].
28 pub limit: usize,
29}
30
31/// Which rule decides a clipping path's interior (ISO 32000-1 §8.5.4).
32///
33/// Distinct from [`FillRule`](crate::FillRule), which has a third state for
34/// "does not fill at all": a clip always has an interior, so `W` and `W*` are
35/// the only two answers.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
37pub enum ClipRule {
38 /// Nonzero winding number rule (`W`).
39 #[default]
40 Winding,
41 /// Even-odd rule (`W*`).
42 EvenOdd,
43}
44
45/// One clipping contribution.
46#[derive(Debug, Clone, PartialEq)]
47pub enum ClipEntry {
48 /// A path, with the rule that decides its interior.
49 Path {
50 /// The path in device-ish space; the drawn path keeps its own matrix.
51 path: BezPath,
52 /// The rule deciding the path's interior.
53 rule: ClipRule,
54 },
55 /// One batch of text objects shown in a clipping render mode, between a
56 /// `BT` and the `ET` that closed it.
57 ///
58 /// The *objects*, not their outlines, because turning a run into glyph
59 /// outlines needs the placement arithmetic — advances, kerning, word and
60 /// character spacing, the substituted-font width solve — that lives in the
61 /// renderer beside the code that draws the same run normally. The oracle
62 /// holds text objects here for the same reason and lays each one out at
63 /// clip time; deriving them twice, once for painting and once for
64 /// clipping, is how the two would drift apart.
65 Text {
66 /// The runs, in the order they were shown.
67 runs: Vec<TextClipRun>,
68 },
69}
70
71/// One text run held for clipping, with the state its placement needs.
72///
73/// Character and word spacing are graphics state rather than properties of the
74/// object, and a `Tc` or `Tw` between two runs applies to the second only, so
75/// each run carries the values that were live when it was shown.
76#[derive(Debug, Clone, PartialEq)]
77pub struct TextClipRun {
78 /// The run itself.
79 pub object: crate::TextObject,
80 /// `Tc` when the run was shown.
81 pub char_space: f32,
82 /// `Tw` when the run was shown.
83 pub word_space: f32,
84}
85
86/// The clipping state: an ordered list of contributions to intersect.
87///
88/// The entries sit behind an `Arc`, so the clone every emitted object takes
89/// of its graphics state shares them: thousands of consecutive paths under
90/// one clip cost one reference count each, and a push copies the vector
91/// only when it is shared. With the vector owned, the state clone was 9.6%
92/// of `vector_paths_1751`'s build.
93#[derive(Debug, Clone, PartialEq, Default)]
94pub struct ClipStack {
95 entries: Arc<Vec<ClipEntry>>,
96 /// How many text objects the whole stack holds, for the 1024 cap.
97 text_objects: usize,
98}
99
100impl ClipStack {
101 /// An unclipped state.
102 #[must_use]
103 pub fn new() -> Self {
104 Self::default()
105 }
106
107 /// The contributions, outermost first.
108 #[must_use]
109 pub fn entries(&self) -> &[ClipEntry] {
110 &self.entries
111 }
112
113 /// How many contributions there are.
114 #[must_use]
115 pub fn len(&self) -> usize {
116 self.entries.len()
117 }
118
119 /// Whether nothing clips.
120 #[must_use]
121 pub fn is_empty(&self) -> bool {
122 self.entries.is_empty()
123 }
124
125 /// Add a path, merging it with a containing rectangle above it.
126 ///
127 /// The merge is what keeps a `re W n` inside a larger `re W n` from
128 /// producing two entries.
129 pub fn push_path(&mut self, path: BezPath, rule: ClipRule) {
130 let incoming = path.bounding_box();
131 if let Some(ClipEntry::Path {
132 path: previous,
133 rule: _,
134 }) = self.entries.last()
135 && let Some(rect) = as_rectangle(previous)
136 && contains_rect(rect, incoming)
137 {
138 Arc::make_mut(&mut self.entries).pop();
139 }
140 Arc::make_mut(&mut self.entries).push(ClipEntry::Path { path, rule });
141 }
142
143 /// Add a batch of clipping text runs.
144 ///
145 /// A batch taking the total past [`MAX_TEXT_OBJECTS`] is **dropped
146 /// whole**, not truncated — no prefix of it clips.
147 ///
148 /// # Errors
149 ///
150 /// [`TextClipLimit`] when the batch would take the stack past the cap.
151 /// The stack is left unchanged; a refused batch does not re-offer itself
152 /// at the next `ET`.
153 pub fn push_text(&mut self, runs: Vec<TextClipRun>) -> Result<(), TextClipLimit> {
154 let adding = runs.len();
155 if self.text_objects + adding > MAX_TEXT_OBJECTS {
156 return Err(TextClipLimit {
157 have: self.text_objects,
158 adding,
159 limit: MAX_TEXT_OBJECTS,
160 });
161 }
162 self.text_objects += adding;
163 Arc::make_mut(&mut self.entries).push(ClipEntry::Text { runs });
164 Ok(())
165 }
166
167 /// Add an empty clip, which blanks everything after it.
168 ///
169 /// This is what a single-point path with a pending clip produces: a
170 /// degenerate rectangle at the origin, whose interior is nothing.
171 pub fn push_empty(&mut self) {
172 Arc::make_mut(&mut self.entries).push(ClipEntry::Path {
173 path: Rect::ZERO.to_path(0.1),
174 rule: ClipRule::Winding,
175 });
176 }
177
178 /// The intersection of every contribution's bounding box, or `None` when
179 /// nothing clips.
180 #[must_use]
181 pub fn bounds(&self) -> Option<Rect> {
182 let mut result: Option<Rect> = None;
183 for entry in self.entries.iter() {
184 let rect = match entry {
185 ClipEntry::Path { path, .. } => path.bounding_box(),
186 // A text layer's contribution is the *union* of its runs; the
187 // layers then intersect.
188 //
189 // A run contributes only its **origin**, because that is the
190 // only geometry an unplaced run has: the glyph outlines come
191 // into existence when a renderer places them, and this entry
192 // deliberately holds the run instead. So the box under-reports
193 // a text clip, and the renderer must not use it to cull —
194 // which it does not: `clip::resolve` reads `entries()` and
195 // nothing in `pdfrum-render` calls this at all. It answers the
196 // page-graph question "where does this clip start", and a
197 // caller wanting the ink must place the runs.
198 ClipEntry::Text { runs } => {
199 let mut union: Option<Rect> = None;
200 for run in runs {
201 let p = run.object.position;
202 let b = Rect::new(p.x, p.y, p.x, p.y);
203 union = Some(union.map_or(b, |u| u.union(b)));
204 }
205 union?
206 }
207 };
208 result = Some(result.map_or(rect, |r| r.intersect(rect)));
209 }
210 result
211 }
212}
213
214/// Whether a path is exactly a rectangle, and which one.
215///
216/// PDFium tests the path's own shape rather than its bounding box, and
217/// builds the rectangle from points 0 and 2.
218fn as_rectangle(path: &BezPath) -> Option<Rect> {
219 let points: Vec<_> = path
220 .elements()
221 .iter()
222 .filter_map(|el| match el {
223 kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) => Some(*p),
224 _ => None,
225 })
226 .collect();
227 // A rectangle is five points with the last closing back onto the first,
228 // or four with an explicit close.
229 if !(4..=5).contains(&points.len()) {
230 return None;
231 }
232 let (p0, p2) = (points.first()?, points.get(2)?);
233 let rect = Rect::from_points(*p0, *p2);
234 // Every point must sit on the rectangle's boundary for it to be one.
235 let on_edge = |p: &kurbo::Point| {
236 let x_edge = (p.x - rect.x0).abs() < 1e-9 || (p.x - rect.x1).abs() < 1e-9;
237 let y_edge = (p.y - rect.y0).abs() < 1e-9 || (p.y - rect.y1).abs() < 1e-9;
238 x_edge && y_edge
239 };
240 points.iter().all(on_edge).then_some(rect)
241}
242
243/// Whether `outer` contains `inner`, inclusively.
244fn contains_rect(outer: Rect, inner: Rect) -> bool {
245 outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
246}
247
248#[cfg(test)]
249mod tests {
250 // Test fixtures quote the oracle's own vectors, compare floats exactly
251 // where the behaviour being pinned is exact, and index arrays whose
252 // length the fixture itself fixes.
253 #![allow(
254 clippy::unreadable_literal,
255 clippy::float_cmp,
256 clippy::indexing_slicing,
257 clippy::cast_precision_loss,
258 clippy::cast_possible_truncation,
259 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
260 )]
261
262 use super::{ClipRule, ClipStack, MAX_TEXT_OBJECTS, TextClipLimit, TextClipRun};
263 use kurbo::{BezPath, Rect, Shape};
264
265 fn rect_path(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath {
266 Rect::new(x0, y0, x1, y1).to_path(0.1)
267 }
268
269 /// A clipping run that shows nothing, at `(x, y)`.
270 ///
271 /// The cap and the bounds are the only things these tests ask of a run,
272 /// and both read its position rather than its glyphs.
273 fn run_at(x: f64, y: f64) -> TextClipRun {
274 TextClipRun {
275 object: crate::TextObject {
276 segments: Box::new([]),
277 position: kurbo::Point::new(x, y),
278 matrix: kurbo::Affine::IDENTITY,
279 font: None,
280 font_source: None,
281 render_mode: crate::ops::TextRenderMode::Clip,
282 type3_metrics: std::collections::BTreeMap::new(),
283 },
284 char_space: 0.0,
285 word_space: 0.0,
286 }
287 }
288
289 #[test]
290 fn a_contained_rectangle_replaces_its_container() {
291 let mut stack = ClipStack::new();
292 stack.push_path(rect_path(0.0, 0.0, 100.0, 100.0), ClipRule::Winding);
293 assert_eq!(stack.len(), 1);
294 // A smaller rectangle inside the first merges rather than stacking.
295 stack.push_path(rect_path(10.0, 10.0, 50.0, 50.0), ClipRule::Winding);
296 assert_eq!(stack.len(), 1);
297 let bounds = stack.bounds().expect("bounds");
298 assert!((bounds.width() - 40.0).abs() < 1.0);
299 }
300
301 #[test]
302 fn an_overlapping_rectangle_does_not_merge() {
303 let mut stack = ClipStack::new();
304 stack.push_path(rect_path(0.0, 0.0, 100.0, 100.0), ClipRule::Winding);
305 // Sticking out to the right: not contained, so both stay.
306 stack.push_path(rect_path(50.0, 50.0, 150.0, 150.0), ClipRule::Winding);
307 assert_eq!(stack.len(), 2);
308 }
309
310 #[test]
311 fn a_text_batch_past_the_cap_is_dropped_whole() {
312 let mut stack = ClipStack::new();
313 let run = || run_at(0.0, 0.0);
314 // Exactly the cap fits.
315 let batch: Vec<_> = std::iter::repeat_with(run).take(MAX_TEXT_OBJECTS).collect();
316 assert!(stack.push_text(batch).is_ok());
317 assert_eq!(stack.len(), 1);
318 // One more is refused, and nothing is truncated in.
319 assert_eq!(
320 stack.push_text(vec![run()]),
321 Err(TextClipLimit {
322 have: MAX_TEXT_OBJECTS,
323 adding: 1,
324 limit: MAX_TEXT_OBJECTS,
325 })
326 );
327 assert_eq!(stack.len(), 1);
328 }
329
330 #[test]
331 fn a_batch_that_would_overflow_is_refused_before_any_of_it_lands() {
332 let mut stack = ClipStack::new();
333 let run = || run_at(0.0, 0.0);
334 let batch: Vec<_> = std::iter::repeat_with(run)
335 .take(MAX_TEXT_OBJECTS - 1)
336 .collect();
337 assert!(stack.push_text(batch).is_ok());
338 // Two more would make 1025: the whole batch is dropped.
339 assert_eq!(
340 stack.push_text(vec![run(), run()]),
341 Err(TextClipLimit {
342 have: MAX_TEXT_OBJECTS - 1,
343 adding: 2,
344 limit: MAX_TEXT_OBJECTS,
345 })
346 );
347 assert_eq!(stack.len(), 1);
348 }
349
350 #[test]
351 fn an_empty_clip_blanks_everything() {
352 let mut stack = ClipStack::new();
353 stack.push_path(rect_path(0.0, 0.0, 100.0, 100.0), ClipRule::Winding);
354 stack.push_empty();
355 let bounds = stack.bounds().expect("bounds");
356 assert!(bounds.area() < 1e-6, "got {bounds:?}");
357 }
358
359 #[test]
360 fn an_unclipped_stack_has_no_bounds() {
361 assert!(ClipStack::new().bounds().is_none());
362 assert!(ClipStack::new().is_empty());
363 }
364
365 #[test]
366 fn text_layers_union_within_and_intersect_between() {
367 let mut stack = ClipStack::new();
368 // One layer covering two far-apart runs unions to a wide box.
369 assert!(
370 stack
371 .push_text(vec![run_at(0.0, 0.0), run_at(100.0, 0.0)])
372 .is_ok()
373 );
374 let bounds = stack.bounds().expect("bounds");
375 assert!((bounds.width() - 100.0).abs() < 1.0);
376 // A second layer intersects with the first.
377 assert!(
378 stack
379 .push_text(vec![run_at(0.0, 0.0), run_at(20.0, 0.0)])
380 .is_ok()
381 );
382 let bounds = stack.bounds().expect("bounds");
383 assert!(bounds.width() <= 21.0);
384 }
385}