Skip to main content

oxidize_pdf/graphics/
shadings.rs

1//! Shading support for PDF graphics according to ISO 32000-1 Section 8.7.4
2//!
3//! This module provides basic support for PDF shadings including:
4//! - Axial shadings (linear gradients)
5//! - Radial shadings (radial gradients)
6//! - Function-based shadings
7//! - Shading dictionaries and patterns
8
9use crate::error::{PdfError, Result};
10use crate::graphics::Color;
11use crate::objects::{Dictionary, Object};
12use std::collections::HashMap;
13
14/// Shading type enumeration according to ISO 32000-1
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum ShadingType {
17    /// Function-based shading (Type 1)
18    FunctionBased = 1,
19    /// Axial shading (Type 2) - linear gradient
20    Axial = 2,
21    /// Radial shading (Type 3) - radial gradient
22    Radial = 3,
23    /// Free-form Gouraud-shaded triangle mesh (Type 4)
24    FreeFormGouraud = 4,
25    /// Lattice-form Gouraud-shaded triangle mesh (Type 5)
26    LatticeFormGouraud = 5,
27    /// Coons patch mesh (Type 6)
28    CoonsPatch = 6,
29    /// Tensor-product patch mesh (Type 7)
30    TensorProductPatch = 7,
31}
32
33/// Color stop for gradient definitions
34#[derive(Debug, Clone, PartialEq)]
35pub struct ColorStop {
36    /// Position along gradient (0.0 to 1.0)
37    pub position: f64,
38    /// Color at this position
39    pub color: Color,
40}
41
42impl ColorStop {
43    /// Create a new color stop
44    pub fn new(position: f64, color: Color) -> Self {
45        Self {
46            position: position.clamp(0.0, 1.0),
47            color,
48        }
49    }
50}
51
52/// Resolve the PDF colour space name for a set of stops.
53///
54/// A shading dictionary carries a single `/ColorSpace` (ISO 32000-1
55/// §8.7.4.3, Table 78), so all stops must share one space. If every stop
56/// is already in the same device space that space is kept; any mix is
57/// promoted to `DeviceRGB` (the lossless common denominator here, since
58/// `Color::to_rgb` converts Gray/CMYK exactly for our device spaces).
59fn resolve_color_space(stops: &[ColorStop]) -> &'static str {
60    match stops.first() {
61        Some(first) => {
62            let name = first.color.color_space_name();
63            if stops.iter().all(|s| s.color.color_space_name() == name) {
64                name
65            } else {
66                "DeviceRGB"
67            }
68        }
69        None => "DeviceRGB",
70    }
71}
72
73/// Component values of `color` expressed in the given device space.
74fn color_components(color: &Color, space: &str) -> Vec<f64> {
75    match space {
76        "DeviceGray" => vec![match color {
77            Color::Gray(g) => *g,
78            // `resolve_color_space` only yields "DeviceGray" when every stop
79            // is `Color::Gray`, so a non-Gray colour here is a logic bug, not
80            // a case to silently approximate.
81            other => {
82                unreachable!("color_components(DeviceGray) called with non-Gray color: {other:?}")
83            }
84        }],
85        "DeviceCMYK" => {
86            let (c, m, y, k) = color.cmyk_components();
87            vec![c, m, y, k]
88        }
89        // DeviceRGB (and any unexpected name) → exact RGB conversion.
90        _ => match color.to_rgb() {
91            Color::Rgb(r, g, b) => vec![r, g, b],
92            _ => unreachable!("to_rgb always yields Color::Rgb"),
93        },
94    }
95}
96
97/// Build a Type 2 (exponential interpolation) function dictionary mapping
98/// the parametric domain `[0 1]` linearly from `c0` to `c1`
99/// (ISO 32000-1 §7.10.3). Mirrors the Type 2 shape built by
100/// `separation_color::TintTransform::to_pdf_dict`, but over `Color` rather
101/// than raw component vectors.
102fn type2_function(c0: &Color, c1: &Color, space: &str) -> Dictionary {
103    let mut dict = Dictionary::new();
104    dict.set("FunctionType", Object::Integer(2));
105    dict.set(
106        "Domain",
107        Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
108    );
109    dict.set(
110        "C0",
111        Object::Array(
112            color_components(c0, space)
113                .into_iter()
114                .map(Object::Real)
115                .collect(),
116        ),
117    );
118    dict.set(
119        "C1",
120        Object::Array(
121            color_components(c1, space)
122                .into_iter()
123                .map(Object::Real)
124                .collect(),
125        ),
126    );
127    dict.set("N", Object::Real(1.0));
128    dict
129}
130
131/// Build the colour-interpolation `/Function` for a gradient from its
132/// stops (ISO 32000-1 §7.10, Functions):
133/// - 1 stop  → a constant Type 2 (`C0 == C1`),
134/// - 2 stops → a single Type 2 (§7.10.3),
135/// - N stops → a Type 3 stitching function (§7.10.4) wrapping `N-1` Type 2
136///   subfunctions, with `/Bounds` at the interior stop positions and
137///   `/Encode` mapping each segment back onto `[0 1]`.
138fn build_color_function(stops: &[ColorStop], space: &str) -> Result<Dictionary> {
139    match stops {
140        [] => Err(PdfError::InvalidStructure(
141            "Shading must have at least one color stop".to_string(),
142        )),
143        [only] => Ok(type2_function(&only.color, &only.color, space)),
144        [a, b] => Ok(type2_function(&a.color, &b.color, space)),
145        _ => {
146            let subfunctions: Vec<Object> = stops
147                .windows(2)
148                .map(|w| Object::Dictionary(type2_function(&w[0].color, &w[1].color, space)))
149                .collect();
150
151            // Interior stop positions become the stitching bounds.
152            let bounds: Vec<Object> = stops[1..stops.len() - 1]
153                .iter()
154                .map(|s| Object::Real(s.position))
155                .collect();
156
157            // Each subfunction consumes the full [0 1] sub-domain.
158            let encode: Vec<Object> = (0..subfunctions.len())
159                .flat_map(|_| [Object::Real(0.0), Object::Real(1.0)])
160                .collect();
161
162            let mut dict = Dictionary::new();
163            dict.set("FunctionType", Object::Integer(3));
164            dict.set(
165                "Domain",
166                Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
167            );
168            dict.set("Functions", Object::Array(subfunctions));
169            dict.set("Bounds", Object::Array(bounds));
170            dict.set("Encode", Object::Array(encode));
171            Ok(dict)
172        }
173    }
174}
175
176/// Assemble a complete axial/radial shading dictionary with a real,
177/// renderable `/Function` and the required `/ColorSpace`. The function is
178/// inlined here; the writer hoists it to an indirect object at emit time
179/// (issue #297 B) so the dictionary is also valid standalone.
180fn assemble_gradient_dict(
181    shading_type: ShadingType,
182    coords: Vec<Object>,
183    stops: &[ColorStop],
184    extend_start: bool,
185    extend_end: bool,
186) -> Result<Dictionary> {
187    let space = resolve_color_space(stops);
188    let function = build_color_function(stops, space)?;
189
190    let mut dict = Dictionary::new();
191    dict.set("ShadingType", Object::Integer(shading_type as i64));
192    dict.set("ColorSpace", Object::Name(space.to_string()));
193    dict.set("Coords", Object::Array(coords));
194    dict.set(
195        "Domain",
196        Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
197    );
198    dict.set("Function", Object::Dictionary(function));
199    dict.set(
200        "Extend",
201        Object::Array(vec![
202            Object::Boolean(extend_start),
203            Object::Boolean(extend_end),
204        ]),
205    );
206    Ok(dict)
207}
208
209/// Coordinate point for shading definitions
210#[derive(Debug, Clone, Copy, PartialEq)]
211pub struct Point {
212    pub x: f64,
213    pub y: f64,
214}
215
216impl Point {
217    /// Create a new point
218    pub fn new(x: f64, y: f64) -> Self {
219        Self { x, y }
220    }
221}
222
223/// Axial (linear) shading definition
224#[derive(Debug, Clone)]
225pub struct AxialShading {
226    /// Shading name for referencing
227    pub name: String,
228    /// Start point of the gradient
229    pub start_point: Point,
230    /// End point of the gradient
231    pub end_point: Point,
232    /// Color stops along the gradient
233    pub color_stops: Vec<ColorStop>,
234    /// Whether to extend beyond the start point
235    pub extend_start: bool,
236    /// Whether to extend beyond the end point
237    pub extend_end: bool,
238}
239
240impl AxialShading {
241    /// Create a new axial shading
242    pub fn new(
243        name: String,
244        start_point: Point,
245        end_point: Point,
246        color_stops: Vec<ColorStop>,
247    ) -> Self {
248        Self {
249            name,
250            start_point,
251            end_point,
252            color_stops,
253            extend_start: false,
254            extend_end: false,
255        }
256    }
257
258    /// Set extension options
259    pub fn with_extend(mut self, extend_start: bool, extend_end: bool) -> Self {
260        self.extend_start = extend_start;
261        self.extend_end = extend_end;
262        self
263    }
264
265    /// Create a simple two-color linear gradient
266    pub fn linear_gradient(
267        name: String,
268        start_point: Point,
269        end_point: Point,
270        start_color: Color,
271        end_color: Color,
272    ) -> Self {
273        let color_stops = vec![
274            ColorStop::new(0.0, start_color),
275            ColorStop::new(1.0, end_color),
276        ];
277
278        Self::new(name, start_point, end_point, color_stops)
279    }
280
281    /// Generate PDF shading dictionary (ISO 32000-1 §8.7.4.3, Table 78).
282    ///
283    /// Emits a real `/Function` interpolating the `color_stops` and the
284    /// required `/ColorSpace`. The function is inlined; the writer hoists
285    /// it to an indirect object when emitting the page (issue #297).
286    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
287        let coords = vec![
288            Object::Real(self.start_point.x),
289            Object::Real(self.start_point.y),
290            Object::Real(self.end_point.x),
291            Object::Real(self.end_point.y),
292        ];
293        assemble_gradient_dict(
294            ShadingType::Axial,
295            coords,
296            &self.color_stops,
297            self.extend_start,
298            self.extend_end,
299        )
300    }
301
302    /// Validate axial shading parameters
303    pub fn validate(&self) -> Result<()> {
304        if self.color_stops.is_empty() {
305            return Err(PdfError::InvalidStructure(
306                "Axial shading must have at least one color stop".to_string(),
307            ));
308        }
309
310        // Check that color stops are in order
311        for window in self.color_stops.windows(2) {
312            if window[0].position > window[1].position {
313                return Err(PdfError::InvalidStructure(
314                    "Color stops must be in ascending order".to_string(),
315                ));
316            }
317        }
318
319        // Check start and end points are different
320        if (self.start_point.x - self.end_point.x).abs() < f64::EPSILON
321            && (self.start_point.y - self.end_point.y).abs() < f64::EPSILON
322        {
323            return Err(PdfError::InvalidStructure(
324                "Start and end points cannot be the same".to_string(),
325            ));
326        }
327
328        Ok(())
329    }
330}
331
332/// Radial shading definition
333#[derive(Debug, Clone)]
334pub struct RadialShading {
335    /// Shading name for referencing
336    pub name: String,
337    /// Center point of the start circle
338    pub start_center: Point,
339    /// Radius of the start circle
340    pub start_radius: f64,
341    /// Center point of the end circle
342    pub end_center: Point,
343    /// Radius of the end circle
344    pub end_radius: f64,
345    /// Color stops along the gradient
346    pub color_stops: Vec<ColorStop>,
347    /// Whether to extend beyond the start circle
348    pub extend_start: bool,
349    /// Whether to extend beyond the end circle
350    pub extend_end: bool,
351}
352
353impl RadialShading {
354    /// Create a new radial shading
355    pub fn new(
356        name: String,
357        start_center: Point,
358        start_radius: f64,
359        end_center: Point,
360        end_radius: f64,
361        color_stops: Vec<ColorStop>,
362    ) -> Self {
363        Self {
364            name,
365            start_center,
366            start_radius: start_radius.max(0.0),
367            end_center,
368            end_radius: end_radius.max(0.0),
369            color_stops,
370            extend_start: false,
371            extend_end: false,
372        }
373    }
374
375    /// Set extension options
376    pub fn with_extend(mut self, extend_start: bool, extend_end: bool) -> Self {
377        self.extend_start = extend_start;
378        self.extend_end = extend_end;
379        self
380    }
381
382    /// Create a simple two-color radial gradient
383    pub fn radial_gradient(
384        name: String,
385        center: Point,
386        start_radius: f64,
387        end_radius: f64,
388        start_color: Color,
389        end_color: Color,
390    ) -> Self {
391        let color_stops = vec![
392            ColorStop::new(0.0, start_color),
393            ColorStop::new(1.0, end_color),
394        ];
395
396        Self::new(name, center, start_radius, center, end_radius, color_stops)
397    }
398
399    /// Generate PDF shading dictionary (ISO 32000-1 §8.7.4.4, Table 79).
400    ///
401    /// Emits a real `/Function` interpolating the `color_stops` and the
402    /// required `/ColorSpace`. The function is inlined; the writer hoists
403    /// it to an indirect object when emitting the page (issue #297).
404    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
405        let coords = vec![
406            Object::Real(self.start_center.x),
407            Object::Real(self.start_center.y),
408            Object::Real(self.start_radius),
409            Object::Real(self.end_center.x),
410            Object::Real(self.end_center.y),
411            Object::Real(self.end_radius),
412        ];
413        assemble_gradient_dict(
414            ShadingType::Radial,
415            coords,
416            &self.color_stops,
417            self.extend_start,
418            self.extend_end,
419        )
420    }
421
422    /// Validate radial shading parameters
423    pub fn validate(&self) -> Result<()> {
424        if self.color_stops.is_empty() {
425            return Err(PdfError::InvalidStructure(
426                "Radial shading must have at least one color stop".to_string(),
427            ));
428        }
429
430        // Check that color stops are in order
431        for window in self.color_stops.windows(2) {
432            if window[0].position > window[1].position {
433                return Err(PdfError::InvalidStructure(
434                    "Color stops must be in ascending order".to_string(),
435                ));
436            }
437        }
438
439        // Check for valid radii
440        if self.start_radius < 0.0 || self.end_radius < 0.0 {
441            return Err(PdfError::InvalidStructure(
442                "Radii cannot be negative".to_string(),
443            ));
444        }
445
446        Ok(())
447    }
448}
449
450/// Function-based shading definition (simplified)
451#[derive(Debug, Clone)]
452pub struct FunctionBasedShading {
453    /// Shading name for referencing
454    pub name: String,
455    /// Domain of the function [xmin, xmax, ymin, ymax]
456    pub domain: [f64; 4],
457    /// Transformation matrix
458    pub matrix: Option<[f64; 6]>,
459    /// Function reference (placeholder)
460    pub function_id: u32,
461}
462
463impl FunctionBasedShading {
464    /// Create a new function-based shading
465    pub fn new(name: String, domain: [f64; 4], function_id: u32) -> Self {
466        Self {
467            name,
468            domain,
469            matrix: None,
470            function_id,
471        }
472    }
473
474    /// Set transformation matrix
475    pub fn with_matrix(mut self, matrix: [f64; 6]) -> Self {
476        self.matrix = Some(matrix);
477        self
478    }
479
480    /// Generate PDF shading dictionary
481    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
482        let mut shading_dict = Dictionary::new();
483
484        // Basic shading properties
485        shading_dict.set(
486            "ShadingType",
487            Object::Integer(ShadingType::FunctionBased as i64),
488        );
489
490        // Domain array
491        let domain = vec![
492            Object::Real(self.domain[0]),
493            Object::Real(self.domain[1]),
494            Object::Real(self.domain[2]),
495            Object::Real(self.domain[3]),
496        ];
497        shading_dict.set("Domain", Object::Array(domain));
498
499        // Matrix (if specified)
500        if let Some(matrix) = self.matrix {
501            let matrix_objects: Vec<Object> = matrix.iter().map(|&x| Object::Real(x)).collect();
502            shading_dict.set("Matrix", Object::Array(matrix_objects));
503        }
504
505        // Function reference
506        shading_dict.set("Function", Object::Integer(self.function_id as i64));
507
508        Ok(shading_dict)
509    }
510
511    /// Validate function-based shading parameters
512    pub fn validate(&self) -> Result<()> {
513        // Check domain validity
514        if self.domain[0] >= self.domain[1] || self.domain[2] >= self.domain[3] {
515            return Err(PdfError::InvalidStructure(
516                "Invalid domain: min values must be less than max values".to_string(),
517            ));
518        }
519
520        Ok(())
521    }
522}
523
524/// Shading pattern that combines a shading with pattern properties
525#[derive(Debug, Clone)]
526pub struct ShadingPattern {
527    /// Pattern name for referencing
528    pub name: String,
529    /// The underlying shading
530    pub shading: ShadingDefinition,
531    /// Pattern transformation matrix
532    pub matrix: Option<[f64; 6]>,
533}
534
535/// Enumeration of different shading types
536#[derive(Debug, Clone)]
537pub enum ShadingDefinition {
538    /// Axial (linear) shading
539    Axial(AxialShading),
540    /// Radial shading
541    Radial(RadialShading),
542    /// Function-based shading
543    FunctionBased(FunctionBasedShading),
544}
545
546impl ShadingDefinition {
547    /// Get the name of the shading
548    pub fn name(&self) -> &str {
549        match self {
550            ShadingDefinition::Axial(shading) => &shading.name,
551            ShadingDefinition::Radial(shading) => &shading.name,
552            ShadingDefinition::FunctionBased(shading) => &shading.name,
553        }
554    }
555
556    /// Validate the shading
557    pub fn validate(&self) -> Result<()> {
558        match self {
559            ShadingDefinition::Axial(shading) => shading.validate(),
560            ShadingDefinition::Radial(shading) => shading.validate(),
561            ShadingDefinition::FunctionBased(shading) => shading.validate(),
562        }
563    }
564
565    /// Generate PDF shading dictionary
566    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
567        match self {
568            ShadingDefinition::Axial(shading) => shading.to_pdf_dictionary(),
569            ShadingDefinition::Radial(shading) => shading.to_pdf_dictionary(),
570            ShadingDefinition::FunctionBased(shading) => shading.to_pdf_dictionary(),
571        }
572    }
573}
574
575impl ShadingPattern {
576    /// Create a new shading pattern
577    pub fn new(name: String, shading: ShadingDefinition) -> Self {
578        Self {
579            name,
580            shading,
581            matrix: None,
582        }
583    }
584
585    /// Set pattern transformation matrix
586    pub fn with_matrix(mut self, matrix: [f64; 6]) -> Self {
587        self.matrix = Some(matrix);
588        self
589    }
590
591    /// Generate PDF pattern dictionary for shading pattern.
592    ///
593    /// NOTE: `ShadingPattern` is not yet wired through `Page` → writer (there
594    /// is no `Page::add_shading_pattern` and the writer iterates only
595    /// `page.shadings()`), so this method is not exercised by the
596    /// serialisation pipeline today. The `sh` direct-paint path
597    /// ([`GraphicsContext::paint_shading`] over [`Page::add_shading`]) is the
598    /// wired, end-to-end gradient path. Because the inlined `/Shading` here
599    /// carries its `/Function` inline (the writer's indirect-hoist only
600    /// applies to `page.shadings()`), full PatternType-2 fill support remains
601    /// a follow-up.
602    pub fn to_pdf_pattern_dictionary(&self) -> Result<Dictionary> {
603        let mut pattern_dict = Dictionary::new();
604
605        // Pattern properties
606        pattern_dict.set("Type", Object::Name("Pattern".to_string()));
607        pattern_dict.set("PatternType", Object::Integer(2)); // Shading pattern
608
609        // Inline the real shading dictionary (issue #297 C). A PatternType 2
610        // /Shading may be a dictionary or an indirect reference (ISO 32000-1
611        // §8.7.3.3, Table 76); inlining keeps the pattern self-contained and
612        // renderable instead of the old `Object::Integer(1)` placeholder.
613        pattern_dict.set(
614            "Shading",
615            Object::Dictionary(self.shading.to_pdf_dictionary()?),
616        );
617
618        // Matrix (if specified)
619        if let Some(matrix) = self.matrix {
620            let matrix_objects: Vec<Object> = matrix.iter().map(|&x| Object::Real(x)).collect();
621            pattern_dict.set("Matrix", Object::Array(matrix_objects));
622        }
623
624        Ok(pattern_dict)
625    }
626
627    /// Validate shading pattern
628    pub fn validate(&self) -> Result<()> {
629        self.shading.validate()
630    }
631}
632
633/// Shading manager for handling multiple shadings
634#[derive(Debug, Clone)]
635pub struct ShadingManager {
636    /// Stored shadings
637    shadings: HashMap<String, ShadingDefinition>,
638    /// Stored shading patterns
639    patterns: HashMap<String, ShadingPattern>,
640    /// Next shading ID
641    next_id: usize,
642}
643
644impl Default for ShadingManager {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650impl ShadingManager {
651    /// Create a new shading manager
652    pub fn new() -> Self {
653        Self {
654            shadings: HashMap::new(),
655            patterns: HashMap::new(),
656            next_id: 1,
657        }
658    }
659
660    /// Add a shading
661    pub fn add_shading(&mut self, mut shading: ShadingDefinition) -> Result<String> {
662        // Validate shading before adding
663        shading.validate()?;
664
665        let name = shading.name().to_string();
666
667        // Generate unique name if empty or already exists
668        let final_name = if name.is_empty() || self.shadings.contains_key(&name) {
669            let auto_name = format!("Sh{}", self.next_id);
670            self.next_id += 1;
671
672            // Update the shading name
673            match &mut shading {
674                ShadingDefinition::Axial(s) => s.name = auto_name.clone(),
675                ShadingDefinition::Radial(s) => s.name = auto_name.clone(),
676                ShadingDefinition::FunctionBased(s) => s.name = auto_name.clone(),
677            }
678
679            auto_name
680        } else {
681            name
682        };
683
684        self.shadings.insert(final_name.clone(), shading);
685        Ok(final_name)
686    }
687
688    /// Add a shading pattern
689    pub fn add_shading_pattern(&mut self, mut pattern: ShadingPattern) -> Result<String> {
690        // Validate pattern before adding
691        pattern.validate()?;
692
693        // Generate unique name if empty or already exists
694        if pattern.name.is_empty() || self.patterns.contains_key(&pattern.name) {
695            pattern.name = format!("SP{}", self.next_id);
696            self.next_id += 1;
697        }
698
699        let name = pattern.name.clone();
700        self.patterns.insert(name.clone(), pattern);
701        Ok(name)
702    }
703
704    /// Get a shading by name
705    pub fn get_shading(&self, name: &str) -> Option<&ShadingDefinition> {
706        self.shadings.get(name)
707    }
708
709    /// Get a shading pattern by name
710    pub fn get_pattern(&self, name: &str) -> Option<&ShadingPattern> {
711        self.patterns.get(name)
712    }
713
714    /// Get all shadings
715    pub fn shadings(&self) -> &HashMap<String, ShadingDefinition> {
716        &self.shadings
717    }
718
719    /// Get all patterns
720    pub fn patterns(&self) -> &HashMap<String, ShadingPattern> {
721        &self.patterns
722    }
723
724    /// Clear all shadings and patterns
725    pub fn clear(&mut self) {
726        self.shadings.clear();
727        self.patterns.clear();
728        self.next_id = 1;
729    }
730
731    /// Count of registered shadings
732    pub fn shading_count(&self) -> usize {
733        self.shadings.len()
734    }
735
736    /// Count of registered patterns
737    pub fn pattern_count(&self) -> usize {
738        self.patterns.len()
739    }
740
741    /// Total count of all items
742    pub fn total_count(&self) -> usize {
743        self.shading_count() + self.pattern_count()
744    }
745
746    /// Create a simple linear gradient
747    pub fn create_linear_gradient(
748        &mut self,
749        start_point: Point,
750        end_point: Point,
751        start_color: Color,
752        end_color: Color,
753    ) -> Result<String> {
754        let shading = ShadingDefinition::Axial(AxialShading::linear_gradient(
755            String::new(), // Auto-generated name
756            start_point,
757            end_point,
758            start_color,
759            end_color,
760        ));
761
762        self.add_shading(shading)
763    }
764
765    /// Create a simple radial gradient
766    pub fn create_radial_gradient(
767        &mut self,
768        center: Point,
769        start_radius: f64,
770        end_radius: f64,
771        start_color: Color,
772        end_color: Color,
773    ) -> Result<String> {
774        let shading = ShadingDefinition::Radial(RadialShading::radial_gradient(
775            String::new(), // Auto-generated name
776            center,
777            start_radius,
778            end_radius,
779            start_color,
780            end_color,
781        ));
782
783        self.add_shading(shading)
784    }
785
786    /// Generate shading resource dictionary for PDF
787    pub fn to_resource_dictionary(&self) -> Result<String> {
788        if self.shadings.is_empty() && self.patterns.is_empty() {
789            return Ok(String::new());
790        }
791
792        let mut dict = String::new();
793
794        // Shadings
795        if !self.shadings.is_empty() {
796            dict.push_str("/Shading <<");
797            for name in self.shadings.keys() {
798                dict.push_str(&format!(" /{} {} 0 R", name, self.next_id));
799            }
800            dict.push_str(" >>");
801        }
802
803        // Patterns
804        if !self.patterns.is_empty() {
805            if !dict.is_empty() {
806                dict.push('\n');
807            }
808            dict.push_str("/Pattern <<");
809            for name in self.patterns.keys() {
810                dict.push_str(&format!(" /{} {} 0 R", name, self.next_id));
811            }
812            dict.push_str(" >>");
813        }
814
815        Ok(dict)
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    #[test]
824    fn test_color_stop_creation() {
825        let stop = ColorStop::new(0.5, Color::red());
826        assert_eq!(stop.position, 0.5);
827        assert_eq!(stop.color, Color::red());
828
829        // Test clamping
830        let stop_clamped = ColorStop::new(1.5, Color::blue());
831        assert_eq!(stop_clamped.position, 1.0);
832    }
833
834    #[test]
835    fn test_point_creation() {
836        let point = Point::new(10.0, 20.0);
837        assert_eq!(point.x, 10.0);
838        assert_eq!(point.y, 20.0);
839    }
840
841    #[test]
842    fn test_axial_shading_creation() {
843        let start = Point::new(0.0, 0.0);
844        let end = Point::new(100.0, 100.0);
845        let stops = vec![
846            ColorStop::new(0.0, Color::red()),
847            ColorStop::new(1.0, Color::blue()),
848        ];
849
850        let shading = AxialShading::new("TestGradient".to_string(), start, end, stops);
851        assert_eq!(shading.name, "TestGradient");
852        assert_eq!(shading.start_point, start);
853        assert_eq!(shading.end_point, end);
854        assert_eq!(shading.color_stops.len(), 2);
855        assert!(!shading.extend_start);
856        assert!(!shading.extend_end);
857    }
858
859    #[test]
860    fn test_axial_shading_linear_gradient() {
861        let start = Point::new(0.0, 0.0);
862        let end = Point::new(100.0, 0.0);
863        let shading = AxialShading::linear_gradient(
864            "LinearGrad".to_string(),
865            start,
866            end,
867            Color::red(),
868            Color::blue(),
869        );
870
871        assert_eq!(shading.color_stops.len(), 2);
872        assert_eq!(shading.color_stops[0].position, 0.0);
873        assert_eq!(shading.color_stops[1].position, 1.0);
874    }
875
876    #[test]
877    fn test_axial_shading_with_extend() {
878        let start = Point::new(0.0, 0.0);
879        let end = Point::new(100.0, 0.0);
880        let shading = AxialShading::linear_gradient(
881            "ExtendedGrad".to_string(),
882            start,
883            end,
884            Color::red(),
885            Color::blue(),
886        )
887        .with_extend(true, true);
888
889        assert!(shading.extend_start);
890        assert!(shading.extend_end);
891    }
892
893    #[test]
894    fn test_axial_shading_validation_valid() {
895        let start = Point::new(0.0, 0.0);
896        let end = Point::new(100.0, 0.0);
897        let shading = AxialShading::linear_gradient(
898            "ValidGrad".to_string(),
899            start,
900            end,
901            Color::red(),
902            Color::blue(),
903        );
904
905        assert!(shading.validate().is_ok());
906    }
907
908    #[test]
909    fn test_axial_shading_validation_no_stops() {
910        let start = Point::new(0.0, 0.0);
911        let end = Point::new(100.0, 0.0);
912        let shading = AxialShading::new("EmptyGrad".to_string(), start, end, Vec::new());
913
914        assert!(shading.validate().is_err());
915    }
916
917    #[test]
918    fn test_axial_shading_validation_same_points() {
919        let point = Point::new(50.0, 50.0);
920        let shading = AxialShading::linear_gradient(
921            "SamePointGrad".to_string(),
922            point,
923            point,
924            Color::red(),
925            Color::blue(),
926        );
927
928        assert!(shading.validate().is_err());
929    }
930
931    #[test]
932    fn test_radial_shading_creation() {
933        let center = Point::new(50.0, 50.0);
934        let stops = vec![
935            ColorStop::new(0.0, Color::red()),
936            ColorStop::new(1.0, Color::blue()),
937        ];
938
939        let shading =
940            RadialShading::new("RadialGrad".to_string(), center, 10.0, center, 50.0, stops);
941
942        assert_eq!(shading.name, "RadialGrad");
943        assert_eq!(shading.start_center, center);
944        assert_eq!(shading.start_radius, 10.0);
945        assert_eq!(shading.end_radius, 50.0);
946    }
947
948    #[test]
949    fn test_radial_shading_gradient() {
950        let center = Point::new(50.0, 50.0);
951        let shading = RadialShading::radial_gradient(
952            "SimpleRadial".to_string(),
953            center,
954            0.0,
955            25.0,
956            Color::white(),
957            Color::black(),
958        );
959
960        assert_eq!(shading.color_stops.len(), 2);
961        assert_eq!(shading.start_radius, 0.0);
962        assert_eq!(shading.end_radius, 25.0);
963    }
964
965    #[test]
966    fn test_radial_shading_radius_clamping() {
967        let center = Point::new(50.0, 50.0);
968        let stops = vec![ColorStop::new(0.0, Color::red())];
969
970        let shading = RadialShading::new(
971            "ClampedRadial".to_string(),
972            center,
973            -5.0, // Negative radius should be clamped to 0
974            center,
975            10.0,
976            stops,
977        );
978
979        assert_eq!(shading.start_radius, 0.0);
980    }
981
982    #[test]
983    fn test_radial_shading_validation_valid() {
984        let center = Point::new(50.0, 50.0);
985        let shading = RadialShading::radial_gradient(
986            "ValidRadial".to_string(),
987            center,
988            0.0,
989            25.0,
990            Color::red(),
991            Color::blue(),
992        );
993
994        assert!(shading.validate().is_ok());
995    }
996
997    #[test]
998    fn test_function_based_shading_creation() {
999        let domain = [0.0, 1.0, 0.0, 1.0];
1000        let shading = FunctionBasedShading::new("FuncShading".to_string(), domain, 1);
1001
1002        assert_eq!(shading.name, "FuncShading");
1003        assert_eq!(shading.domain, domain);
1004        assert_eq!(shading.function_id, 1);
1005        assert!(shading.matrix.is_none());
1006    }
1007
1008    #[test]
1009    fn test_function_based_shading_with_matrix() {
1010        let domain = [0.0, 1.0, 0.0, 1.0];
1011        let matrix = [2.0, 0.0, 0.0, 2.0, 10.0, 20.0];
1012        let shading =
1013            FunctionBasedShading::new("FuncShading".to_string(), domain, 1).with_matrix(matrix);
1014
1015        assert_eq!(shading.matrix, Some(matrix));
1016    }
1017
1018    #[test]
1019    fn test_function_based_shading_validation_valid() {
1020        let domain = [0.0, 1.0, 0.0, 1.0];
1021        let shading = FunctionBasedShading::new("ValidFunc".to_string(), domain, 1);
1022
1023        assert!(shading.validate().is_ok());
1024    }
1025
1026    #[test]
1027    fn test_function_based_shading_validation_invalid_domain() {
1028        let domain = [1.0, 0.0, 0.0, 1.0]; // min > max
1029        let shading = FunctionBasedShading::new("InvalidFunc".to_string(), domain, 1);
1030
1031        assert!(shading.validate().is_err());
1032    }
1033
1034    #[test]
1035    fn test_shading_pattern_creation() {
1036        let start = Point::new(0.0, 0.0);
1037        let end = Point::new(100.0, 0.0);
1038        let axial = AxialShading::linear_gradient(
1039            "PatternGrad".to_string(),
1040            start,
1041            end,
1042            Color::red(),
1043            Color::blue(),
1044        );
1045        let shading = ShadingDefinition::Axial(axial);
1046        let pattern = ShadingPattern::new("Pattern1".to_string(), shading);
1047
1048        assert_eq!(pattern.name, "Pattern1");
1049        assert!(pattern.matrix.is_none());
1050    }
1051
1052    #[test]
1053    fn test_shading_pattern_with_matrix() {
1054        let start = Point::new(0.0, 0.0);
1055        let end = Point::new(100.0, 0.0);
1056        let axial = AxialShading::linear_gradient(
1057            "PatternGrad".to_string(),
1058            start,
1059            end,
1060            Color::red(),
1061            Color::blue(),
1062        );
1063        let shading = ShadingDefinition::Axial(axial);
1064        let matrix = [1.0, 0.0, 0.0, 1.0, 50.0, 50.0];
1065        let pattern = ShadingPattern::new("Pattern1".to_string(), shading).with_matrix(matrix);
1066
1067        assert_eq!(pattern.matrix, Some(matrix));
1068    }
1069
1070    #[test]
1071    fn test_shading_manager_creation() {
1072        let manager = ShadingManager::new();
1073        assert_eq!(manager.shading_count(), 0);
1074        assert_eq!(manager.pattern_count(), 0);
1075        assert_eq!(manager.total_count(), 0);
1076    }
1077
1078    #[test]
1079    fn test_shading_manager_add_shading() {
1080        let mut manager = ShadingManager::new();
1081        let start = Point::new(0.0, 0.0);
1082        let end = Point::new(100.0, 0.0);
1083        let axial = AxialShading::linear_gradient(
1084            "TestGrad".to_string(),
1085            start,
1086            end,
1087            Color::red(),
1088            Color::blue(),
1089        );
1090        let shading = ShadingDefinition::Axial(axial);
1091
1092        let name = manager.add_shading(shading).unwrap();
1093        assert_eq!(name, "TestGrad");
1094        assert_eq!(manager.shading_count(), 1);
1095
1096        let retrieved = manager.get_shading(&name).unwrap();
1097        assert_eq!(retrieved.name(), "TestGrad");
1098    }
1099
1100    #[test]
1101    fn test_shading_manager_auto_naming() {
1102        let mut manager = ShadingManager::new();
1103        let start = Point::new(0.0, 0.0);
1104        let end = Point::new(100.0, 0.0);
1105        let axial = AxialShading::linear_gradient(
1106            String::new(), // Empty name
1107            start,
1108            end,
1109            Color::red(),
1110            Color::blue(),
1111        );
1112        let shading = ShadingDefinition::Axial(axial);
1113
1114        let name = manager.add_shading(shading).unwrap();
1115        assert_eq!(name, "Sh1");
1116
1117        // Add another with empty name
1118        let axial2 = AxialShading::linear_gradient(
1119            String::new(),
1120            start,
1121            end,
1122            Color::green(),
1123            Color::yellow(),
1124        );
1125        let shading2 = ShadingDefinition::Axial(axial2);
1126
1127        let name2 = manager.add_shading(shading2).unwrap();
1128        assert_eq!(name2, "Sh2");
1129    }
1130
1131    #[test]
1132    fn test_shading_manager_create_gradients() {
1133        let mut manager = ShadingManager::new();
1134
1135        let linear_name = manager
1136            .create_linear_gradient(
1137                Point::new(0.0, 0.0),
1138                Point::new(100.0, 0.0),
1139                Color::red(),
1140                Color::blue(),
1141            )
1142            .unwrap();
1143
1144        let radial_name = manager
1145            .create_radial_gradient(
1146                Point::new(50.0, 50.0),
1147                0.0,
1148                25.0,
1149                Color::white(),
1150                Color::black(),
1151            )
1152            .unwrap();
1153
1154        assert_eq!(manager.shading_count(), 2);
1155        assert!(manager.get_shading(&linear_name).is_some());
1156        assert!(manager.get_shading(&radial_name).is_some());
1157    }
1158
1159    #[test]
1160    fn test_shading_manager_clear() {
1161        let mut manager = ShadingManager::new();
1162
1163        manager
1164            .create_linear_gradient(
1165                Point::new(0.0, 0.0),
1166                Point::new(100.0, 0.0),
1167                Color::red(),
1168                Color::blue(),
1169            )
1170            .unwrap();
1171
1172        assert_eq!(manager.shading_count(), 1);
1173
1174        manager.clear();
1175        assert_eq!(manager.shading_count(), 0);
1176        assert_eq!(manager.total_count(), 0);
1177    }
1178
1179    #[test]
1180    fn test_axial_shading_pdf_dictionary() {
1181        let start = Point::new(0.0, 0.0);
1182        let end = Point::new(100.0, 50.0);
1183        let shading = AxialShading::linear_gradient(
1184            "TestPDF".to_string(),
1185            start,
1186            end,
1187            Color::red(),
1188            Color::blue(),
1189        )
1190        .with_extend(true, false);
1191
1192        let dict = shading.to_pdf_dictionary().unwrap();
1193
1194        if let Some(Object::Integer(shading_type)) = dict.get("ShadingType") {
1195            assert_eq!(*shading_type, 2); // Axial type
1196        }
1197
1198        if let Some(Object::Array(coords)) = dict.get("Coords") {
1199            assert_eq!(coords.len(), 4);
1200        }
1201
1202        if let Some(Object::Array(extend)) = dict.get("Extend") {
1203            assert_eq!(extend.len(), 2);
1204            if let (Object::Boolean(start_extend), Object::Boolean(end_extend)) =
1205                (&extend[0], &extend[1])
1206            {
1207                assert!(*start_extend);
1208                assert!(!(*end_extend));
1209            }
1210        }
1211    }
1212
1213    // ── Issue #297: real /Function, /ColorSpace and the `sh` paint path ──
1214
1215    /// Extract the C0/C1 arrays of a Type 2 function dictionary as f64 vecs.
1216    fn type2_c0_c1(func: &Dictionary) -> (Vec<f64>, Vec<f64>) {
1217        let extract = |key: &str| -> Vec<f64> {
1218            match func.get(key) {
1219                Some(Object::Array(a)) => a
1220                    .iter()
1221                    .map(|o| match o {
1222                        Object::Real(v) => *v,
1223                        Object::Integer(v) => *v as f64,
1224                        _ => panic!("{key} component is not numeric"),
1225                    })
1226                    .collect(),
1227                other => panic!("{key} is not an array: {other:?}"),
1228            }
1229        };
1230        (extract("C0"), extract("C1"))
1231    }
1232
1233    #[test]
1234    fn test_axial_two_stops_emits_real_type2_function() {
1235        // 2 stops red→blue must produce a Type 2 (exponential) function whose
1236        // endpoints carry the actual stop colours, not a placeholder integer.
1237        let shading = AxialShading::linear_gradient(
1238            "G".to_string(),
1239            Point::new(0.0, 0.0),
1240            Point::new(100.0, 0.0),
1241            Color::red(),
1242            Color::blue(),
1243        );
1244        let dict = shading.to_pdf_dictionary().unwrap();
1245
1246        // /ColorSpace is REQUIRED by ISO 32000-1 §8.7.4.3 Table 78 — was missing.
1247        assert_eq!(
1248            dict.get("ColorSpace"),
1249            Some(&Object::Name("DeviceRGB".to_string())),
1250            "axial shading must declare /ColorSpace"
1251        );
1252
1253        // /Function must be a real function dictionary, not Object::Integer(1).
1254        let func = match dict.get("Function") {
1255            Some(Object::Dictionary(d)) => d,
1256            other => panic!("/Function must be a dictionary, got {other:?}"),
1257        };
1258        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(2)));
1259        let (c0, c1) = type2_c0_c1(func);
1260        assert_eq!(c0, vec![1.0, 0.0, 0.0], "C0 must be red");
1261        assert_eq!(c1, vec![0.0, 0.0, 1.0], "C1 must be blue");
1262        assert_eq!(func.get("N"), Some(&Object::Real(1.0)));
1263        assert_eq!(
1264            func.get("Domain"),
1265            Some(&Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]))
1266        );
1267    }
1268
1269    #[test]
1270    fn test_axial_three_stops_emits_type3_stitching() {
1271        // 3 stops must produce a Type 3 stitching function wrapping 2 Type 2
1272        // subfunctions, with /Bounds at the interior stop and /Encode [0 1 0 1].
1273        let shading = AxialShading::new(
1274            "G".to_string(),
1275            Point::new(0.0, 0.0),
1276            Point::new(100.0, 0.0),
1277            vec![
1278                ColorStop::new(0.0, Color::red()),
1279                ColorStop::new(0.5, Color::green()),
1280                ColorStop::new(1.0, Color::blue()),
1281            ],
1282        );
1283        let dict = shading.to_pdf_dictionary().unwrap();
1284        let func = match dict.get("Function") {
1285            Some(Object::Dictionary(d)) => d,
1286            other => panic!("/Function must be a dictionary, got {other:?}"),
1287        };
1288        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(3)));
1289        assert_eq!(
1290            func.get("Bounds"),
1291            Some(&Object::Array(vec![Object::Real(0.5)])),
1292            "interior stop position is the only bound"
1293        );
1294        assert_eq!(
1295            func.get("Encode"),
1296            Some(&Object::Array(vec![
1297                Object::Real(0.0),
1298                Object::Real(1.0),
1299                Object::Real(0.0),
1300                Object::Real(1.0),
1301            ]))
1302        );
1303        let subfuncs = match func.get("Functions") {
1304            Some(Object::Array(a)) => a,
1305            other => panic!("/Functions must be an array, got {other:?}"),
1306        };
1307        assert_eq!(subfuncs.len(), 2, "two segments for three stops");
1308        // First subfunction red→green.
1309        let f0 = match &subfuncs[0] {
1310            Object::Dictionary(d) => d,
1311            other => panic!("subfunction 0 not a dict: {other:?}"),
1312        };
1313        let (c0, c1) = type2_c0_c1(f0);
1314        assert_eq!(c0, vec![1.0, 0.0, 0.0]);
1315        assert_eq!(c1, vec![0.0, 1.0, 0.0]);
1316    }
1317
1318    #[test]
1319    fn test_axial_gray_stops_emit_devicegray_function() {
1320        // Uniform Gray stops must keep DeviceGray (1 component), not promote to RGB.
1321        let shading = AxialShading::linear_gradient(
1322            "G".to_string(),
1323            Point::new(0.0, 0.0),
1324            Point::new(10.0, 0.0),
1325            Color::black(),
1326            Color::white(),
1327        );
1328        let dict = shading.to_pdf_dictionary().unwrap();
1329        assert_eq!(
1330            dict.get("ColorSpace"),
1331            Some(&Object::Name("DeviceGray".to_string()))
1332        );
1333        let func = match dict.get("Function") {
1334            Some(Object::Dictionary(d)) => d,
1335            other => panic!("/Function must be a dictionary, got {other:?}"),
1336        };
1337        let (c0, c1) = type2_c0_c1(func);
1338        assert_eq!(c0, vec![0.0], "black");
1339        assert_eq!(c1, vec![1.0], "white");
1340    }
1341
1342    #[test]
1343    fn test_axial_cmyk_stops_emit_devicecmyk_function() {
1344        // Uniform CMYK stops keep DeviceCMYK with 4-component C0/C1.
1345        let shading = AxialShading::linear_gradient(
1346            "G".to_string(),
1347            Point::new(0.0, 0.0),
1348            Point::new(10.0, 0.0),
1349            Color::Cmyk(1.0, 0.0, 0.0, 0.0),
1350            Color::Cmyk(0.0, 1.0, 0.0, 0.0),
1351        );
1352        let dict = shading.to_pdf_dictionary().unwrap();
1353        assert_eq!(
1354            dict.get("ColorSpace"),
1355            Some(&Object::Name("DeviceCMYK".to_string()))
1356        );
1357        let func = match dict.get("Function") {
1358            Some(Object::Dictionary(d)) => d,
1359            other => panic!("/Function must be a dictionary, got {other:?}"),
1360        };
1361        let (c0, c1) = type2_c0_c1(func);
1362        assert_eq!(c0, vec![1.0, 0.0, 0.0, 0.0], "C0 = cyan, 4 components");
1363        assert_eq!(c1, vec![0.0, 1.0, 0.0, 0.0], "C1 = magenta, 4 components");
1364    }
1365
1366    #[test]
1367    fn test_axial_four_stops_type3_has_three_subfunctions_two_bounds() {
1368        let shading = AxialShading::new(
1369            "G".to_string(),
1370            Point::new(0.0, 0.0),
1371            Point::new(100.0, 0.0),
1372            vec![
1373                ColorStop::new(0.0, Color::red()),
1374                ColorStop::new(0.3, Color::green()),
1375                ColorStop::new(0.7, Color::blue()),
1376                ColorStop::new(1.0, Color::white()),
1377            ],
1378        );
1379        let dict = shading.to_pdf_dictionary().unwrap();
1380        let func = match dict.get("Function") {
1381            Some(Object::Dictionary(d)) => d,
1382            other => panic!("/Function must be a dictionary, got {other:?}"),
1383        };
1384        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(3)));
1385        let subfuncs = match func.get("Functions") {
1386            Some(Object::Array(a)) => a,
1387            other => panic!("/Functions array expected, got {other:?}"),
1388        };
1389        assert_eq!(subfuncs.len(), 3, "4 stops → 3 segments");
1390        assert_eq!(
1391            func.get("Bounds"),
1392            Some(&Object::Array(vec![Object::Real(0.3), Object::Real(0.7)])),
1393            "two interior bounds at the middle stops"
1394        );
1395        assert_eq!(
1396            func.get("Encode"),
1397            Some(&Object::Array(vec![
1398                Object::Real(0.0),
1399                Object::Real(1.0),
1400                Object::Real(0.0),
1401                Object::Real(1.0),
1402                Object::Real(0.0),
1403                Object::Real(1.0),
1404            ]))
1405        );
1406    }
1407
1408    #[test]
1409    fn test_single_stop_emits_constant_type2() {
1410        // A lone stop is valid (validate() only rejects empty) → constant colour.
1411        let shading = AxialShading::new(
1412            "G".to_string(),
1413            Point::new(0.0, 0.0),
1414            Point::new(10.0, 0.0),
1415            vec![ColorStop::new(0.0, Color::Rgb(0.2, 0.4, 0.6))],
1416        );
1417        let func = match shading.to_pdf_dictionary().unwrap().get("Function") {
1418            Some(Object::Dictionary(d)) => d.clone(),
1419            other => panic!("/Function must be a dictionary, got {other:?}"),
1420        };
1421        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(2)));
1422        let (c0, c1) = type2_c0_c1(&func);
1423        assert_eq!(c0, c1, "constant colour: C0 == C1");
1424        assert_eq!(c0, vec![0.2, 0.4, 0.6]);
1425    }
1426
1427    #[test]
1428    fn test_mixed_color_spaces_promote_to_rgb() {
1429        // Mixing Gray and RGB stops must promote the whole shading to DeviceRGB.
1430        let shading = AxialShading::new(
1431            "G".to_string(),
1432            Point::new(0.0, 0.0),
1433            Point::new(10.0, 0.0),
1434            vec![
1435                ColorStop::new(0.0, Color::Gray(0.5)),
1436                ColorStop::new(1.0, Color::Rgb(1.0, 0.0, 0.0)),
1437            ],
1438        );
1439        let dict = shading.to_pdf_dictionary().unwrap();
1440        assert_eq!(
1441            dict.get("ColorSpace"),
1442            Some(&Object::Name("DeviceRGB".to_string()))
1443        );
1444        let func = match dict.get("Function") {
1445            Some(Object::Dictionary(d)) => d,
1446            other => panic!("/Function must be a dictionary, got {other:?}"),
1447        };
1448        let (c0, c1) = type2_c0_c1(func);
1449        assert_eq!(c0, vec![0.5, 0.5, 0.5], "gray 0.5 promoted to RGB");
1450        assert_eq!(c1, vec![1.0, 0.0, 0.0]);
1451    }
1452
1453    #[test]
1454    fn test_radial_emits_real_function_and_colorspace() {
1455        let center = Point::new(50.0, 50.0);
1456        let shading = RadialShading::radial_gradient(
1457            "R".to_string(),
1458            center,
1459            0.0,
1460            25.0,
1461            Color::cyan(),
1462            Color::magenta(),
1463        );
1464        let dict = shading.to_pdf_dictionary().unwrap();
1465        assert_eq!(
1466            dict.get("ColorSpace"),
1467            Some(&Object::Name("DeviceRGB".to_string()))
1468        );
1469        let func = match dict.get("Function") {
1470            Some(Object::Dictionary(d)) => d,
1471            other => panic!("/Function must be a dictionary, got {other:?}"),
1472        };
1473        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(2)));
1474    }
1475
1476    #[test]
1477    fn test_shading_pattern_inlines_real_shading_not_placeholder() {
1478        // Issue #297 C: /Shading must be the real shading dict, never Integer(1).
1479        let axial = AxialShading::linear_gradient(
1480            "P".to_string(),
1481            Point::new(0.0, 0.0),
1482            Point::new(100.0, 0.0),
1483            Color::red(),
1484            Color::blue(),
1485        );
1486        let pattern = ShadingPattern::new("SP1".to_string(), ShadingDefinition::Axial(axial));
1487        let dict = pattern.to_pdf_pattern_dictionary().unwrap();
1488        assert_eq!(dict.get("PatternType"), Some(&Object::Integer(2)));
1489        let shading = match dict.get("Shading") {
1490            Some(Object::Dictionary(d)) => d,
1491            other => panic!("/Shading must be an inline dict, got {other:?}"),
1492        };
1493        assert_eq!(shading.get("ShadingType"), Some(&Object::Integer(2)));
1494        assert!(
1495            matches!(shading.get("Function"), Some(Object::Dictionary(_))),
1496            "inlined shading must carry a real /Function"
1497        );
1498    }
1499
1500    #[test]
1501    fn test_radial_shading_pdf_dictionary() {
1502        let center = Point::new(50.0, 50.0);
1503        let shading = RadialShading::radial_gradient(
1504            "TestRadialPDF".to_string(),
1505            center,
1506            10.0,
1507            30.0,
1508            Color::yellow(),
1509            Color::red(),
1510        );
1511
1512        let dict = shading.to_pdf_dictionary().unwrap();
1513
1514        if let Some(Object::Integer(shading_type)) = dict.get("ShadingType") {
1515            assert_eq!(*shading_type, 3); // Radial type
1516        }
1517
1518        if let Some(Object::Array(coords)) = dict.get("Coords") {
1519            assert_eq!(coords.len(), 6); // [x0 y0 r0 x1 y1 r1]
1520        }
1521    }
1522}