pdfrum_page/pattern/mod.rs
1//! Patterns, tiling and shading (ISO 32000-1 §8.7.3).
2//!
3//! # The matrix rule
4//!
5//! A pattern's own `/Matrix` composes with the **parent matrix** — the
6//! form's or page's coordinate system — not with the current transformation
7//! matrix. Patterns are anchored to the space they were declared in, so
8//! translating the CTM before filling does not slide the tiling. This is a
9//! frequent source of bugs and is worth restating whenever it comes up.
10//!
11//! `kurbo::Affine` multiplies right-to-left, so the code writes
12//! `parent * pattern` — the pattern's matrix applied first, then the parent's.
13//!
14//! One asymmetry to know: a **bare shading** reached through the `sh`
15//! operator ignores any `/Matrix` on its dictionary entirely. The matrix is
16//! composed for a pattern, never for a shading object reached directly.
17
18mod tiling;
19
20pub use tiling::{TileRange, TilingPattern};
21
22use crate::color::ColorSpace;
23use crate::function::FunctionCache;
24use crate::names;
25use crate::shading::{Shading, ShadingSource};
26use kurbo::Affine;
27use pdfrum_common::{Diagnostics, Limits};
28use pdfrum_object::{Dict, Object, Resolve};
29use std::sync::Arc;
30
31/// A loaded pattern.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub enum Pattern {
35 /// `/PatternType 1`: a content stream tiled across the fill.
36 Tiling(Box<TilingPattern>),
37 /// `/PatternType 2`: a shading painted across the fill.
38 Shading(Box<ShadingPattern>),
39}
40
41/// A `/PatternType 2` pattern: a shading plus the matrix placing it.
42#[derive(Debug, Clone, PartialEq)]
43pub struct ShadingPattern {
44 /// The shading itself.
45 pub shading: Arc<Shading>,
46 /// The pattern's own space composed with the parent matrix.
47 pub matrix: Affine,
48 /// The pattern's `/ExtGState`, which a shading pattern may carry.
49 pub ext_g_state: Option<Dict>,
50}
51
52impl Pattern {
53 /// The matrix taking the pattern's own space to the parent's.
54 #[must_use]
55 pub fn matrix(&self) -> Affine {
56 match self {
57 Self::Tiling(p) => p.matrix,
58 Self::Shading(p) => p.matrix,
59 }
60 }
61
62 /// Load a pattern from the object a `/Pattern` resource names.
63 ///
64 /// `parent_matrix` is the form's or page's coordinate system, **not** the
65 /// current transformation matrix. Dispatch is on `/PatternType`: 1 is
66 /// tiling, 2 is shading, and anything else — including a missing key —
67 /// yields no pattern at all.
68 ///
69 /// **There is no pattern cache.** A pattern is loaded afresh at each use,
70 /// so the `parent_matrix` a use is given is the one it gets.
71 //
72 // [oracle-bug] cpdf_docpagedata.cpp:388 looks a pattern up in
73 // `pattern_map_` keyed on **the pattern object alone**, and :406 stores it
74 // there. The `matrix` argument is a *construction* parameter (:396, :400),
75 // so it is baked into whichever instance was built first and every later
76 // user of the same object silently inherits it. Two forms at different
77 // nesting levels naming one pattern therefore paint it in one form's
78 // coordinate system — §8.7.3 anchors a pattern in the space of the
79 // content stream in which it is *used*, which is what makes the shared
80 // matrix wrong rather than merely surprising. Worse, `GetShading`
81 // (:410-424) consults and writes **the same map** while constructing with
82 // `bShading = true` where `GetPattern` passes `false` (:400), so an object
83 // reached once through `sh` and once through `scn` returns whichever was
84 // built first — with the wrong `/Background` handling for the other. There
85 // is no independent implementation to weigh: pdf.js is canvas-backed and
86 // has no pattern cache at all. We keep no cache either, which cannot
87 // alias; if one is ever added it must be keyed on
88 // `(ObjRef, parent_matrix, is_shading)`.
89 #[must_use]
90 pub fn load<R: Resolve>(
91 obj: &Object,
92 parent_matrix: Affine,
93 resources: Option<&Dict>,
94 r: &R,
95 functions: &mut FunctionCache,
96 limits: &Limits,
97 diags: &mut Diagnostics,
98 ) -> Option<Self> {
99 let resolved = obj.resolve(r).ok()?;
100 let dict = match &*resolved {
101 Object::Dict(d) => d.clone(),
102 Object::Stream(s) => s.dict.clone(),
103 _ => return None,
104 };
105 // `/Matrix` must have exactly six elements or it reads as identity.
106 let own = dict.matrix(names::MATRIX, r);
107 let matrix = parent_matrix * own;
108
109 match dict.int(names::PATTERN_TYPE, r)? {
110 1 => {
111 let stream = resolved.as_stream()?;
112 Some(Self::Tiling(Box::new(TilingPattern::load(
113 stream, matrix, r, limits, diags,
114 ))))
115 }
116 2 => {
117 let shading_obj = dict.raw(names::SHADING)?;
118 let shading = Shading::load(
119 shading_obj,
120 resources,
121 ShadingSource::Pattern,
122 r,
123 functions,
124 limits,
125 diags,
126 )?;
127 Some(Self::Shading(Box::new(ShadingPattern {
128 shading: Arc::new(shading),
129 matrix,
130 ext_g_state: dict.dict(names::EXT_G_STATE, r),
131 })))
132 }
133 _ => None,
134 }
135 }
136}
137
138/// The colour an uncoloured pattern paints with, and the fallbacks when none
139/// resolves.
140///
141/// PDFium's two sentinels are worth naming: a **coloured** tiling pattern
142/// whose colour will not resolve falls back to mid grey (`0xBFBFBF`), while
143/// everything else falls back to white. The grey is what makes an unpainted
144/// coloured tile visible rather than invisible.
145#[must_use]
146pub fn uncolored_pattern_rgb(
147 space: &ColorSpace,
148 components: &[f32],
149 colored_tiling: bool,
150) -> crate::color::Rgb {
151 if let ColorSpace::Pattern(p) = space
152 && let Some(rgb) = p.to_rgb(components)
153 {
154 return rgb;
155 }
156 if colored_tiling {
157 // Mid grey, `0x00BFBFBF`.
158 crate::color::Rgb {
159 r: 191.0 / 255.0,
160 g: 191.0 / 255.0,
161 b: 191.0 / 255.0,
162 }
163 } else {
164 crate::color::Rgb {
165 r: 1.0,
166 g: 1.0,
167 b: 1.0,
168 }
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 // Test fixtures quote the oracle's own vectors, compare floats exactly
175 // where the behaviour being pinned is exact, and index arrays whose
176 // length the fixture itself fixes.
177 #![allow(
178 clippy::unreadable_literal,
179 clippy::float_cmp,
180 clippy::indexing_slicing,
181 clippy::cast_precision_loss,
182 clippy::cast_possible_truncation,
183 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
184 )]
185
186 use super::{Pattern, uncolored_pattern_rgb};
187 use crate::color::{ColorSpace, PatternSpace};
188 use crate::function::FunctionCache;
189 use kurbo::Affine;
190 use pdfrum_common::{Diagnostics, Limits};
191 use pdfrum_object::{Dict, Name, NoResolve, Object};
192
193 fn load(dict: Dict, parent: Affine) -> Option<Pattern> {
194 let mut funcs = FunctionCache::new();
195 let mut diags = Diagnostics::default();
196 Pattern::load(
197 &Object::Dict(dict),
198 parent,
199 None,
200 &NoResolve,
201 &mut funcs,
202 &Limits::default(),
203 &mut diags,
204 )
205 }
206
207 #[test]
208 fn an_unknown_pattern_type_yields_no_pattern() {
209 for kind in [0i64, 3, -1] {
210 let dict = Dict::from_pairs([(Name::from("PatternType"), Object::Int(kind))]);
211 assert!(load(dict, Affine::IDENTITY).is_none(), "type {kind}");
212 }
213 // A missing `/PatternType` likewise.
214 assert!(load(Dict::new(), Affine::IDENTITY).is_none());
215 }
216
217 #[test]
218 fn a_tiling_pattern_must_be_a_stream() {
219 // A plain dictionary with `/PatternType 1` is not enough.
220 let dict = Dict::from_pairs([(Name::from("PatternType"), Object::Int(1))]);
221 assert!(load(dict, Affine::IDENTITY).is_none());
222 }
223
224 #[test]
225 fn uncolored_fallbacks_differ_by_paint_type() {
226 let no_base = ColorSpace::Pattern(Box::default());
227 let grey = uncolored_pattern_rgb(&no_base, &[0.5], true);
228 assert!((grey.r - 191.0 / 255.0).abs() < 1e-5);
229 let white = uncolored_pattern_rgb(&no_base, &[0.5], false);
230 assert!((white.r - 1.0).abs() < 1e-6);
231
232 // With a base the operands resolve normally and no fallback applies.
233 let with_base = ColorSpace::Pattern(Box::new(PatternSpace {
234 base: Some(Box::new(ColorSpace::DeviceGray)),
235 }));
236 let resolved = uncolored_pattern_rgb(&with_base, &[0.25], true);
237 assert!((resolved.r - 0.25).abs() < 1e-6);
238 }
239}