Skip to main content

pdfrum_page/pattern/
tiling.rs

1//! Tiling patterns, `/PatternType 1` (ISO 32000-1 ยง8.7.3.2).
2//!
3//! A content stream repeated on a grid. Three behaviours are worth naming
4//! because each one silently makes a pattern paint nothing:
5//!
6//! - **`/XStep` and `/YStep` are absolute-valued at load**, so a negative
7//!   step becomes positive and its sign never reaches the tiler.
8//! - **A zero or non-finite step draws nothing at all.** Negatives cannot
9//!   reach that test, having already been made positive, so the only way to
10//!   trip it is a literal zero, a missing key (which reads as zero), or an
11//!   infinity.
12//! - **Tile indices that do not fit an `i32` abort the whole pattern.** This
13//!   is what stops an absurdly small step from asking for an unbounded number
14//!   of tiles.
15//!
16//! A degenerate `/BBox` does *not* abort: the cell is clamped to one pixel
17//! by one and still drawn.
18
19use crate::names;
20use kurbo::{Affine, Rect};
21use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
22use pdfrum_object::{ByteSpan, Dict, Resolve, Stream};
23
24/// A `/PatternType 1` pattern.
25#[derive(Debug, Clone, PartialEq)]
26pub struct TilingPattern {
27    /// Whether the tile supplies its own colour (`/PaintType 1`). Anything
28    /// else, including `/PaintType 2`, a zero, and a missing key, is
29    /// uncoloured and takes its colour from the `scn` operands.
30    pub colored: bool,
31    /// The horizontal spacing, already absolute-valued.
32    pub x_step: f32,
33    /// The vertical spacing, already absolute-valued.
34    pub y_step: f32,
35    /// The tile's clipping rectangle. Requires exactly four elements or it is
36    /// all zeros.
37    pub bbox: Rect,
38    /// The pattern's space composed with the parent matrix.
39    pub matrix: Affine,
40    /// The tile's `/Resources`.
41    pub resources: Option<Dict>,
42    /// The tile's content stream, still filtered.
43    pub content: ByteSpan,
44    /// The cell's own page objects, interpreted from `content`.
45    ///
46    /// A tile is a content stream like any other, so what it paints is a page
47    /// object list; expanding it at load time is what lets the renderer walk
48    /// a cell with the same code that walks a page. The tile inherits the
49    /// **painting object's general state** โ€” its alpha, blend mode and soft
50    /// mask โ€” and default colour, text and path state, which is why the cell
51    /// is interpreted where the pattern is installed rather than where it is
52    /// declared.
53    pub objects: Vec<crate::page::PageObject>,
54}
55
56/// The range of tile indices covering a clip rectangle.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct TileRange {
59    /// Lowest column index, inclusive.
60    pub min_col: i32,
61    /// Highest column index, inclusive.
62    pub max_col: i32,
63    /// Lowest row index, inclusive.
64    pub min_row: i32,
65    /// Highest row index, inclusive.
66    pub max_row: i32,
67}
68
69impl TilingPattern {
70    /// Load from the stream a `/Pattern` resource names.
71    pub(super) fn load<R: Resolve>(
72        stream: &Stream,
73        matrix: Affine,
74        r: &R,
75        limits: &Limits,
76        diags: &mut Diagnostics,
77    ) -> Self {
78        let _ = (limits, diags);
79        let dict = &stream.dict;
80        Self {
81            colored: dict.int(names::PAINT_TYPE, r) == Some(1),
82            // Absolute-valued here, so the tiler never sees a negative.
83            x_step: dict.number(names::X_STEP, r).unwrap_or(0.0).abs(),
84            y_step: dict.number(names::Y_STEP, r).unwrap_or(0.0).abs(),
85            // Exactly four elements or an all-zero rectangle.
86            bbox: dict
87                .array(names::BBOX, r)
88                .filter(|a| a.len() == 4)
89                .map_or(Rect::ZERO, |a| a.as_rect()),
90            matrix,
91            resources: dict.dict(names::RESOURCES, r),
92            content: stream.data.clone(),
93            // Filled in by `load_pattern`, which has the interpreter.
94            objects: Vec::new(),
95        }
96    }
97
98    /// Whether the steps allow the pattern to be drawn at all.
99    ///
100    /// A zero or non-finite step means it paints nothing โ€” silently, in the
101    /// C++, and with a diagnostic here.
102    #[must_use]
103    pub fn steps_are_drawable(&self) -> bool {
104        self.x_step.is_finite()
105            && self.y_step.is_finite()
106            && self.x_step != 0.0
107            && self.y_step != 0.0
108    }
109
110    /// The tile indices covering `clip`, in the pattern's own space.
111    ///
112    /// `None` when the steps are unusable or an index does not fit an `i32`,
113    /// both of which abort the whole pattern.
114    #[must_use]
115    pub fn tile_range(&self, clip: Rect, diags: &mut Diagnostics) -> Option<TileRange> {
116        if !self.steps_are_drawable() {
117            diags.record(Severity::Suspicious, DiagKind::TilingStepInvalid, None);
118            return None;
119        }
120        let x_step = f64::from(self.x_step);
121        let y_step = f64::from(self.y_step);
122        // Each bound is a *checked* float-to-integer conversion; any failure
123        // aborts, which is what bounds a tiny step's tile count.
124        let to_i32 = |v: f64| -> Option<i32> {
125            if !v.is_finite() || v < f64::from(i32::MIN) || v > f64::from(i32::MAX) {
126                return None;
127            }
128            #[expect(
129                clippy::cast_possible_truncation,
130                reason = "the range check above is the C++'s checked conversion"
131            )]
132            Some(v as i32)
133        };
134        let range = (|| {
135            Some(TileRange {
136                min_col: to_i32(((clip.x0 - self.bbox.x1) / x_step).ceil())?,
137                max_col: to_i32(((clip.x1 - self.bbox.x0) / x_step).floor())?,
138                min_row: to_i32(((clip.y0 - self.bbox.y1) / y_step).ceil())?,
139                max_row: to_i32(((clip.y1 - self.bbox.y0) / y_step).floor())?,
140            })
141        })();
142        if range.is_none() {
143            diags.record(Severity::Suspicious, DiagKind::TilingRangeOverflow, None);
144        }
145        range
146    }
147
148    /// The tile's pixel size in the device space `matrix` maps to, clamped to
149    /// at least one by one.
150    ///
151    /// A degenerate `/BBox` still produces a tile; only a size that will not
152    /// fit an `i32` aborts.
153    ///
154    /// The two edges narrow to `f32` **before** the ceiling, because
155    /// `CFX_FloatRect` is single-precision and the ceiling is exactly where
156    /// that precision shows. A `/Matrix` scale of `0.4` over a 100-unit
157    /// `/BBox` is the case: the nearest `f32` to `0.4` is a shade above it, so
158    /// the edge is `40.000001` in double precision and ceils to 41, while the
159    /// same product rounded to `f32` is exactly `40` and ceils to 40. One
160    /// extra row and column stretches the whole cell by a fortieth and
161    /// smears every tile's contents against its neighbours.
162    #[must_use]
163    pub fn cell_size(&self, to_device: Affine) -> Option<(i32, i32)> {
164        let cell = (to_device * self.matrix).transform_rect_bbox(self.bbox);
165        #[expect(
166            clippy::cast_possible_truncation,
167            reason = "the narrowing is the point: `CFX_FloatRect` is `float`"
168        )]
169        let narrow = |edge: f64| f64::from(edge as f32);
170        let width = narrow(cell.width()).ceil();
171        let height = narrow(cell.height()).ceil();
172        if !width.is_finite()
173            || !height.is_finite()
174            || width > f64::from(i32::MAX)
175            || height > f64::from(i32::MAX)
176        {
177            return None;
178        }
179        #[expect(
180            clippy::cast_possible_truncation,
181            reason = "the range check above is the C++'s checked conversion"
182        )]
183        let (w, h) = (width as i32, height as i32);
184        Some((w.max(1), h.max(1)))
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    // Test fixtures quote the oracle's own vectors, compare floats exactly
191    // where the behaviour being pinned is exact, and index arrays whose
192    // length the fixture itself fixes.
193    #![allow(
194        clippy::unreadable_literal,
195        clippy::float_cmp,
196        clippy::indexing_slicing,
197        clippy::cast_precision_loss,
198        clippy::cast_possible_truncation,
199        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
200    )]
201
202    use super::TilingPattern;
203    use kurbo::{Affine, Rect};
204    use pdfrum_common::{DiagKind, Diagnostics};
205    use pdfrum_object::ByteSpan;
206
207    fn pattern(x_step: f32, y_step: f32) -> TilingPattern {
208        TilingPattern {
209            colored: true,
210            x_step,
211            y_step,
212            bbox: Rect::new(0.0, 0.0, 10.0, 10.0),
213            matrix: Affine::IDENTITY,
214            resources: None,
215            content: ByteSpan::empty(),
216            objects: Vec::new(),
217        }
218    }
219
220    #[test]
221    fn zero_and_non_finite_steps_draw_nothing() {
222        let mut diags = Diagnostics::default();
223        assert!(!pattern(0.0, 10.0).steps_are_drawable());
224        assert!(!pattern(10.0, 0.0).steps_are_drawable());
225        assert!(!pattern(f32::NAN, 10.0).steps_are_drawable());
226        assert!(!pattern(f32::INFINITY, 10.0).steps_are_drawable());
227        assert!(pattern(10.0, 10.0).steps_are_drawable());
228        assert!(
229            pattern(0.0, 10.0)
230                .tile_range(Rect::new(0.0, 0.0, 100.0, 100.0), &mut diags)
231                .is_none()
232        );
233        assert!(diags.contains(&DiagKind::TilingStepInvalid));
234    }
235
236    #[test]
237    fn a_tiny_step_aborts_rather_than_asking_for_endless_tiles() {
238        let mut diags = Diagnostics::default();
239        let p = pattern(1e-30, 1e-30);
240        assert!(
241            p.tile_range(Rect::new(0.0, 0.0, 100.0, 100.0), &mut diags)
242                .is_none()
243        );
244        assert!(diags.contains(&DiagKind::TilingRangeOverflow));
245    }
246
247    #[test]
248    fn a_reasonable_step_covers_the_clip() {
249        let mut diags = Diagnostics::default();
250        let p = pattern(10.0, 10.0);
251        let range = p
252            .tile_range(Rect::new(0.0, 0.0, 100.0, 100.0), &mut diags)
253            .expect("a tile range");
254        assert!(range.min_col <= 0);
255        assert!(range.max_col >= 9);
256        assert!(diags.is_empty());
257    }
258
259    #[test]
260    fn a_degenerate_bbox_still_yields_a_one_pixel_cell() {
261        let p = TilingPattern {
262            bbox: Rect::ZERO,
263            ..pattern(10.0, 10.0)
264        };
265        assert_eq!(p.cell_size(Affine::IDENTITY), Some((1, 1)));
266        // A negative-extent bbox likewise.
267        let p = TilingPattern {
268            bbox: Rect::new(10.0, 10.0, 0.0, 0.0),
269            ..pattern(10.0, 10.0)
270        };
271        let (w, h) = p.cell_size(Affine::IDENTITY).expect("a cell");
272        assert!(w >= 1 && h >= 1);
273    }
274
275    #[test]
276    fn a_scale_that_is_whole_only_in_single_precision_gives_a_whole_cell() {
277        // `2_uncolor_tiling.pdf`: a 100-unit `/BBox` under a `/Matrix` scale of
278        // `0.4`. The nearest `f32` to `0.4` is above it, so the edge is
279        // `40.000001` in double precision; `CFX_FloatRect` holds `40` exactly.
280        let scale = f64::from(0.4f32);
281        let p = TilingPattern {
282            bbox: Rect::new(0.0, 0.0, 100.0, 100.0),
283            ..pattern(100.0, 100.0)
284        };
285        assert_eq!(
286            p.cell_size(Affine::scale(scale)),
287            Some((40, 40)),
288            "the ceiling must not see the widening error"
289        );
290    }
291
292    #[test]
293    fn an_enormous_cell_aborts() {
294        let p = TilingPattern {
295            bbox: Rect::new(0.0, 0.0, 1e30, 1e30),
296            ..pattern(10.0, 10.0)
297        };
298        assert!(p.cell_size(Affine::IDENTITY).is_none());
299    }
300}