Skip to main content

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}