1mod 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
45pub const MAX_FUNCTIONS: usize = 4;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub enum ShadingKind {
53 FunctionBased = 1,
55 Axial = 2,
57 Radial = 3,
59 FreeFormMesh = 4,
61 LatticeMesh = 5,
63 CoonsMesh = 6,
65 TensorMesh = 7,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub enum ShadingSource {
73 ShadingOperator,
78 Pattern,
83}
84
85impl ShadingKind {
86 #[must_use]
91 pub fn reads_edge_flags(self) -> bool {
92 self != Self::LatticeMesh
93 }
94
95 #[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 #[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#[derive(Debug, Clone, PartialEq)]
122pub struct Shading {
123 pub geometry: Geometry,
125 pub space: Arc<ColorSpace>,
127 pub functions: Box<[Arc<Function>]>,
129 pub background: Option<Rgb>,
132 pub bbox: Option<Rect>,
135}
136
137#[derive(Debug, Clone, PartialEq)]
139#[non_exhaustive]
140pub enum Geometry {
141 FunctionBased(FunctionBased),
143 Axial(Axial),
145 Radial(Radial),
147 Mesh {
149 kind: ShadingKind,
151 mesh: Box<Mesh>,
153 },
154}
155
156impl Shading {
157 #[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 #[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 if kind.is_mesh() && stream.is_none() {
195 diags.record(Severity::Suspicious, DiagKind::ShadingUnsupported, None);
196 return None;
197 }
198
199 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 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 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 #[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
279fn 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 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 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
317fn 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 ShadingKind::FunctionBased | ShadingKind::Axial | ShadingKind::Radial => {
324 if indexed {
325 return false;
326 }
327 }
328 _ => {
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 ShadingKind::FunctionBased | ShadingKind::Axial | ShadingKind::Radial => shapes,
344 _ => functions.is_empty() || shapes,
346 }
347}
348
349fn 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#[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 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, ¶ms, 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 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#[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 (
444 array.bool_at(0).unwrap_or(false),
445 array.bool_at(1).unwrap_or(false),
446 )
447}
448
449#[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 #![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 assert!(validate(ShadingKind::Axial, &rgb, &[func(1, 3)]));
509 assert!(validate(
511 ShadingKind::Axial,
512 &rgb,
513 &[func(1, 1), func(1, 1), func(1, 1)]
514 ));
515 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 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 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 assert!(!validate(ShadingKind::Axial, &indexed, &[func(1, 1)]));
552 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 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 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}