Skip to main content

pdfrum_page/shading/
mod.rs

1//! Shadings, types 1 to 7 (ISO 32000-1 ยง8.7.4.5).
2//!
3//! A shading is a colorspace, up to four functions, and a geometry. Loading
4//! validates all three together, because the arity a function must have
5//! depends on both the type and the colorspace's component count:
6//!
7//! | Type | Functions |
8//! |---|---|
9//! | 1 | one 2-in *N*-out, or *N* 2-in 1-out. **Required.** |
10//! | 2, 3 | one 1-in *N*-out, or *N* 1-in 1-out. **Required.** |
11//! | 4-7 | none, or either shape above. **Optional.** |
12//!
13//! Because the `/Function` array is capped at **four** entries, a colorspace
14//! with more than four components can never satisfy the "*N* one-to-one
15//! functions" branch through an array โ€” only through the single-function
16//! form.
17//!
18//! # `/Extend` is stricter than it looks
19//!
20//! `GetBooleanAt` returns its default for a non-Boolean element, so
21//! `/Extend [0 1]` โ€” integers, not booleans โ€” yields **false, false**, not
22//! false and true. Files written that way do not extend.
23
24mod axial;
25mod function_based;
26mod mesh;
27mod radial;
28
29pub use axial::Axial;
30pub use function_based::FunctionBased;
31pub use mesh::{
32    MAX_COMPONENTS, Mesh, MeshParams, MeshReader, Patch, Triangle, Vertex, coons_interior,
33};
34pub use radial::Radial;
35
36use crate::color::{ColorSpace, Rgb};
37use crate::function::{Function, FunctionCache};
38use crate::names;
39use kurbo::Rect;
40use pdfrum_common::{DiagKind, Diagnostics, Limits, Severity};
41use pdfrum_filters::decode_chain;
42use pdfrum_object::{Dict, Object, Resolve};
43use std::sync::Arc;
44
45/// The most functions a `/Function` **array** may hold. A literal in the C++,
46/// not a named constant, and the reason a five-function array silently loses
47/// its last entry.
48pub const MAX_FUNCTIONS: usize = 4;
49
50/// Which geometry a shading paints.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum ShadingKind {
53    /// Type 1: a function over a rectangle of the shading's own space.
54    FunctionBased = 1,
55    /// Type 2: a linear gradient along an axis.
56    Axial = 2,
57    /// Type 3: a gradient between two circles.
58    Radial = 3,
59    /// Type 4: free-form Gouraud triangles.
60    FreeFormMesh = 4,
61    /// Type 5: lattice-form Gouraud triangles.
62    LatticeMesh = 5,
63    /// Type 6: Coons patches.
64    CoonsMesh = 6,
65    /// Type 7: tensor-product patches.
66    TensorMesh = 7,
67}
68
69/// Which entry point reached a shading, which decides whether `/Background`
70/// is honoured.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum ShadingSource {
73    /// The `sh` operator, naming a `/Shading` resource directly.
74    ///
75    /// `/Background` is **ignored**: `sh` paints only the shading's own
76    /// geometry.
77    ShadingOperator,
78    /// A `/PatternType 2` pattern wrapping the shading.
79    ///
80    /// `/Background` **is** honoured, filling the pattern cell outside the
81    /// geometry.
82    Pattern,
83}
84
85impl ShadingKind {
86    /// Whether a mesh of this kind carries a per-vertex edge flag.
87    ///
88    /// Only the lattice type does not: its topology is the row length, so
89    /// its `/BitsPerFlag` is never read and never validated.
90    #[must_use]
91    pub fn reads_edge_flags(self) -> bool {
92        self != Self::LatticeMesh
93    }
94
95    /// The kind `n` names, or `None` for anything outside 1..=7.
96    #[must_use]
97    pub fn from_int(n: i64) -> Option<Self> {
98        Some(match n {
99            1 => Self::FunctionBased,
100            2 => Self::Axial,
101            3 => Self::Radial,
102            4 => Self::FreeFormMesh,
103            5 => Self::LatticeMesh,
104            6 => Self::CoonsMesh,
105            7 => Self::TensorMesh,
106            _ => return None,
107        })
108    }
109
110    /// Whether this kind reads its geometry from a stream of vertices.
111    #[must_use]
112    pub fn is_mesh(self) -> bool {
113        matches!(
114            self,
115            Self::FreeFormMesh | Self::LatticeMesh | Self::CoonsMesh | Self::TensorMesh
116        )
117    }
118}
119
120/// A loaded, validated shading.
121#[derive(Debug, Clone, PartialEq)]
122pub struct Shading {
123    /// The geometry and its parameters.
124    pub geometry: Geometry,
125    /// The colour space every function's output lands in.
126    pub space: Arc<ColorSpace>,
127    /// The tint functions, at most four when they came from an array.
128    pub functions: Box<[Arc<Function>]>,
129    /// `/Background`, honoured only for a `/PatternType 2` shading pattern
130    /// and never for the `sh` operator.
131    pub background: Option<Rgb>,
132    /// `/BBox`, in the shading's own space. A malformed array collapses this
133    /// to a zero rectangle, which makes the shading invisible.
134    pub bbox: Option<Rect>,
135}
136
137/// A shading's geometry.
138#[derive(Debug, Clone, PartialEq)]
139#[non_exhaustive]
140pub enum Geometry {
141    /// Type 1.
142    FunctionBased(FunctionBased),
143    /// Type 2.
144    Axial(Axial),
145    /// Type 3.
146    Radial(Radial),
147    /// Types 4 to 7, already decoded from the stream.
148    Mesh {
149        /// Which of the four mesh types this is.
150        kind: ShadingKind,
151        /// The decoded triangles and patches.
152        mesh: Box<Mesh>,
153    },
154}
155
156impl Shading {
157    /// Which kind this is.
158    #[must_use]
159    pub fn kind(&self) -> ShadingKind {
160        match &self.geometry {
161            Geometry::FunctionBased(_) => ShadingKind::FunctionBased,
162            Geometry::Axial(_) => ShadingKind::Axial,
163            Geometry::Radial(_) => ShadingKind::Radial,
164            Geometry::Mesh { kind, .. } => *kind,
165        }
166    }
167
168    /// Load and validate a shading.
169    ///
170    /// Returns `None` for every condition PDFium treats as "unsupported
171    /// shading", which paints nothing.
172    #[must_use]
173    pub fn load<R: Resolve>(
174        obj: &Object,
175        resources: Option<&Dict>,
176        source: ShadingSource,
177        r: &R,
178        functions_cache: &mut FunctionCache,
179        limits: &Limits,
180        diags: &mut Diagnostics,
181    ) -> Option<Self> {
182        let resolved = obj.resolve(r).ok()?;
183        let (dict, stream) = match &*resolved {
184            Object::Dict(d) => (d.clone(), None),
185            Object::Stream(s) => (s.dict.clone(), Some(s.clone())),
186            _ => {
187                diags.record(Severity::Suspicious, DiagKind::ShadingUnsupported, None);
188                return None;
189            }
190        };
191
192        let kind = ShadingKind::from_int(dict.int(names::SHADING_TYPE, r).unwrap_or(0))?;
193        // Mesh types **require** a stream; types 1 to 3 accept either.
194        if kind.is_mesh() && stream.is_none() {
195            diags.record(Severity::Suspicious, DiagKind::ShadingUnsupported, None);
196            return None;
197        }
198
199        // `/ColorSpace` is required and may not be a Pattern space
200        // (ISO 32000-1 table 78).
201        let cs_obj = dict.raw(names::COLOR_SPACE)?;
202        let space =
203            crate::color::load_colorspace(cs_obj, resources, r, functions_cache, limits, diags)?;
204        if matches!(space, ColorSpace::Pattern(_)) {
205            diags.record(Severity::Suspicious, DiagKind::ShadingUnsupported, None);
206            return None;
207        }
208        let space = Arc::new(space);
209
210        let functions = load_functions(&dict, r, functions_cache, limits, diags);
211        if !validate(kind, &space, &functions) {
212            diags.record(Severity::Suspicious, DiagKind::ShadingUnsupported, None);
213            return None;
214        }
215
216        let geometry = match kind {
217            ShadingKind::FunctionBased => Geometry::FunctionBased(FunctionBased::load(&dict, r)),
218            ShadingKind::Axial => Geometry::Axial(Axial::load(&dict, r)?),
219            ShadingKind::Radial => Geometry::Radial(Radial::load(&dict, r)?),
220            _ => {
221                let stream = stream?;
222                let mesh = load_mesh(kind, &stream, &dict, &space, &functions, r, limits, diags)?;
223                Geometry::Mesh {
224                    kind,
225                    mesh: Box::new(mesh),
226                }
227            }
228        };
229
230        // `/Background` is honoured **only** for a pattern, never for `sh`,
231        // and only when the array is at least as long as the space needs.
232        let background = (source == ShadingSource::Pattern)
233            .then(|| dict.array(names::BACKGROUND, r))
234            .flatten()
235            .filter(|a| a.len() >= space.n_components())
236            .map(|a| {
237                let comps: Vec<f32> = (0..space.n_components())
238                    .map(|i| a.number_at_or_zero(i))
239                    .collect();
240                space.to_rgb(&comps)
241            });
242
243        // `/BBox` needs exactly four elements; anything else collapses to a
244        // zero rectangle, which is what makes such a shading invisible.
245        let bbox = dict.array(names::BBOX, r).map(|a| {
246            if a.len() == 4 {
247                a.as_rect()
248            } else {
249                Rect::ZERO
250            }
251        });
252
253        Some(Self {
254            geometry,
255            space,
256            functions,
257            background,
258            bbox,
259        })
260    }
261
262    /// Evaluate the shading's functions at one parametric position,
263    /// concatenating multi-function outputs, and convert to a colour.
264    #[must_use]
265    pub fn color_at(&self, t: f32) -> Rgb {
266        let total: usize = self.functions.iter().map(|f| f.output_count()).sum();
267        let mut buffer = vec![0.0f32; total.max(self.space.n_components())];
268        let mut written = 0usize;
269        for f in &self.functions {
270            let Some(span) = buffer.get_mut(written..) else {
271                break;
272            };
273            written += f.eval_into(&[t], span);
274        }
275        self.space.to_rgb(&buffer)
276    }
277}
278
279/// Read `/Function`, which may be one function or an array of up to four.
280fn load_functions<R: Resolve>(
281    dict: &Dict,
282    r: &R,
283    cache: &mut FunctionCache,
284    limits: &Limits,
285    diags: &mut Diagnostics,
286) -> Box<[Arc<Function>]> {
287    let Some(obj) = dict.raw(names::FUNCTION) else {
288        return Box::default();
289    };
290    // An array of functions; entries past the fourth are dropped, and an
291    // entry that fails to load leaves a hole that `validate` then rejects.
292    if let Some(array) = obj.as_array() {
293        let mut out = Vec::new();
294        let mut had_hole = false;
295        for i in 0..array.len().min(MAX_FUNCTIONS) {
296            match array
297                .raw_at(i)
298                .and_then(|o| cache.load(o, r, limits, diags))
299            {
300                Some(f) => out.push(f),
301                None => had_hole = true,
302            }
303        }
304        if had_hole {
305            // A null entry rejects the whole shading, so signal it by
306            // returning a set that cannot validate.
307            return Box::default();
308        }
309        return out.into();
310    }
311    match cache.load(obj, r, limits, diags) {
312        Some(f) => Box::from([f]),
313        None => Box::default(),
314    }
315}
316
317/// The type-and-colorspace-dependent function requirements.
318fn validate(kind: ShadingKind, space: &ColorSpace, functions: &[Arc<Function>]) -> bool {
319    let n = space.n_components();
320    let indexed = matches!(space, ColorSpace::Indexed(_));
321    match kind {
322        // Types 1 to 3 forbid Indexed unconditionally.
323        ShadingKind::FunctionBased | ShadingKind::Axial | ShadingKind::Radial => {
324            if indexed {
325                return false;
326            }
327        }
328        // Mesh types forbid it only when functions are present.
329        _ => {
330            if indexed && !functions.is_empty() {
331                return false;
332            }
333        }
334    }
335    let inputs = if kind == ShadingKind::FunctionBased {
336        2
337    } else {
338        1
339    };
340    let shapes = shape_matches(functions, 1, inputs, n) || shape_matches(functions, n, inputs, 1);
341    match kind {
342        // Mandatory for types 1 to 3.
343        ShadingKind::FunctionBased | ShadingKind::Axial | ShadingKind::Radial => shapes,
344        // Optional for the mesh types.
345        _ => functions.is_empty() || shapes,
346    }
347}
348
349/// Whether `functions` is exactly `count` functions of `inputs` to `outputs`.
350fn shape_matches(functions: &[Arc<Function>], count: usize, inputs: usize, outputs: usize) -> bool {
351    functions.len() == count
352        && functions
353            .iter()
354            .all(|f| f.input_count() == inputs && f.output_count() == outputs)
355}
356
357/// Decode a mesh stream into triangles or patches.
358#[expect(
359    clippy::too_many_arguments,
360    reason = "the mesh reader needs the stream, its dictionary, the colour \
361              space, the functions and the usual resolver/limits/diagnostics trio"
362)]
363fn load_mesh<R: Resolve>(
364    kind: ShadingKind,
365    stream: &pdfrum_object::Stream,
366    dict: &Dict,
367    space: &ColorSpace,
368    functions: &[Arc<Function>],
369    r: &R,
370    limits: &Limits,
371    diags: &mut Diagnostics,
372) -> Option<Mesh> {
373    if space.n_components() > MAX_COMPONENTS {
374        return None;
375    }
376    let coord_bits = u32::try_from(dict.int(names::BITS_PER_COORDINATE, r).unwrap_or(0)).ok()?;
377    let component_bits = u32::try_from(dict.int(names::BITS_PER_COMPONENT, r).unwrap_or(0)).ok()?;
378    let flag_bits = u32::try_from(dict.int(names::BITS_PER_FLAG, r).unwrap_or(0)).unwrap_or(0);
379    // With any function present, exactly one parametric value is read per
380    // colour and the functions map it.
381    let components = if functions.is_empty() {
382        space.n_components()
383    } else {
384        1
385    };
386    let decode: Vec<f32> = match dict.array(names::DECODE, r) {
387        Some(a) => (0..a.len()).map(|i| a.number_at_or_zero(i)).collect(),
388        None => Vec::new(),
389    };
390    let Some(params) = MeshParams::new(
391        coord_bits,
392        component_bits,
393        flag_bits,
394        components,
395        &decode,
396        kind,
397    ) else {
398        diags.record(Severity::Suspicious, DiagKind::MeshDecodeMalformed, None);
399        return None;
400    };
401
402    let component_range = params.component_range();
403    let data = decode_chain(stream, 0, r, limits, diags).data;
404    let mut reader = MeshReader::new(&data, &params, space, functions);
405    let mesh = match kind {
406        ShadingKind::FreeFormMesh => Mesh {
407            triangles: reader.read_free_form(),
408            patches: Vec::new(),
409            component_range,
410        },
411        ShadingKind::LatticeMesh => {
412            let per_row = dict.int(names::VERTICES_PER_ROW, r).unwrap_or(0);
413            // At least two, with no maximum: an absurd value simply produces
414            // an empty first row and aborts.
415            let per_row = usize::try_from(per_row).unwrap_or(0);
416            Mesh {
417                triangles: reader.read_lattice(per_row),
418                patches: Vec::new(),
419                component_range,
420            }
421        }
422        ShadingKind::CoonsMesh | ShadingKind::TensorMesh => Mesh {
423            triangles: Vec::new(),
424            patches: reader.read_patches(kind),
425            component_range,
426        },
427        _ => return None,
428    };
429    if mesh.triangles.is_empty() && mesh.patches.is_empty() && !data.is_empty() {
430        diags.record(Severity::Recovered, DiagKind::MeshTruncated, None);
431    }
432    Some(mesh)
433}
434
435/// Read `/Extend`, which needs genuine booleans.
436#[must_use]
437pub fn read_extend(dict: &Dict, r: &impl Resolve) -> (bool, bool) {
438    let Some(array) = dict.array(names::EXTEND, r) else {
439        return (false, false);
440    };
441    // `GetBooleanAt` yields the default for a non-Boolean element, so
442    // `/Extend [0 1]` gives false, false.
443    (
444        array.bool_at(0).unwrap_or(false),
445        array.bool_at(1).unwrap_or(false),
446    )
447}
448
449/// Read `/Domain`, two elements defaulting to `[0, 1]`.
450///
451/// A shorter array reads zero for the missing entries, and there is **no**
452/// check that the low bound is below the high one.
453#[must_use]
454pub fn read_domain(dict: &Dict, r: &impl Resolve) -> (f32, f32) {
455    match dict.array(names::DOMAIN, r) {
456        Some(a) => (a.number_at_or_zero(0), a.number_at_or_zero(1)),
457        None => (0.0, 1.0),
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    // Test fixtures quote the oracle's own vectors, compare floats exactly
464    // where the behaviour being pinned is exact, and index arrays whose
465    // length the fixture itself fixes.
466    #![allow(
467        clippy::unreadable_literal,
468        clippy::float_cmp,
469        clippy::indexing_slicing,
470        clippy::cast_precision_loss,
471        clippy::cast_possible_truncation,
472        reason = "test fixtures quote oracle vectors verbatim and compare exactly"
473    )]
474
475    use super::{ShadingKind, read_domain, read_extend, validate};
476    use crate::color::ColorSpace;
477    use crate::function::{Exponential, Function};
478    use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
479    use std::sync::Arc;
480
481    fn func(inputs: usize, outputs: usize) -> Arc<Function> {
482        Arc::new(Function::Exponential(Exponential {
483            domain: (0..inputs).flat_map(|_| [0.0f32, 1.0]).collect(),
484            range: (0..outputs).flat_map(|_| [0.0f32, 1.0]).collect(),
485            c0: vec![0.0f32; outputs].into(),
486            c1: vec![1.0f32; outputs].into(),
487            exponent: 1.0,
488            orig_outputs: outputs,
489            outputs,
490        }))
491    }
492
493    #[test]
494    fn only_types_one_to_seven_are_shadings() {
495        assert_eq!(ShadingKind::from_int(1), Some(ShadingKind::FunctionBased));
496        assert_eq!(ShadingKind::from_int(7), Some(ShadingKind::TensorMesh));
497        assert!(ShadingKind::from_int(0).is_none());
498        assert!(ShadingKind::from_int(8).is_none());
499        assert!(ShadingKind::from_int(-1).is_none());
500        assert!(ShadingKind::FreeFormMesh.is_mesh());
501        assert!(!ShadingKind::Axial.is_mesh());
502    }
503
504    #[test]
505    fn axial_and_radial_need_a_one_in_n_out_function() {
506        let rgb = ColorSpace::DeviceRgb;
507        // One 1-to-3 function.
508        assert!(validate(ShadingKind::Axial, &rgb, &[func(1, 3)]));
509        // Or three 1-to-1 functions.
510        assert!(validate(
511            ShadingKind::Axial,
512            &rgb,
513            &[func(1, 1), func(1, 1), func(1, 1)]
514        ));
515        // But not two, and not a 2-input one.
516        assert!(!validate(
517            ShadingKind::Axial,
518            &rgb,
519            &[func(1, 1), func(1, 1)]
520        ));
521        assert!(!validate(ShadingKind::Axial, &rgb, &[func(2, 3)]));
522        // And not none at all: they are mandatory here.
523        assert!(!validate(ShadingKind::Axial, &rgb, &[]));
524    }
525
526    #[test]
527    fn function_based_shadings_take_two_inputs() {
528        let rgb = ColorSpace::DeviceRgb;
529        assert!(validate(ShadingKind::FunctionBased, &rgb, &[func(2, 3)]));
530        assert!(!validate(ShadingKind::FunctionBased, &rgb, &[func(1, 3)]));
531    }
532
533    #[test]
534    fn mesh_functions_are_optional() {
535        let rgb = ColorSpace::DeviceRgb;
536        assert!(validate(ShadingKind::FreeFormMesh, &rgb, &[]));
537        assert!(validate(ShadingKind::FreeFormMesh, &rgb, &[func(1, 3)]));
538        // But a wrong shape still fails.
539        assert!(!validate(ShadingKind::FreeFormMesh, &rgb, &[func(2, 2)]));
540    }
541
542    #[test]
543    fn indexed_is_forbidden_differently_per_type() {
544        let indexed = ColorSpace::Indexed(Box::new(crate::color::Indexed {
545            base: Box::new(ColorSpace::DeviceRgb),
546            max_index: 1,
547            lookup: Box::from(&[0u8; 6][..]),
548            component_ranges: Box::from(&[(0.0f32, 1.0f32); 3][..]),
549        }));
550        // Types 1 to 3 refuse it whatever the functions are.
551        assert!(!validate(ShadingKind::Axial, &indexed, &[func(1, 1)]));
552        // Mesh types refuse it only when functions are present.
553        assert!(validate(ShadingKind::FreeFormMesh, &indexed, &[]));
554        assert!(!validate(
555            ShadingKind::FreeFormMesh,
556            &indexed,
557            &[func(1, 1)]
558        ));
559    }
560
561    #[test]
562    fn extend_needs_genuine_booleans() {
563        let with_ints = Dict::from_pairs([(
564            Name::from("Extend"),
565            Object::Array(Array::of([Object::Int(0), Object::Int(1)])),
566        )]);
567        // Integers are not booleans, so neither end extends.
568        assert_eq!(read_extend(&with_ints, &NoResolve), (false, false));
569
570        let with_bools = Dict::from_pairs([(
571            Name::from("Extend"),
572            Object::Array(Array::of([Object::Bool(false), Object::Bool(true)])),
573        )]);
574        assert_eq!(read_extend(&with_bools, &NoResolve), (false, true));
575
576        assert_eq!(read_extend(&Dict::new(), &NoResolve), (false, false));
577    }
578
579    #[test]
580    fn domain_defaults_to_zero_one_only_when_absent() {
581        assert_eq!(read_domain(&Dict::new(), &NoResolve), (0.0, 1.0));
582        // A short array reads zeros for what it does not state.
583        let short = Dict::from_pairs([(
584            Name::from("Domain"),
585            Object::Array(Array::of([Object::Real(0.25)])),
586        )]);
587        assert_eq!(read_domain(&short, &NoResolve), (0.25, 0.0));
588    }
589}