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        // Invariant: `Color::to_rgb` (color.rs) always returns `Color::Rgb`. If
91        // a future `Color` variant changes that, update this arm — the compiler
92        // will not flag it here.
93        _ => match color.to_rgb() {
94            Color::Rgb(r, g, b) => vec![r, g, b],
95            _ => unreachable!("to_rgb always yields Color::Rgb"),
96        },
97    }
98}
99
100/// Build a Type 2 (exponential interpolation) function dictionary mapping
101/// the parametric domain `[0 1]` linearly from `c0` to `c1`
102/// (ISO 32000-1 §7.10.3). Mirrors the Type 2 shape built by
103/// `separation_color::TintTransform::to_pdf_dict`, but over `Color` rather
104/// than raw component vectors.
105fn type2_function(c0: &Color, c1: &Color, space: &str) -> Dictionary {
106    let mut dict = Dictionary::new();
107    dict.set("FunctionType", Object::Integer(2));
108    dict.set(
109        "Domain",
110        Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
111    );
112    dict.set(
113        "C0",
114        Object::Array(
115            color_components(c0, space)
116                .into_iter()
117                .map(Object::Real)
118                .collect(),
119        ),
120    );
121    dict.set(
122        "C1",
123        Object::Array(
124            color_components(c1, space)
125                .into_iter()
126                .map(Object::Real)
127                .collect(),
128        ),
129    );
130    dict.set("N", Object::Real(1.0));
131    dict
132}
133
134/// Build the colour-interpolation `/Function` for a gradient from its
135/// stops (ISO 32000-1 §7.10, Functions):
136/// - 1 stop  → a constant Type 2 (`C0 == C1`),
137/// - 2 stops → a single Type 2 (§7.10.3),
138/// - N stops → a Type 3 stitching function (§7.10.4) wrapping `N-1` Type 2
139///   subfunctions, with `/Bounds` at the interior stop positions and
140///   `/Encode` mapping each segment back onto `[0 1]`.
141fn build_color_function(stops: &[ColorStop], space: &str) -> Result<Dictionary> {
142    match stops {
143        [] => Err(PdfError::InvalidStructure(
144            "Shading must have at least one color stop".to_string(),
145        )),
146        [only] => Ok(type2_function(&only.color, &only.color, space)),
147        [a, b] => Ok(type2_function(&a.color, &b.color, space)),
148        _ => {
149            let subfunctions: Vec<Object> = stops
150                .windows(2)
151                .map(|w| Object::Dictionary(type2_function(&w[0].color, &w[1].color, space)))
152                .collect();
153
154            // Interior stop positions become the stitching bounds.
155            let bounds: Vec<Object> = stops[1..stops.len() - 1]
156                .iter()
157                .map(|s| Object::Real(s.position))
158                .collect();
159
160            // Each subfunction consumes the full [0 1] sub-domain.
161            let encode: Vec<Object> = (0..subfunctions.len())
162                .flat_map(|_| [Object::Real(0.0), Object::Real(1.0)])
163                .collect();
164
165            let mut dict = Dictionary::new();
166            dict.set("FunctionType", Object::Integer(3));
167            dict.set(
168                "Domain",
169                Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
170            );
171            dict.set("Functions", Object::Array(subfunctions));
172            dict.set("Bounds", Object::Array(bounds));
173            dict.set("Encode", Object::Array(encode));
174            Ok(dict)
175        }
176    }
177}
178
179/// Assemble a complete axial/radial shading dictionary with a real,
180/// renderable `/Function` and the required `/ColorSpace`. The function is
181/// inlined here; the writer hoists it to an indirect object at emit time
182/// (issue #297 B) so the dictionary is also valid standalone.
183fn assemble_gradient_dict(
184    shading_type: ShadingType,
185    coords: Vec<Object>,
186    stops: &[ColorStop],
187    extend_start: bool,
188    extend_end: bool,
189) -> Result<Dictionary> {
190    let space = resolve_color_space(stops);
191    let function = build_color_function(stops, space)?;
192
193    let mut dict = Dictionary::new();
194    dict.set("ShadingType", Object::Integer(shading_type as i64));
195    dict.set("ColorSpace", Object::Name(space.to_string()));
196    dict.set("Coords", Object::Array(coords));
197    dict.set(
198        "Domain",
199        Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]),
200    );
201    dict.set("Function", Object::Dictionary(function));
202    dict.set(
203        "Extend",
204        Object::Array(vec![
205            Object::Boolean(extend_start),
206            Object::Boolean(extend_end),
207        ]),
208    );
209    Ok(dict)
210}
211
212/// MSB-first bit packer for Type 4 mesh vertex streams (ISO 32000-1
213/// §8.7.4.5.5). Coordinate/component/flag values are written most-significant-
214/// bit first; each vertex is padded to a byte boundary via [`align_to_byte`].
215struct BitWriter {
216    buffer: Vec<u8>,
217    current_byte: u8,
218    bits_filled: u8,
219}
220
221impl BitWriter {
222    fn new() -> Self {
223        Self {
224            buffer: Vec::new(),
225            current_byte: 0,
226            bits_filled: 0,
227        }
228    }
229
230    /// Append the low `bits` bits of `value`, most-significant-bit first.
231    fn write_bits(&mut self, value: u64, bits: u8) {
232        for i in (0..bits).rev() {
233            let bit = ((value >> i) & 1) as u8;
234            self.current_byte = (self.current_byte << 1) | bit;
235            self.bits_filled += 1;
236            if self.bits_filled == 8 {
237                self.buffer.push(self.current_byte);
238                self.current_byte = 0;
239                self.bits_filled = 0;
240            }
241        }
242    }
243
244    /// Zero-pad any partial byte up to the next byte boundary.
245    fn align_to_byte(&mut self) {
246        if self.bits_filled > 0 {
247            self.current_byte <<= 8 - self.bits_filled;
248            self.buffer.push(self.current_byte);
249            self.current_byte = 0;
250            self.bits_filled = 0;
251        }
252    }
253
254    fn into_bytes(self) -> Vec<u8> {
255        self.buffer
256    }
257}
258
259/// Map a real `value` in `[min, max]` to an unsigned integer of `bits` width
260/// for a Type 4 mesh vertex stream (ISO 32000-1 §8.7.4.5.5, `/Decode`). The
261/// value is clamped to the range, normalised to `[0, 1]`, then scaled to
262/// `2^bits - 1` with round-half-away-from-zero (Rust `f64::round`).
263fn encode_value(value: f64, min: f64, max: f64, bits: u8) -> u64 {
264    let span = max - min;
265    let frac = if span == 0.0 {
266        0.0
267    } else {
268        ((value.clamp(min, max) - min) / span).clamp(0.0, 1.0)
269    };
270    let max_int = (1u64 << bits) - 1;
271    (frac * max_int as f64).round() as u64
272}
273
274/// A single vertex of a Type 4 free-form Gouraud-shaded triangle mesh
275/// (ISO 32000-1 §8.7.4.5.5). `flag` is the edge flag (0 starts a new
276/// triangle; 1 and 2 share an edge with the previous triangle).
277#[derive(Debug, Clone, PartialEq)]
278pub struct GouraudVertex {
279    /// Edge flag (0, 1, or 2).
280    pub flag: u8,
281    /// X coordinate in shading space.
282    pub x: f64,
283    /// Y coordinate in shading space.
284    pub y: f64,
285    /// Vertex colour.
286    pub color: Color,
287}
288
289/// Pack one mesh vertex into its byte-aligned binary form (ISO 32000-1
290/// §8.7.4.5.5): edge flag, then x, y, then colour components, each written at
291/// its declared bit width via [`BitWriter`] with coordinates/components mapped
292/// through `decode`. Each vertex's data is an integral number of bytes.
293fn pack_vertex(
294    vertex: &GouraudVertex,
295    bits_per_flag: u8,
296    bits_per_coordinate: u8,
297    bits_per_component: u8,
298    decode: &[f64],
299    color_space: &str,
300) -> Vec<u8> {
301    let mut w = BitWriter::new();
302    w.write_bits(vertex.flag as u64, bits_per_flag);
303    w.write_bits(
304        encode_value(vertex.x, decode[0], decode[1], bits_per_coordinate),
305        bits_per_coordinate,
306    );
307    w.write_bits(
308        encode_value(vertex.y, decode[2], decode[3], bits_per_coordinate),
309        bits_per_coordinate,
310    );
311    for (i, comp) in color_components(&vertex.color, color_space)
312        .into_iter()
313        .enumerate()
314    {
315        let lo = decode[4 + 2 * i];
316        let hi = decode[4 + 2 * i + 1];
317        w.write_bits(
318            encode_value(comp, lo, hi, bits_per_component),
319            bits_per_component,
320        );
321    }
322    w.align_to_byte();
323    w.into_bytes()
324}
325
326/// Number of colour components for a device colour space name (used to size
327/// the `/Decode` array and validate mesh vertex colours).
328fn n_components(color_space: &str) -> usize {
329    match color_space {
330        "DeviceGray" => 1,
331        "DeviceCMYK" => 4,
332        // DeviceRGB and any unexpected name default to 3-component RGB.
333        _ => 3,
334    }
335}
336
337/// Free-form Gouraud-shaded triangle mesh (Type 4 shading, ISO 32000-1
338/// §8.7.4.5.5). Emitted as a PDF stream: the shading dictionary plus a binary
339/// body of packed vertex data. Construct with [`FreeFormGouraudShading::new`]
340/// (defaulting to 16-bit coordinates and 8-bit components/flags) and adjust the
341/// bit widths with [`with_bits`](FreeFormGouraudShading::with_bits).
342///
343/// Marked `#[non_exhaustive]`: future additive fields (e.g. an optional
344/// `/Function` over the vertices) must not break external construction, so
345/// build via `new`/`with_bits` rather than a struct literal across crates.
346#[derive(Debug, Clone)]
347#[non_exhaustive]
348pub struct FreeFormGouraudShading {
349    /// Shading name for referencing.
350    pub name: String,
351    /// Device colour space name (`DeviceGray`/`DeviceRGB`/`DeviceCMYK`).
352    pub color_space: String,
353    /// Bits per coordinate (∈ {1,2,4,8,12,16,24,32}).
354    pub bits_per_coordinate: u8,
355    /// Bits per colour component (∈ {1,2,4,8,12,16}).
356    pub bits_per_component: u8,
357    /// Bits per edge flag (∈ {2,4,8}).
358    pub bits_per_flag: u8,
359    /// Decode array `[xmin xmax ymin ymax c1min c1max …]` (§8.7.4.5.5).
360    pub decode: Vec<f64>,
361    /// Mesh vertices in emission order.
362    pub vertices: Vec<GouraudVertex>,
363}
364
365impl FreeFormGouraudShading {
366    /// Create a mesh shading with default bit widths (16-bit coordinates,
367    /// 8-bit components, 8-bit flags). `decode` must have
368    /// `4 + 2 * n_components(color_space)` entries.
369    pub fn new(
370        name: impl Into<String>,
371        color_space: impl Into<String>,
372        decode: Vec<f64>,
373        vertices: Vec<GouraudVertex>,
374    ) -> Self {
375        Self {
376            name: name.into(),
377            color_space: color_space.into(),
378            bits_per_coordinate: 16,
379            bits_per_component: 8,
380            bits_per_flag: 8,
381            decode,
382            vertices,
383        }
384    }
385
386    /// Override the packed bit widths.
387    pub fn with_bits(
388        mut self,
389        bits_per_coordinate: u8,
390        bits_per_component: u8,
391        bits_per_flag: u8,
392    ) -> Self {
393        self.bits_per_coordinate = bits_per_coordinate;
394        self.bits_per_component = bits_per_component;
395        self.bits_per_flag = bits_per_flag;
396        self
397    }
398
399    /// Validate the mesh against the Type 4 constraints (ISO 32000-1
400    /// §8.7.4.5.5): permitted bit widths, `/Decode` length matching the colour
401    /// space, at least one vertex, and a leading edge flag of 0.
402    pub fn validate(&self) -> Result<()> {
403        if !matches!(self.bits_per_coordinate, 1 | 2 | 4 | 8 | 12 | 16 | 24 | 32) {
404            return Err(PdfError::InvalidStructure(format!(
405                "BitsPerCoordinate must be 1,2,4,8,12,16,24 or 32, got {}",
406                self.bits_per_coordinate
407            )));
408        }
409        if !matches!(self.bits_per_component, 1 | 2 | 4 | 8 | 12 | 16) {
410            return Err(PdfError::InvalidStructure(format!(
411                "BitsPerComponent must be 1,2,4,8,12 or 16, got {}",
412                self.bits_per_component
413            )));
414        }
415        if !matches!(self.bits_per_flag, 2 | 4 | 8) {
416            return Err(PdfError::InvalidStructure(format!(
417                "BitsPerFlag must be 2, 4 or 8, got {}",
418                self.bits_per_flag
419            )));
420        }
421        let expected = 4 + 2 * n_components(&self.color_space);
422        if self.decode.len() != expected {
423            return Err(PdfError::InvalidStructure(format!(
424                "Decode must have {} entries for {}, got {}",
425                expected,
426                self.color_space,
427                self.decode.len()
428            )));
429        }
430        // Each Decode pair must be non-decreasing: `encode_value` maps within
431        // `[lo, hi]` (a reversed range would panic `f64::clamp`) and this
432        // encoder does not express an inverted decode.
433        for pair in self.decode.chunks_exact(2) {
434            if pair[0] > pair[1] {
435                return Err(PdfError::InvalidStructure(format!(
436                    "Decode ranges must be non-decreasing, got [{}, {}]",
437                    pair[0], pair[1]
438                )));
439            }
440        }
441        if self.vertices.is_empty() {
442            return Err(PdfError::InvalidStructure(
443                "Mesh shading must have at least one vertex".to_string(),
444            ));
445        }
446        if self.vertices[0].flag != 0 {
447            return Err(PdfError::InvalidStructure(
448                "First mesh vertex must have edge flag 0".to_string(),
449            ));
450        }
451        // Per-vertex checks: edge flags are 0/1/2 (bit packer would silently
452        // truncate anything larger), and a `DeviceGray` mesh requires
453        // `Color::Gray` vertices (other variants would hit the unreachable! in
454        // `color_components`). RGB/CMYK spaces convert any `Color` losslessly.
455        let gray_space = self.color_space == "DeviceGray";
456        for (i, v) in self.vertices.iter().enumerate() {
457            if v.flag > 2 {
458                return Err(PdfError::InvalidStructure(format!(
459                    "Vertex {i} edge flag must be 0, 1 or 2, got {}",
460                    v.flag
461                )));
462            }
463            if gray_space && !matches!(v.color, Color::Gray(_)) {
464                return Err(PdfError::InvalidStructure(format!(
465                    "Vertex {i} color must be Color::Gray to match DeviceGray, got {:?}",
466                    v.color
467                )));
468            }
469        }
470        Ok(())
471    }
472
473    /// Build the Type 4 shading as a PDF stream object: the shading dictionary
474    /// plus the byte-aligned packed vertex data. A mesh cannot be inlined as a
475    /// plain dictionary, so this is the emission entry point (the writer hoists
476    /// it to an indirect object).
477    pub fn to_pdf_object(&self) -> Result<Object> {
478        self.validate()?;
479
480        let mut dict = Dictionary::new();
481        dict.set(
482            "ShadingType",
483            Object::Integer(ShadingType::FreeFormGouraud as i64),
484        );
485        dict.set("ColorSpace", Object::Name(self.color_space.clone()));
486        dict.set(
487            "BitsPerCoordinate",
488            Object::Integer(self.bits_per_coordinate as i64),
489        );
490        dict.set(
491            "BitsPerComponent",
492            Object::Integer(self.bits_per_component as i64),
493        );
494        dict.set("BitsPerFlag", Object::Integer(self.bits_per_flag as i64));
495        dict.set(
496            "Decode",
497            Object::Array(self.decode.iter().map(|&d| Object::Real(d)).collect()),
498        );
499
500        let mut data = Vec::new();
501        for v in &self.vertices {
502            data.extend(pack_vertex(
503                v,
504                self.bits_per_flag,
505                self.bits_per_coordinate,
506                self.bits_per_component,
507                &self.decode,
508                &self.color_space,
509            ));
510        }
511
512        Ok(Object::Stream(dict, data))
513    }
514}
515
516/// Assemble a Type 4 (PostScript calculator) function object (ISO 32000-1
517/// §7.10.5): a stream whose body is the calculator program `code` verbatim,
518/// with the given `/Domain` and `/Range`. Mirrors the shape used elsewhere in
519/// the crate (`devicen_color`), returned as `(dict, bytes)` for the writer to
520/// hoist to an indirect object.
521fn postscript_type4_function(code: &str, domain: &[f64], range: &[f64]) -> (Dictionary, Vec<u8>) {
522    let mut dict = Dictionary::new();
523    dict.set("FunctionType", Object::Integer(4));
524    dict.set(
525        "Domain",
526        Object::Array(domain.iter().map(|&d| Object::Real(d)).collect()),
527    );
528    dict.set(
529        "Range",
530        Object::Array(range.iter().map(|&r| Object::Real(r)).collect()),
531    );
532    (dict, code.as_bytes().to_vec())
533}
534
535/// PostScript that maps a local parameter `t ∈ [0,1]` on the stack to the `n`
536/// colour components of a two-stop linear interpolation `start → end`. Ends
537/// with the components in order (component 0 deepest).
538fn ramp2_ps(start: &Color, end: &Color, space: &str) -> String {
539    let s = color_components(start, space);
540    let e = color_components(end, space);
541    let n = s.len();
542    let mut parts = Vec::with_capacity(n);
543    for j in 0..n {
544        let block = format!("{} mul {} add", e[j] - s[j], s[j]);
545        if j + 1 < n {
546            // Keep a fresh copy of t for the next component and tuck the result
547            // beneath it, preserving output order.
548            parts.push(format!("dup {block} exch"));
549        } else {
550            parts.push(block);
551        }
552    }
553    parts.join(" ")
554}
555
556/// PostScript that remaps a global `t` on the stack to a segment-local
557/// parameter `(t - lo) / (hi - lo)`.
558fn remap_local_ps(lo: f64, hi: f64) -> String {
559    format!("{} sub {} div", lo, hi - lo)
560}
561
562/// PostScript mapping a global `t ∈ [0,1]` on the stack to colour components
563/// across `stops` (≥ 1). One stop → constant colour (discards `t`); two →
564/// [`ramp2_ps`]; more → nested `ifelse` split at the interior stop positions,
565/// each segment remapped to `[0,1]` then interpolated.
566fn build_color_ramp_ps(stops: &[ColorStop], space: &str) -> String {
567    match stops {
568        [] => String::new(),
569        [only] => {
570            let mut s = String::from("pop");
571            for c in color_components(&only.color, space) {
572                s.push_str(&format!(" {c}"));
573            }
574            s
575        }
576        [a, b] => ramp2_ps(&a.color, &b.color, space),
577        _ => build_ramp_nested_ps(stops, space),
578    }
579}
580
581/// Recursive nested-`ifelse` ramp for 3+ stops. Each level splits at the next
582/// interior stop position; the deepest level (two stops) is a remapped
583/// [`ramp2_ps`].
584fn build_ramp_nested_ps(stops: &[ColorStop], space: &str) -> String {
585    debug_assert!(stops.len() >= 2);
586    let lo = stops[0].position;
587    let hi = stops[1].position;
588    let seg0 = format!(
589        "{} {}",
590        remap_local_ps(lo, hi),
591        ramp2_ps(&stops[0].color, &stops[1].color, space)
592    );
593    if stops.len() == 2 {
594        return seg0;
595    }
596    let split = stops[1].position;
597    let rest = build_ramp_nested_ps(&stops[1..], space);
598    format!("dup {split} lt {{ {seg0} }} {{ {rest} }} ifelse")
599}
600
601/// PostScript prologue for a conic (angular) gradient: given `x y` on the
602/// stack, computes the angle of the vector from `center` and normalises it to
603/// `t = angle / 360 ∈ [0,1)`. `atan` (ISO 32000-1 Table 42) takes `num den`
604/// and returns `atan2(num, den)` in degrees `[0,360)`; with `dy dx` on the
605/// stack it yields the angle of `(dx, dy)`.
606fn build_conic_angle_prologue(center: Point) -> String {
607    // stack: x y → (y - cy)=dy, exch, (x - cx)=dx → dy dx → atan → /360.
608    format!("{} sub exch {} sub atan 360 div", center.y, center.x)
609}
610
611/// Conic (angular / "sweep") gradient, emitted as an exact Type 1
612/// function-based shading (ISO 32000-1 §8.7.4.5.2) whose `/Function` is a real
613/// Type 4 PostScript calculator: the colour is a resolution-independent
614/// function of the angle around `center`, not a piecewise mesh approximation.
615///
616/// Marked `#[non_exhaustive]`: build via [`ConicShading::new`] /
617/// [`with_matrix`](ConicShading::with_matrix) so future additive fields stay
618/// non-breaking.
619#[derive(Debug, Clone)]
620#[non_exhaustive]
621pub struct ConicShading {
622    /// Shading name for referencing.
623    pub name: String,
624    /// Centre of the angular sweep, in the shading's domain coordinates.
625    pub center: Point,
626    /// Domain `[xmin xmax ymin ymax]` the function is evaluated over.
627    pub domain: [f64; 4],
628    /// Optional matrix mapping domain space to the shading target space.
629    pub matrix: Option<[f64; 6]>,
630    /// Colour stops swept from angle 0 (t=0) to a full turn (t=1).
631    pub color_stops: Vec<ColorStop>,
632}
633
634impl ConicShading {
635    /// Create a conic gradient centred at `center` over `domain`.
636    pub fn new(
637        name: impl Into<String>,
638        center: Point,
639        domain: [f64; 4],
640        color_stops: Vec<ColorStop>,
641    ) -> Self {
642        Self {
643            name: name.into(),
644            center,
645            domain,
646            matrix: None,
647            color_stops,
648        }
649    }
650
651    /// Set the shading-to-target transformation matrix.
652    pub fn with_matrix(mut self, matrix: [f64; 6]) -> Self {
653        self.matrix = Some(matrix);
654        self
655    }
656
657    /// Validate stops (non-empty, ascending) and domain (min < max).
658    pub fn validate(&self) -> Result<()> {
659        if self.color_stops.is_empty() {
660            return Err(PdfError::InvalidStructure(
661                "Conic shading must have at least one color stop".to_string(),
662            ));
663        }
664        if self.domain[0] >= self.domain[1] || self.domain[2] >= self.domain[3] {
665            return Err(PdfError::InvalidStructure(
666                "Invalid domain: min values must be less than max values".to_string(),
667            ));
668        }
669        // Strictly ascending: equal adjacent positions would emit a `0 div`
670        // (division by a zero-width segment) into the PostScript ramp.
671        for window in self.color_stops.windows(2) {
672            if window[0].position >= window[1].position {
673                return Err(PdfError::InvalidStructure(
674                    "Color stops must be in strictly ascending order".to_string(),
675                ));
676            }
677        }
678        // The conic sweeps the full turn: with 2+ stops the ramp maps the
679        // normalised angle t∈[0,1) across the stops, so the first must sit at
680        // 0.0 and the last at 1.0 (interior positions are honoured). Otherwise
681        // a 2-stop ramp would silently ignore its positions and a general ramp
682        // would extrapolate past the ends.
683        if self.color_stops.len() >= 2 {
684            let first = self.color_stops[0].position;
685            let last = self.color_stops[self.color_stops.len() - 1].position;
686            if first != 0.0 || last != 1.0 {
687                return Err(PdfError::InvalidStructure(format!(
688                    "Conic color stops must span [0.0, 1.0]; first={first}, last={last}"
689                )));
690            }
691        }
692        Ok(())
693    }
694
695    /// Build the Type 1 function-based shading dictionary with a real Type 4
696    /// PostScript `/Function` (angle prologue + colour ramp) and the required
697    /// `/ColorSpace`. The `/Function` is inlined as a stream; the writer hoists
698    /// it to an indirect object (a stream cannot be a dictionary value).
699    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
700        self.validate()?;
701        let space = resolve_color_space(&self.color_stops);
702        let code = format!(
703            "{{ {} {} }}",
704            build_conic_angle_prologue(self.center),
705            build_color_ramp_ps(&self.color_stops, space)
706        );
707        let range: Vec<f64> = (0..n_components(space)).flat_map(|_| [0.0, 1.0]).collect();
708        let (fdict, fbytes) = postscript_type4_function(&code, &self.domain, &range);
709
710        let mut dict = Dictionary::new();
711        dict.set(
712            "ShadingType",
713            Object::Integer(ShadingType::FunctionBased as i64),
714        );
715        dict.set("ColorSpace", Object::Name(space.to_string()));
716        dict.set(
717            "Domain",
718            Object::Array(self.domain.iter().map(|&d| Object::Real(d)).collect()),
719        );
720        dict.set("Function", Object::Stream(fdict, fbytes));
721        if let Some(matrix) = self.matrix {
722            dict.set(
723                "Matrix",
724                Object::Array(matrix.iter().map(|&v| Object::Real(v)).collect()),
725            );
726        }
727        Ok(dict)
728    }
729}
730
731/// Internal wrapper for the additive shading types (Type 4 mesh, Type 1 conic)
732/// registered via [`Page::add_mesh_shading`](crate::Page::add_mesh_shading) and
733/// [`Page::add_conic_shading`](crate::Page::add_conic_shading). Kept in a
734/// separate page collection from [`ShadingDefinition`] so the public gradient
735/// enum stays unchanged (folding these in is a 5.0.0 breaking-bundle item).
736#[derive(Debug, Clone)]
737pub(crate) enum AdvancedShading {
738    /// Type 4 free-form Gouraud mesh (emitted as a stream).
739    Mesh(FreeFormGouraudShading),
740    /// Type 1 conic gradient (emitted as a dictionary with a `/Function`
741    /// stream the writer hoists).
742    Conic(ConicShading),
743}
744
745impl AdvancedShading {
746    /// Emit the shading as a PDF object: a stream for the mesh, a dictionary
747    /// for the conic.
748    pub(crate) fn to_pdf_object(&self) -> Result<Object> {
749        match self {
750            AdvancedShading::Mesh(m) => m.to_pdf_object(),
751            AdvancedShading::Conic(c) => Ok(Object::Dictionary(c.to_pdf_dictionary()?)),
752        }
753    }
754}
755
756/// Coordinate point for shading definitions
757#[derive(Debug, Clone, Copy, PartialEq)]
758pub struct Point {
759    pub x: f64,
760    pub y: f64,
761}
762
763impl Point {
764    /// Create a new point
765    pub fn new(x: f64, y: f64) -> Self {
766        Self { x, y }
767    }
768}
769
770/// Axial (linear) shading definition
771#[derive(Debug, Clone)]
772pub struct AxialShading {
773    /// Shading name for referencing
774    pub name: String,
775    /// Start point of the gradient
776    pub start_point: Point,
777    /// End point of the gradient
778    pub end_point: Point,
779    /// Color stops along the gradient
780    pub color_stops: Vec<ColorStop>,
781    /// Whether to extend beyond the start point
782    pub extend_start: bool,
783    /// Whether to extend beyond the end point
784    pub extend_end: bool,
785}
786
787impl AxialShading {
788    /// Create a new axial shading
789    pub fn new(
790        name: String,
791        start_point: Point,
792        end_point: Point,
793        color_stops: Vec<ColorStop>,
794    ) -> Self {
795        Self {
796            name,
797            start_point,
798            end_point,
799            color_stops,
800            extend_start: false,
801            extend_end: false,
802        }
803    }
804
805    /// Set extension options
806    pub fn with_extend(mut self, extend_start: bool, extend_end: bool) -> Self {
807        self.extend_start = extend_start;
808        self.extend_end = extend_end;
809        self
810    }
811
812    /// Create a simple two-color linear gradient
813    pub fn linear_gradient(
814        name: String,
815        start_point: Point,
816        end_point: Point,
817        start_color: Color,
818        end_color: Color,
819    ) -> Self {
820        let color_stops = vec![
821            ColorStop::new(0.0, start_color),
822            ColorStop::new(1.0, end_color),
823        ];
824
825        Self::new(name, start_point, end_point, color_stops)
826    }
827
828    /// Generate PDF shading dictionary (ISO 32000-1 §8.7.4.3, Table 78).
829    ///
830    /// Emits a real `/Function` interpolating the `color_stops` and the
831    /// required `/ColorSpace`. The function is inlined; the writer hoists
832    /// it to an indirect object when emitting the page (issue #297).
833    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
834        let coords = vec![
835            Object::Real(self.start_point.x),
836            Object::Real(self.start_point.y),
837            Object::Real(self.end_point.x),
838            Object::Real(self.end_point.y),
839        ];
840        assemble_gradient_dict(
841            ShadingType::Axial,
842            coords,
843            &self.color_stops,
844            self.extend_start,
845            self.extend_end,
846        )
847    }
848
849    /// Validate axial shading parameters
850    pub fn validate(&self) -> Result<()> {
851        if self.color_stops.is_empty() {
852            return Err(PdfError::InvalidStructure(
853                "Axial shading must have at least one color stop".to_string(),
854            ));
855        }
856
857        // Check that color stops are in order
858        for window in self.color_stops.windows(2) {
859            if window[0].position > window[1].position {
860                return Err(PdfError::InvalidStructure(
861                    "Color stops must be in ascending order".to_string(),
862                ));
863            }
864        }
865
866        // Check start and end points are different
867        if (self.start_point.x - self.end_point.x).abs() < f64::EPSILON
868            && (self.start_point.y - self.end_point.y).abs() < f64::EPSILON
869        {
870            return Err(PdfError::InvalidStructure(
871                "Start and end points cannot be the same".to_string(),
872            ));
873        }
874
875        Ok(())
876    }
877}
878
879/// Radial shading definition
880#[derive(Debug, Clone)]
881pub struct RadialShading {
882    /// Shading name for referencing
883    pub name: String,
884    /// Center point of the start circle
885    pub start_center: Point,
886    /// Radius of the start circle
887    pub start_radius: f64,
888    /// Center point of the end circle
889    pub end_center: Point,
890    /// Radius of the end circle
891    pub end_radius: f64,
892    /// Color stops along the gradient
893    pub color_stops: Vec<ColorStop>,
894    /// Whether to extend beyond the start circle
895    pub extend_start: bool,
896    /// Whether to extend beyond the end circle
897    pub extend_end: bool,
898}
899
900impl RadialShading {
901    /// Create a new radial shading
902    pub fn new(
903        name: String,
904        start_center: Point,
905        start_radius: f64,
906        end_center: Point,
907        end_radius: f64,
908        color_stops: Vec<ColorStop>,
909    ) -> Self {
910        Self {
911            name,
912            start_center,
913            start_radius: start_radius.max(0.0),
914            end_center,
915            end_radius: end_radius.max(0.0),
916            color_stops,
917            extend_start: false,
918            extend_end: false,
919        }
920    }
921
922    /// Set extension options
923    pub fn with_extend(mut self, extend_start: bool, extend_end: bool) -> Self {
924        self.extend_start = extend_start;
925        self.extend_end = extend_end;
926        self
927    }
928
929    /// Create a simple two-color radial gradient
930    pub fn radial_gradient(
931        name: String,
932        center: Point,
933        start_radius: f64,
934        end_radius: f64,
935        start_color: Color,
936        end_color: Color,
937    ) -> Self {
938        let color_stops = vec![
939            ColorStop::new(0.0, start_color),
940            ColorStop::new(1.0, end_color),
941        ];
942
943        Self::new(name, center, start_radius, center, end_radius, color_stops)
944    }
945
946    /// Generate PDF shading dictionary (ISO 32000-1 §8.7.4.4, Table 79).
947    ///
948    /// Emits a real `/Function` interpolating the `color_stops` and the
949    /// required `/ColorSpace`. The function is inlined; the writer hoists
950    /// it to an indirect object when emitting the page (issue #297).
951    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
952        let coords = vec![
953            Object::Real(self.start_center.x),
954            Object::Real(self.start_center.y),
955            Object::Real(self.start_radius),
956            Object::Real(self.end_center.x),
957            Object::Real(self.end_center.y),
958            Object::Real(self.end_radius),
959        ];
960        assemble_gradient_dict(
961            ShadingType::Radial,
962            coords,
963            &self.color_stops,
964            self.extend_start,
965            self.extend_end,
966        )
967    }
968
969    /// Validate radial shading parameters
970    pub fn validate(&self) -> Result<()> {
971        if self.color_stops.is_empty() {
972            return Err(PdfError::InvalidStructure(
973                "Radial shading must have at least one color stop".to_string(),
974            ));
975        }
976
977        // Check that color stops are in order
978        for window in self.color_stops.windows(2) {
979            if window[0].position > window[1].position {
980                return Err(PdfError::InvalidStructure(
981                    "Color stops must be in ascending order".to_string(),
982                ));
983            }
984        }
985
986        // Check for valid radii
987        if self.start_radius < 0.0 || self.end_radius < 0.0 {
988            return Err(PdfError::InvalidStructure(
989                "Radii cannot be negative".to_string(),
990            ));
991        }
992
993        Ok(())
994    }
995}
996
997/// Function-based shading definition (simplified)
998#[derive(Debug, Clone)]
999pub struct FunctionBasedShading {
1000    /// Shading name for referencing
1001    pub name: String,
1002    /// Domain of the function [xmin, xmax, ymin, ymax]
1003    pub domain: [f64; 4],
1004    /// Transformation matrix
1005    pub matrix: Option<[f64; 6]>,
1006    /// Function reference (placeholder)
1007    pub function_id: u32,
1008}
1009
1010impl FunctionBasedShading {
1011    /// Create a new function-based shading
1012    pub fn new(name: String, domain: [f64; 4], function_id: u32) -> Self {
1013        Self {
1014            name,
1015            domain,
1016            matrix: None,
1017            function_id,
1018        }
1019    }
1020
1021    /// Set transformation matrix
1022    pub fn with_matrix(mut self, matrix: [f64; 6]) -> Self {
1023        self.matrix = Some(matrix);
1024        self
1025    }
1026
1027    /// Generate PDF shading dictionary
1028    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
1029        let mut shading_dict = Dictionary::new();
1030
1031        // Basic shading properties
1032        shading_dict.set(
1033            "ShadingType",
1034            Object::Integer(ShadingType::FunctionBased as i64),
1035        );
1036
1037        // Domain array
1038        let domain = vec![
1039            Object::Real(self.domain[0]),
1040            Object::Real(self.domain[1]),
1041            Object::Real(self.domain[2]),
1042            Object::Real(self.domain[3]),
1043        ];
1044        shading_dict.set("Domain", Object::Array(domain));
1045
1046        // Matrix (if specified)
1047        if let Some(matrix) = self.matrix {
1048            let matrix_objects: Vec<Object> = matrix.iter().map(|&x| Object::Real(x)).collect();
1049            shading_dict.set("Matrix", Object::Array(matrix_objects));
1050        }
1051
1052        // Function reference
1053        shading_dict.set("Function", Object::Integer(self.function_id as i64));
1054
1055        Ok(shading_dict)
1056    }
1057
1058    /// Validate function-based shading parameters
1059    pub fn validate(&self) -> Result<()> {
1060        // Check domain validity
1061        if self.domain[0] >= self.domain[1] || self.domain[2] >= self.domain[3] {
1062            return Err(PdfError::InvalidStructure(
1063                "Invalid domain: min values must be less than max values".to_string(),
1064            ));
1065        }
1066
1067        Ok(())
1068    }
1069}
1070
1071/// Shading pattern that combines a shading with pattern properties
1072#[derive(Debug, Clone)]
1073pub struct ShadingPattern {
1074    /// Pattern name for referencing
1075    pub name: String,
1076    /// The underlying shading
1077    pub shading: ShadingDefinition,
1078    /// Pattern transformation matrix
1079    pub matrix: Option<[f64; 6]>,
1080}
1081
1082/// Enumeration of different shading types
1083#[derive(Debug, Clone)]
1084pub enum ShadingDefinition {
1085    /// Axial (linear) shading
1086    Axial(AxialShading),
1087    /// Radial shading
1088    Radial(RadialShading),
1089    /// Function-based shading
1090    FunctionBased(FunctionBasedShading),
1091}
1092
1093impl ShadingDefinition {
1094    /// Get the name of the shading
1095    pub fn name(&self) -> &str {
1096        match self {
1097            ShadingDefinition::Axial(shading) => &shading.name,
1098            ShadingDefinition::Radial(shading) => &shading.name,
1099            ShadingDefinition::FunctionBased(shading) => &shading.name,
1100        }
1101    }
1102
1103    /// Validate the shading
1104    pub fn validate(&self) -> Result<()> {
1105        match self {
1106            ShadingDefinition::Axial(shading) => shading.validate(),
1107            ShadingDefinition::Radial(shading) => shading.validate(),
1108            ShadingDefinition::FunctionBased(shading) => shading.validate(),
1109        }
1110    }
1111
1112    /// Generate PDF shading dictionary
1113    pub fn to_pdf_dictionary(&self) -> Result<Dictionary> {
1114        match self {
1115            ShadingDefinition::Axial(shading) => shading.to_pdf_dictionary(),
1116            ShadingDefinition::Radial(shading) => shading.to_pdf_dictionary(),
1117            ShadingDefinition::FunctionBased(shading) => shading.to_pdf_dictionary(),
1118        }
1119    }
1120}
1121
1122impl ShadingPattern {
1123    /// Create a new shading pattern
1124    pub fn new(name: String, shading: ShadingDefinition) -> Self {
1125        Self {
1126            name,
1127            shading,
1128            matrix: None,
1129        }
1130    }
1131
1132    /// Set pattern transformation matrix
1133    pub fn with_matrix(mut self, matrix: [f64; 6]) -> Self {
1134        self.matrix = Some(matrix);
1135        self
1136    }
1137
1138    /// Generate PDF pattern dictionary for shading pattern.
1139    ///
1140    /// NOTE: `ShadingPattern` is not yet wired through `Page` → writer (there
1141    /// is no `Page::add_shading_pattern` and the writer iterates only
1142    /// `page.shadings()`), so this method is not exercised by the
1143    /// serialisation pipeline today. The `sh` direct-paint path
1144    /// ([`GraphicsContext::paint_shading`] over [`Page::add_shading`]) is the
1145    /// wired, end-to-end gradient path. Because the inlined `/Shading` here
1146    /// carries its `/Function` inline (the writer's indirect-hoist only
1147    /// applies to `page.shadings()`), full PatternType-2 fill support remains
1148    /// a follow-up.
1149    pub fn to_pdf_pattern_dictionary(&self) -> Result<Dictionary> {
1150        let mut pattern_dict = Dictionary::new();
1151
1152        // Pattern properties
1153        pattern_dict.set("Type", Object::Name("Pattern".to_string()));
1154        pattern_dict.set("PatternType", Object::Integer(2)); // Shading pattern
1155
1156        // Inline the real shading dictionary (issue #297 C). A PatternType 2
1157        // /Shading may be a dictionary or an indirect reference (ISO 32000-1
1158        // §8.7.3.3, Table 76); inlining keeps the pattern self-contained and
1159        // renderable instead of the old `Object::Integer(1)` placeholder.
1160        pattern_dict.set(
1161            "Shading",
1162            Object::Dictionary(self.shading.to_pdf_dictionary()?),
1163        );
1164
1165        // Matrix (if specified)
1166        if let Some(matrix) = self.matrix {
1167            let matrix_objects: Vec<Object> = matrix.iter().map(|&x| Object::Real(x)).collect();
1168            pattern_dict.set("Matrix", Object::Array(matrix_objects));
1169        }
1170
1171        Ok(pattern_dict)
1172    }
1173
1174    /// Validate shading pattern
1175    pub fn validate(&self) -> Result<()> {
1176        self.shading.validate()
1177    }
1178}
1179
1180/// Shading manager for handling multiple shadings
1181#[derive(Debug, Clone)]
1182pub struct ShadingManager {
1183    /// Stored shadings
1184    shadings: HashMap<String, ShadingDefinition>,
1185    /// Stored shading patterns
1186    patterns: HashMap<String, ShadingPattern>,
1187    /// Next shading ID
1188    next_id: usize,
1189}
1190
1191impl Default for ShadingManager {
1192    fn default() -> Self {
1193        Self::new()
1194    }
1195}
1196
1197impl ShadingManager {
1198    /// Create a new shading manager
1199    pub fn new() -> Self {
1200        Self {
1201            shadings: HashMap::new(),
1202            patterns: HashMap::new(),
1203            next_id: 1,
1204        }
1205    }
1206
1207    /// Add a shading
1208    pub fn add_shading(&mut self, mut shading: ShadingDefinition) -> Result<String> {
1209        // Validate shading before adding
1210        shading.validate()?;
1211
1212        let name = shading.name().to_string();
1213
1214        // Generate unique name if empty or already exists
1215        let final_name = if name.is_empty() || self.shadings.contains_key(&name) {
1216            let auto_name = format!("Sh{}", self.next_id);
1217            self.next_id += 1;
1218
1219            // Update the shading name
1220            match &mut shading {
1221                ShadingDefinition::Axial(s) => s.name = auto_name.clone(),
1222                ShadingDefinition::Radial(s) => s.name = auto_name.clone(),
1223                ShadingDefinition::FunctionBased(s) => s.name = auto_name.clone(),
1224            }
1225
1226            auto_name
1227        } else {
1228            name
1229        };
1230
1231        self.shadings.insert(final_name.clone(), shading);
1232        Ok(final_name)
1233    }
1234
1235    /// Add a shading pattern
1236    pub fn add_shading_pattern(&mut self, mut pattern: ShadingPattern) -> Result<String> {
1237        // Validate pattern before adding
1238        pattern.validate()?;
1239
1240        // Generate unique name if empty or already exists
1241        if pattern.name.is_empty() || self.patterns.contains_key(&pattern.name) {
1242            pattern.name = format!("SP{}", self.next_id);
1243            self.next_id += 1;
1244        }
1245
1246        let name = pattern.name.clone();
1247        self.patterns.insert(name.clone(), pattern);
1248        Ok(name)
1249    }
1250
1251    /// Get a shading by name
1252    pub fn get_shading(&self, name: &str) -> Option<&ShadingDefinition> {
1253        self.shadings.get(name)
1254    }
1255
1256    /// Get a shading pattern by name
1257    pub fn get_pattern(&self, name: &str) -> Option<&ShadingPattern> {
1258        self.patterns.get(name)
1259    }
1260
1261    /// Get all shadings
1262    pub fn shadings(&self) -> &HashMap<String, ShadingDefinition> {
1263        &self.shadings
1264    }
1265
1266    /// Get all patterns
1267    pub fn patterns(&self) -> &HashMap<String, ShadingPattern> {
1268        &self.patterns
1269    }
1270
1271    /// Clear all shadings and patterns
1272    pub fn clear(&mut self) {
1273        self.shadings.clear();
1274        self.patterns.clear();
1275        self.next_id = 1;
1276    }
1277
1278    /// Count of registered shadings
1279    pub fn shading_count(&self) -> usize {
1280        self.shadings.len()
1281    }
1282
1283    /// Count of registered patterns
1284    pub fn pattern_count(&self) -> usize {
1285        self.patterns.len()
1286    }
1287
1288    /// Total count of all items
1289    pub fn total_count(&self) -> usize {
1290        self.shading_count() + self.pattern_count()
1291    }
1292
1293    /// Create a simple linear gradient
1294    pub fn create_linear_gradient(
1295        &mut self,
1296        start_point: Point,
1297        end_point: Point,
1298        start_color: Color,
1299        end_color: Color,
1300    ) -> Result<String> {
1301        let shading = ShadingDefinition::Axial(AxialShading::linear_gradient(
1302            String::new(), // Auto-generated name
1303            start_point,
1304            end_point,
1305            start_color,
1306            end_color,
1307        ));
1308
1309        self.add_shading(shading)
1310    }
1311
1312    /// Create a simple radial gradient
1313    pub fn create_radial_gradient(
1314        &mut self,
1315        center: Point,
1316        start_radius: f64,
1317        end_radius: f64,
1318        start_color: Color,
1319        end_color: Color,
1320    ) -> Result<String> {
1321        let shading = ShadingDefinition::Radial(RadialShading::radial_gradient(
1322            String::new(), // Auto-generated name
1323            center,
1324            start_radius,
1325            end_radius,
1326            start_color,
1327            end_color,
1328        ));
1329
1330        self.add_shading(shading)
1331    }
1332
1333    /// Generate shading resource dictionary for PDF
1334    pub fn to_resource_dictionary(&self) -> Result<String> {
1335        if self.shadings.is_empty() && self.patterns.is_empty() {
1336            return Ok(String::new());
1337        }
1338
1339        let mut dict = String::new();
1340
1341        // Shadings
1342        if !self.shadings.is_empty() {
1343            dict.push_str("/Shading <<");
1344            for name in self.shadings.keys() {
1345                dict.push_str(&format!(" /{} {} 0 R", name, self.next_id));
1346            }
1347            dict.push_str(" >>");
1348        }
1349
1350        // Patterns
1351        if !self.patterns.is_empty() {
1352            if !dict.is_empty() {
1353                dict.push('\n');
1354            }
1355            dict.push_str("/Pattern <<");
1356            for name in self.patterns.keys() {
1357                dict.push_str(&format!(" /{} {} 0 R", name, self.next_id));
1358            }
1359            dict.push_str(" >>");
1360        }
1361
1362        Ok(dict)
1363    }
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368    use super::*;
1369
1370    // ── Issue #407 Track A: BitWriter (MSB-first packing for mesh vertex data) ──
1371
1372    #[test]
1373    fn test_bitwriter_single_value_byte_aligned() {
1374        // 4 bits 0b1011 then pad to a byte → 0b1011_0000.
1375        let mut w = BitWriter::new();
1376        w.write_bits(0b1011, 4);
1377        w.align_to_byte();
1378        assert_eq!(w.into_bytes(), vec![0xB0]);
1379    }
1380
1381    #[test]
1382    fn test_bitwriter_value_spans_two_bytes() {
1383        // 9-bit value 0x1FF crosses the byte boundary: 1111_1111 | 1___ then pad.
1384        let mut w = BitWriter::new();
1385        w.write_bits(0b1_1111_1111, 9);
1386        w.align_to_byte();
1387        assert_eq!(w.into_bytes(), vec![0xFF, 0x80]);
1388    }
1389
1390    #[test]
1391    fn test_bitwriter_accumulates_across_writes() {
1392        // 0b10 then 0b110 → 0b10110, padded to 0b1011_0000.
1393        let mut w = BitWriter::new();
1394        w.write_bits(0b10, 2);
1395        w.write_bits(0b110, 3);
1396        w.align_to_byte();
1397        assert_eq!(w.into_bytes(), vec![0xB0]);
1398    }
1399
1400    #[test]
1401    fn test_encode_value_maps_real_to_packed_integer() {
1402        // 50 in [0,100] over 8 bits → 0.5 * 255 = 127.5 → round half away → 128.
1403        assert_eq!(encode_value(50.0, 0.0, 100.0, 8), 128);
1404    }
1405
1406    #[test]
1407    fn test_encode_value_clamps_out_of_range() {
1408        assert_eq!(encode_value(150.0, 0.0, 100.0, 8), 255);
1409        assert_eq!(encode_value(-10.0, 0.0, 100.0, 8), 0);
1410        assert_eq!(encode_value(100.0, 0.0, 100.0, 8), 255);
1411    }
1412
1413    #[test]
1414    fn test_encode_value_16_bit_precision() {
1415        // 0.5 in [0,1] over 16 bits → 0.5 * 65535 = 32767.5 → round half away → 32768.
1416        assert_eq!(encode_value(0.5, 0.0, 1.0, 16), 32768);
1417    }
1418
1419    #[test]
1420    fn test_gouraud_vertex_pack_byte_aligned() {
1421        // flag(8) + x,y(16 each) + 3 rgb components(8 each) = 64 bits = 8 bytes.
1422        let v = GouraudVertex {
1423            flag: 0,
1424            x: 10.0,
1425            y: 20.0,
1426            color: Color::Rgb(1.0, 0.0, 0.0),
1427        };
1428        let decode = [0.0, 100.0, 0.0, 100.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
1429        let bytes = pack_vertex(&v, 8, 16, 8, &decode, "DeviceRGB");
1430        // x=10→0.1*65535=6554=0x199A, y=20→0x3333, r=255,g=0,b=0.
1431        assert_eq!(bytes, vec![0x00, 0x19, 0x9A, 0x33, 0x33, 0xFF, 0x00, 0x00]);
1432    }
1433
1434    #[test]
1435    fn test_gouraud_vertex_pack_with_padding() {
1436        // flag(2) + x,y(8 each) + 1 gray component(8) = 26 bits → 4 bytes (6 pad).
1437        let v = GouraudVertex {
1438            flag: 1,
1439            x: 50.0,
1440            y: 25.0,
1441            color: Color::Gray(0.5),
1442        };
1443        let decode = [0.0, 100.0, 0.0, 100.0, 0.0, 1.0];
1444        let bytes = pack_vertex(&v, 2, 8, 8, &decode, "DeviceGray");
1445        // flag=01, x=128=0x80, y=64=0x40, gray=128=0x80 → 01 10000000 01000000 10000000.
1446        assert_eq!(bytes, vec![0x60, 0x10, 0x20, 0x00]);
1447    }
1448
1449    /// Three valid RGB vertices for the mesh-level tests (flags 0,1,1).
1450    fn sample_rgb_mesh() -> FreeFormGouraudShading {
1451        FreeFormGouraudShading::new(
1452            "M".to_string(),
1453            "DeviceRGB".to_string(),
1454            vec![0.0, 100.0, 0.0, 100.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
1455            vec![
1456                GouraudVertex {
1457                    flag: 0,
1458                    x: 10.0,
1459                    y: 20.0,
1460                    color: Color::Rgb(1.0, 0.0, 0.0),
1461                },
1462                GouraudVertex {
1463                    flag: 1,
1464                    x: 50.0,
1465                    y: 50.0,
1466                    color: Color::Rgb(0.0, 1.0, 0.0),
1467                },
1468                GouraudVertex {
1469                    flag: 1,
1470                    x: 90.0,
1471                    y: 10.0,
1472                    color: Color::Rgb(0.0, 0.0, 1.0),
1473                },
1474            ],
1475        )
1476    }
1477
1478    #[test]
1479    fn test_freeform_gouraud_creation_defaults() {
1480        let mesh = sample_rgb_mesh();
1481        assert!(mesh.validate().is_ok());
1482        assert_eq!(mesh.name, "M");
1483        assert_eq!(mesh.color_space, "DeviceRGB");
1484        // new() defaults: 16-bit coords, 8-bit components, 8-bit flags.
1485        assert_eq!(mesh.bits_per_coordinate, 16);
1486        assert_eq!(mesh.bits_per_component, 8);
1487        assert_eq!(mesh.bits_per_flag, 8);
1488        assert_eq!(mesh.vertices.len(), 3);
1489    }
1490
1491    #[test]
1492    fn test_freeform_gouraud_validate_rejects_invalid_bits() {
1493        let mut m = sample_rgb_mesh();
1494        m.bits_per_coordinate = 5; // not in {1,2,4,8,12,16,24,32}
1495        assert!(m.validate().is_err());
1496
1497        let mut m = sample_rgb_mesh();
1498        m.bits_per_component = 3; // not in {1,2,4,8,12,16}
1499        assert!(m.validate().is_err());
1500
1501        let mut m = sample_rgb_mesh();
1502        m.bits_per_flag = 3; // not in {2,4,8}
1503        assert!(m.validate().is_err());
1504    }
1505
1506    #[test]
1507    fn test_freeform_gouraud_validate_rejects_decode_length_mismatch() {
1508        let mut m = sample_rgb_mesh();
1509        // DeviceRGB needs 4 + 2*3 = 10 entries; give 8.
1510        m.decode = vec![0.0, 100.0, 0.0, 100.0, 0.0, 1.0, 0.0, 1.0];
1511        assert!(m.validate().is_err());
1512    }
1513
1514    #[test]
1515    fn test_freeform_gouraud_validate_rejects_empty_vertices() {
1516        let mut m = sample_rgb_mesh();
1517        m.vertices.clear();
1518        assert!(m.validate().is_err());
1519    }
1520
1521    #[test]
1522    fn test_freeform_gouraud_validate_rejects_nonzero_first_flag() {
1523        let mut m = sample_rgb_mesh();
1524        m.vertices[0].flag = 1;
1525        assert!(m.validate().is_err());
1526    }
1527
1528    #[test]
1529    fn test_freeform_gouraud_to_pdf_object_dict_keys() {
1530        let obj = sample_rgb_mesh().to_pdf_object().unwrap();
1531        let dict = match &obj {
1532            Object::Stream(d, _) => d,
1533            other => panic!("mesh must emit a Stream, got {other:?}"),
1534        };
1535        assert_eq!(dict.get("ShadingType"), Some(&Object::Integer(4)));
1536        assert_eq!(
1537            dict.get("ColorSpace"),
1538            Some(&Object::Name("DeviceRGB".to_string()))
1539        );
1540        assert_eq!(dict.get("BitsPerCoordinate"), Some(&Object::Integer(16)));
1541        assert_eq!(dict.get("BitsPerComponent"), Some(&Object::Integer(8)));
1542        assert_eq!(dict.get("BitsPerFlag"), Some(&Object::Integer(8)));
1543        assert_eq!(
1544            dict.get("Decode"),
1545            Some(&Object::Array(
1546                vec![0.0, 100.0, 0.0, 100.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
1547                    .into_iter()
1548                    .map(Object::Real)
1549                    .collect()
1550            ))
1551        );
1552    }
1553
1554    #[test]
1555    fn test_freeform_gouraud_stream_body_exact_bytes() {
1556        let obj = sample_rgb_mesh().to_pdf_object().unwrap();
1557        let data = match &obj {
1558            Object::Stream(_, d) => d,
1559            other => panic!("mesh must emit a Stream, got {other:?}"),
1560        };
1561        assert_eq!(
1562            *data,
1563            vec![
1564                // v0: flag0, x10, y20, red
1565                0x00, 0x19, 0x9A, 0x33, 0x33, 0xFF, 0x00, 0x00, //
1566                // v1: flag1, x50, y50, green
1567                0x01, 0x80, 0x00, 0x80, 0x00, 0x00, 0xFF, 0x00, //
1568                // v2: flag1, x90, y10, blue
1569                0x01, 0xE6, 0x66, 0x19, 0x9A, 0x00, 0x00, 0xFF,
1570            ]
1571        );
1572    }
1573
1574    // ── Issue #407 Track B: Type 1 conic (PostScript Type 4 function) ──
1575
1576    #[test]
1577    fn test_postscript_type4_function_shape() {
1578        // Wraps arbitrary calculator code with FunctionType 4 + Domain/Range;
1579        // the code bytes are stored verbatim (no transformation).
1580        let (dict, code) = postscript_type4_function(
1581            "{ 1 }",
1582            &[0.0, 1.0, 0.0, 1.0],
1583            &[0.0, 1.0, 0.0, 1.0, 0.0, 1.0],
1584        );
1585        assert_eq!(dict.get("FunctionType"), Some(&Object::Integer(4)));
1586        assert_eq!(
1587            dict.get("Domain"),
1588            Some(&Object::Array(
1589                vec![0.0, 1.0, 0.0, 1.0]
1590                    .into_iter()
1591                    .map(Object::Real)
1592                    .collect()
1593            ))
1594        );
1595        assert_eq!(
1596            dict.get("Range"),
1597            Some(&Object::Array(
1598                vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
1599                    .into_iter()
1600                    .map(Object::Real)
1601                    .collect()
1602            ))
1603        );
1604        assert_eq!(code, b"{ 1 }");
1605    }
1606
1607    #[test]
1608    fn test_color_ramp_ps_two_stops_no_branching() {
1609        // 2 stops → straight per-component linear interpolation of a local t on
1610        // the stack, no ifelse. red→blue over DeviceRGB: deltas (-1, 0, 1).
1611        let stops = vec![
1612            ColorStop::new(0.0, Color::Rgb(1.0, 0.0, 0.0)),
1613            ColorStop::new(1.0, Color::Rgb(0.0, 0.0, 1.0)),
1614        ];
1615        let ps = build_color_ramp_ps(&stops, "DeviceRGB");
1616        assert_eq!(ps, "dup -1 mul 1 add exch dup 0 mul 0 add exch 1 mul 0 add");
1617        assert!(!ps.contains("ifelse"));
1618    }
1619
1620    #[test]
1621    fn test_color_ramp_ps_three_stops_has_bound_check() {
1622        // 3 stops → one ifelse splitting at the interior stop (0.5), two
1623        // interpolation segments (3 muls each over DeviceRGB → 6 total).
1624        let stops = vec![
1625            ColorStop::new(0.0, Color::red()),
1626            ColorStop::new(0.5, Color::green()),
1627            ColorStop::new(1.0, Color::blue()),
1628        ];
1629        let ps = build_color_ramp_ps(&stops, "DeviceRGB");
1630        assert_eq!(ps.matches("ifelse").count(), 1, "one split for three stops");
1631        assert!(ps.contains("0.5"), "interior bound present");
1632        assert_eq!(ps.matches("mul").count(), 6, "two RGB segments");
1633    }
1634
1635    #[test]
1636    fn test_conic_angle_prologue_exact_ps() {
1637        // stack in: x y. dy=y-cy, dx=x-cx, angle=atan2(dy,dx), t=angle/360.
1638        let ps = build_conic_angle_prologue(Point::new(50.0, 50.0));
1639        assert_eq!(ps, "50 sub exch 50 sub atan 360 div");
1640    }
1641
1642    #[test]
1643    fn test_conic_shading_emits_type1_with_ps_function_and_colorspace() {
1644        let stops = vec![
1645            ColorStop::new(0.0, Color::red()),
1646            ColorStop::new(1.0, Color::blue()),
1647        ];
1648        let conic = ConicShading::new(
1649            "C".to_string(),
1650            Point::new(50.0, 50.0),
1651            [0.0, 100.0, 0.0, 100.0],
1652            stops.clone(),
1653        );
1654        let dict = conic.to_pdf_dictionary().unwrap();
1655
1656        // Function-based (Type 1) shading with the required ColorSpace + Domain.
1657        assert_eq!(dict.get("ShadingType"), Some(&Object::Integer(1)));
1658        assert_eq!(
1659            dict.get("ColorSpace"),
1660            Some(&Object::Name("DeviceRGB".to_string()))
1661        );
1662        assert_eq!(
1663            dict.get("Domain"),
1664            Some(&Object::Array(
1665                vec![0.0, 100.0, 0.0, 100.0]
1666                    .into_iter()
1667                    .map(Object::Real)
1668                    .collect()
1669            ))
1670        );
1671
1672        // /Function is a real Type 4 PostScript stream, not a placeholder.
1673        let (fdict, fcode) = match dict.get("Function") {
1674            Some(Object::Stream(d, c)) => (d, c),
1675            other => panic!("Function must be a Type 4 stream, got {other:?}"),
1676        };
1677        assert_eq!(fdict.get("FunctionType"), Some(&Object::Integer(4)));
1678        // Function domain == shading domain (2 inputs x, y).
1679        assert_eq!(
1680            fdict.get("Domain"),
1681            Some(&Object::Array(
1682                vec![0.0, 100.0, 0.0, 100.0]
1683                    .into_iter()
1684                    .map(Object::Real)
1685                    .collect()
1686            ))
1687        );
1688        // Range == n_components pairs of [0, 1] (3 for RGB).
1689        assert_eq!(
1690            fdict.get("Range"),
1691            Some(&Object::Array(
1692                vec![0.0, 1.0, 0.0, 1.0, 0.0, 1.0]
1693                    .into_iter()
1694                    .map(Object::Real)
1695                    .collect()
1696            ))
1697        );
1698        // Program == "{ <angle prologue> <colour ramp> }".
1699        let expected = format!(
1700            "{{ {} {} }}",
1701            build_conic_angle_prologue(Point::new(50.0, 50.0)),
1702            build_color_ramp_ps(&stops, "DeviceRGB")
1703        );
1704        assert_eq!(fcode, &expected.into_bytes());
1705    }
1706
1707    #[test]
1708    fn test_conic_shading_validate_rejects_bad_domain_and_empty_stops() {
1709        let stops = vec![
1710            ColorStop::new(0.0, Color::red()),
1711            ColorStop::new(1.0, Color::blue()),
1712        ];
1713        let bad_domain = ConicShading::new(
1714            "C".to_string(),
1715            Point::new(0.0, 0.0),
1716            [1.0, 0.0, 0.0, 1.0], // xmin > xmax
1717            stops,
1718        );
1719        assert!(bad_domain.validate().is_err());
1720
1721        let empty = ConicShading::new(
1722            "C".to_string(),
1723            Point::new(0.0, 0.0),
1724            [0.0, 1.0, 0.0, 1.0],
1725            vec![],
1726        );
1727        assert!(empty.validate().is_err());
1728    }
1729
1730    // ── #407 QR hardening: validate() must reject inputs that would otherwise
1731    //    panic (clamp/unreachable) or emit malformed output ──
1732
1733    #[test]
1734    fn test_freeform_gouraud_validate_rejects_color_space_mismatch() {
1735        // DeviceGray declared but vertices carry RGB colours → would hit the
1736        // unreachable! in color_components; validate must reject first.
1737        let mut m = sample_rgb_mesh();
1738        m.color_space = "DeviceGray".to_string();
1739        m.decode = vec![0.0, 100.0, 0.0, 100.0, 0.0, 1.0]; // valid DeviceGray length
1740        assert!(m.validate().is_err());
1741    }
1742
1743    #[test]
1744    fn test_freeform_gouraud_validate_rejects_inverted_decode() {
1745        // Inverted x range [100,0] would panic f64::clamp in encode_value.
1746        let mut m = sample_rgb_mesh();
1747        m.decode[0] = 100.0;
1748        m.decode[1] = 0.0;
1749        assert!(m.validate().is_err());
1750    }
1751
1752    #[test]
1753    fn test_freeform_gouraud_validate_rejects_out_of_range_flag() {
1754        // Edge flags are 0/1/2 (ISO 32000-1 §8.7.4.5.5); 3 would be silently
1755        // truncated by the bit packer.
1756        let mut m = sample_rgb_mesh();
1757        m.vertices[1].flag = 3;
1758        assert!(m.validate().is_err());
1759    }
1760
1761    #[test]
1762    fn test_freeform_gouraud_cmyk_mesh_validates_and_packs() {
1763        // Coverage: a CMYK mesh (4 components → 12 decode entries) round-trips
1764        // through validate + pack.
1765        let mesh = FreeFormGouraudShading::new(
1766            "Cmyk".to_string(),
1767            "DeviceCMYK".to_string(),
1768            vec![
1769                0.0, 100.0, 0.0, 100.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0,
1770            ],
1771            vec![GouraudVertex {
1772                flag: 0,
1773                x: 0.0,
1774                y: 0.0,
1775                color: Color::Cmyk(1.0, 0.0, 0.0, 0.0),
1776            }],
1777        )
1778        .with_bits(8, 8, 8);
1779        assert!(mesh.validate().is_ok());
1780        let bytes = pack_vertex(&mesh.vertices[0], 8, 8, 8, &mesh.decode, "DeviceCMYK");
1781        // flag0, x0, y0, then cyan=255, m=0, y=0, k=0.
1782        assert_eq!(bytes, vec![0x00, 0x00, 0x00, 0xFF, 0x00, 0x00, 0x00]);
1783    }
1784
1785    #[test]
1786    fn test_conic_shading_validate_requires_full_range_endpoints() {
1787        // Stops at 0.25/0.75 (not 0/1) would silently produce the same program
1788        // as 0/1 — reject so the caller isn't misled.
1789        let conic = ConicShading::new(
1790            "C".to_string(),
1791            Point::new(0.0, 0.0),
1792            [0.0, 1.0, 0.0, 1.0],
1793            vec![
1794                ColorStop::new(0.25, Color::red()),
1795                ColorStop::new(0.75, Color::blue()),
1796            ],
1797        );
1798        assert!(conic.validate().is_err());
1799    }
1800
1801    #[test]
1802    fn test_conic_shading_validate_rejects_equal_positions() {
1803        // Equal adjacent positions would emit `0 div` into the PostScript ramp.
1804        let conic = ConicShading::new(
1805            "C".to_string(),
1806            Point::new(0.0, 0.0),
1807            [0.0, 1.0, 0.0, 1.0],
1808            vec![
1809                ColorStop::new(0.0, Color::red()),
1810                ColorStop::new(0.5, Color::green()),
1811                ColorStop::new(0.5, Color::blue()),
1812                ColorStop::new(1.0, Color::red()),
1813            ],
1814        );
1815        assert!(conic.validate().is_err());
1816    }
1817
1818    #[test]
1819    fn test_conic_shading_three_stops_no_zero_div_and_one_ifelse() {
1820        // A valid 3-stop conic (endpoints 0/1, strictly ascending) produces a
1821        // well-formed nested ramp: no `0 div`, exactly one ifelse.
1822        let conic = ConicShading::new(
1823            "C".to_string(),
1824            Point::new(50.0, 50.0),
1825            [0.0, 100.0, 0.0, 100.0],
1826            vec![
1827                ColorStop::new(0.0, Color::red()),
1828                ColorStop::new(0.5, Color::green()),
1829                ColorStop::new(1.0, Color::blue()),
1830            ],
1831        );
1832        let dict = conic.to_pdf_dictionary().unwrap();
1833        let code = match dict.get("Function") {
1834            Some(Object::Stream(_, c)) => String::from_utf8(c.clone()).unwrap(),
1835            other => panic!("Function must be a stream, got {other:?}"),
1836        };
1837        // A zero-width segment would emit the token ` 0 div`; the angle
1838        // prologue's `360 div` must not be mistaken for it.
1839        assert!(!code.contains(" 0 div"), "no zero-width segment:\n{code}");
1840        assert_eq!(code.matches("ifelse").count(), 1);
1841    }
1842
1843    #[test]
1844    fn test_color_stop_creation() {
1845        let stop = ColorStop::new(0.5, Color::red());
1846        assert_eq!(stop.position, 0.5);
1847        assert_eq!(stop.color, Color::red());
1848
1849        // Test clamping
1850        let stop_clamped = ColorStop::new(1.5, Color::blue());
1851        assert_eq!(stop_clamped.position, 1.0);
1852    }
1853
1854    #[test]
1855    fn test_point_creation() {
1856        let point = Point::new(10.0, 20.0);
1857        assert_eq!(point.x, 10.0);
1858        assert_eq!(point.y, 20.0);
1859    }
1860
1861    #[test]
1862    fn test_axial_shading_creation() {
1863        let start = Point::new(0.0, 0.0);
1864        let end = Point::new(100.0, 100.0);
1865        let stops = vec![
1866            ColorStop::new(0.0, Color::red()),
1867            ColorStop::new(1.0, Color::blue()),
1868        ];
1869
1870        let shading = AxialShading::new("TestGradient".to_string(), start, end, stops);
1871        assert_eq!(shading.name, "TestGradient");
1872        assert_eq!(shading.start_point, start);
1873        assert_eq!(shading.end_point, end);
1874        assert_eq!(shading.color_stops.len(), 2);
1875        assert!(!shading.extend_start);
1876        assert!(!shading.extend_end);
1877    }
1878
1879    #[test]
1880    fn test_axial_shading_linear_gradient() {
1881        let start = Point::new(0.0, 0.0);
1882        let end = Point::new(100.0, 0.0);
1883        let shading = AxialShading::linear_gradient(
1884            "LinearGrad".to_string(),
1885            start,
1886            end,
1887            Color::red(),
1888            Color::blue(),
1889        );
1890
1891        assert_eq!(shading.color_stops.len(), 2);
1892        assert_eq!(shading.color_stops[0].position, 0.0);
1893        assert_eq!(shading.color_stops[1].position, 1.0);
1894    }
1895
1896    #[test]
1897    fn test_axial_shading_with_extend() {
1898        let start = Point::new(0.0, 0.0);
1899        let end = Point::new(100.0, 0.0);
1900        let shading = AxialShading::linear_gradient(
1901            "ExtendedGrad".to_string(),
1902            start,
1903            end,
1904            Color::red(),
1905            Color::blue(),
1906        )
1907        .with_extend(true, true);
1908
1909        assert!(shading.extend_start);
1910        assert!(shading.extend_end);
1911    }
1912
1913    #[test]
1914    fn test_axial_shading_validation_valid() {
1915        let start = Point::new(0.0, 0.0);
1916        let end = Point::new(100.0, 0.0);
1917        let shading = AxialShading::linear_gradient(
1918            "ValidGrad".to_string(),
1919            start,
1920            end,
1921            Color::red(),
1922            Color::blue(),
1923        );
1924
1925        assert!(shading.validate().is_ok());
1926    }
1927
1928    #[test]
1929    fn test_axial_shading_validation_no_stops() {
1930        let start = Point::new(0.0, 0.0);
1931        let end = Point::new(100.0, 0.0);
1932        let shading = AxialShading::new("EmptyGrad".to_string(), start, end, Vec::new());
1933
1934        assert!(shading.validate().is_err());
1935    }
1936
1937    #[test]
1938    fn test_axial_shading_validation_same_points() {
1939        let point = Point::new(50.0, 50.0);
1940        let shading = AxialShading::linear_gradient(
1941            "SamePointGrad".to_string(),
1942            point,
1943            point,
1944            Color::red(),
1945            Color::blue(),
1946        );
1947
1948        assert!(shading.validate().is_err());
1949    }
1950
1951    #[test]
1952    fn test_radial_shading_creation() {
1953        let center = Point::new(50.0, 50.0);
1954        let stops = vec![
1955            ColorStop::new(0.0, Color::red()),
1956            ColorStop::new(1.0, Color::blue()),
1957        ];
1958
1959        let shading =
1960            RadialShading::new("RadialGrad".to_string(), center, 10.0, center, 50.0, stops);
1961
1962        assert_eq!(shading.name, "RadialGrad");
1963        assert_eq!(shading.start_center, center);
1964        assert_eq!(shading.start_radius, 10.0);
1965        assert_eq!(shading.end_radius, 50.0);
1966    }
1967
1968    #[test]
1969    fn test_radial_shading_gradient() {
1970        let center = Point::new(50.0, 50.0);
1971        let shading = RadialShading::radial_gradient(
1972            "SimpleRadial".to_string(),
1973            center,
1974            0.0,
1975            25.0,
1976            Color::white(),
1977            Color::black(),
1978        );
1979
1980        assert_eq!(shading.color_stops.len(), 2);
1981        assert_eq!(shading.start_radius, 0.0);
1982        assert_eq!(shading.end_radius, 25.0);
1983    }
1984
1985    #[test]
1986    fn test_radial_shading_radius_clamping() {
1987        let center = Point::new(50.0, 50.0);
1988        let stops = vec![ColorStop::new(0.0, Color::red())];
1989
1990        let shading = RadialShading::new(
1991            "ClampedRadial".to_string(),
1992            center,
1993            -5.0, // Negative radius should be clamped to 0
1994            center,
1995            10.0,
1996            stops,
1997        );
1998
1999        assert_eq!(shading.start_radius, 0.0);
2000    }
2001
2002    #[test]
2003    fn test_radial_shading_validation_valid() {
2004        let center = Point::new(50.0, 50.0);
2005        let shading = RadialShading::radial_gradient(
2006            "ValidRadial".to_string(),
2007            center,
2008            0.0,
2009            25.0,
2010            Color::red(),
2011            Color::blue(),
2012        );
2013
2014        assert!(shading.validate().is_ok());
2015    }
2016
2017    #[test]
2018    fn test_function_based_shading_creation() {
2019        let domain = [0.0, 1.0, 0.0, 1.0];
2020        let shading = FunctionBasedShading::new("FuncShading".to_string(), domain, 1);
2021
2022        assert_eq!(shading.name, "FuncShading");
2023        assert_eq!(shading.domain, domain);
2024        assert_eq!(shading.function_id, 1);
2025        assert!(shading.matrix.is_none());
2026    }
2027
2028    #[test]
2029    fn test_function_based_shading_with_matrix() {
2030        let domain = [0.0, 1.0, 0.0, 1.0];
2031        let matrix = [2.0, 0.0, 0.0, 2.0, 10.0, 20.0];
2032        let shading =
2033            FunctionBasedShading::new("FuncShading".to_string(), domain, 1).with_matrix(matrix);
2034
2035        assert_eq!(shading.matrix, Some(matrix));
2036    }
2037
2038    #[test]
2039    fn test_function_based_shading_validation_valid() {
2040        let domain = [0.0, 1.0, 0.0, 1.0];
2041        let shading = FunctionBasedShading::new("ValidFunc".to_string(), domain, 1);
2042
2043        assert!(shading.validate().is_ok());
2044    }
2045
2046    #[test]
2047    fn test_function_based_shading_validation_invalid_domain() {
2048        let domain = [1.0, 0.0, 0.0, 1.0]; // min > max
2049        let shading = FunctionBasedShading::new("InvalidFunc".to_string(), domain, 1);
2050
2051        assert!(shading.validate().is_err());
2052    }
2053
2054    #[test]
2055    fn test_shading_pattern_creation() {
2056        let start = Point::new(0.0, 0.0);
2057        let end = Point::new(100.0, 0.0);
2058        let axial = AxialShading::linear_gradient(
2059            "PatternGrad".to_string(),
2060            start,
2061            end,
2062            Color::red(),
2063            Color::blue(),
2064        );
2065        let shading = ShadingDefinition::Axial(axial);
2066        let pattern = ShadingPattern::new("Pattern1".to_string(), shading);
2067
2068        assert_eq!(pattern.name, "Pattern1");
2069        assert!(pattern.matrix.is_none());
2070    }
2071
2072    #[test]
2073    fn test_shading_pattern_with_matrix() {
2074        let start = Point::new(0.0, 0.0);
2075        let end = Point::new(100.0, 0.0);
2076        let axial = AxialShading::linear_gradient(
2077            "PatternGrad".to_string(),
2078            start,
2079            end,
2080            Color::red(),
2081            Color::blue(),
2082        );
2083        let shading = ShadingDefinition::Axial(axial);
2084        let matrix = [1.0, 0.0, 0.0, 1.0, 50.0, 50.0];
2085        let pattern = ShadingPattern::new("Pattern1".to_string(), shading).with_matrix(matrix);
2086
2087        assert_eq!(pattern.matrix, Some(matrix));
2088    }
2089
2090    #[test]
2091    fn test_shading_manager_creation() {
2092        let manager = ShadingManager::new();
2093        assert_eq!(manager.shading_count(), 0);
2094        assert_eq!(manager.pattern_count(), 0);
2095        assert_eq!(manager.total_count(), 0);
2096    }
2097
2098    #[test]
2099    fn test_shading_manager_add_shading() {
2100        let mut manager = ShadingManager::new();
2101        let start = Point::new(0.0, 0.0);
2102        let end = Point::new(100.0, 0.0);
2103        let axial = AxialShading::linear_gradient(
2104            "TestGrad".to_string(),
2105            start,
2106            end,
2107            Color::red(),
2108            Color::blue(),
2109        );
2110        let shading = ShadingDefinition::Axial(axial);
2111
2112        let name = manager.add_shading(shading).unwrap();
2113        assert_eq!(name, "TestGrad");
2114        assert_eq!(manager.shading_count(), 1);
2115
2116        let retrieved = manager.get_shading(&name).unwrap();
2117        assert_eq!(retrieved.name(), "TestGrad");
2118    }
2119
2120    #[test]
2121    fn test_shading_manager_auto_naming() {
2122        let mut manager = ShadingManager::new();
2123        let start = Point::new(0.0, 0.0);
2124        let end = Point::new(100.0, 0.0);
2125        let axial = AxialShading::linear_gradient(
2126            String::new(), // Empty name
2127            start,
2128            end,
2129            Color::red(),
2130            Color::blue(),
2131        );
2132        let shading = ShadingDefinition::Axial(axial);
2133
2134        let name = manager.add_shading(shading).unwrap();
2135        assert_eq!(name, "Sh1");
2136
2137        // Add another with empty name
2138        let axial2 = AxialShading::linear_gradient(
2139            String::new(),
2140            start,
2141            end,
2142            Color::green(),
2143            Color::yellow(),
2144        );
2145        let shading2 = ShadingDefinition::Axial(axial2);
2146
2147        let name2 = manager.add_shading(shading2).unwrap();
2148        assert_eq!(name2, "Sh2");
2149    }
2150
2151    #[test]
2152    fn test_shading_manager_create_gradients() {
2153        let mut manager = ShadingManager::new();
2154
2155        let linear_name = manager
2156            .create_linear_gradient(
2157                Point::new(0.0, 0.0),
2158                Point::new(100.0, 0.0),
2159                Color::red(),
2160                Color::blue(),
2161            )
2162            .unwrap();
2163
2164        let radial_name = manager
2165            .create_radial_gradient(
2166                Point::new(50.0, 50.0),
2167                0.0,
2168                25.0,
2169                Color::white(),
2170                Color::black(),
2171            )
2172            .unwrap();
2173
2174        assert_eq!(manager.shading_count(), 2);
2175        assert!(manager.get_shading(&linear_name).is_some());
2176        assert!(manager.get_shading(&radial_name).is_some());
2177    }
2178
2179    #[test]
2180    fn test_shading_manager_clear() {
2181        let mut manager = ShadingManager::new();
2182
2183        manager
2184            .create_linear_gradient(
2185                Point::new(0.0, 0.0),
2186                Point::new(100.0, 0.0),
2187                Color::red(),
2188                Color::blue(),
2189            )
2190            .unwrap();
2191
2192        assert_eq!(manager.shading_count(), 1);
2193
2194        manager.clear();
2195        assert_eq!(manager.shading_count(), 0);
2196        assert_eq!(manager.total_count(), 0);
2197    }
2198
2199    #[test]
2200    fn test_axial_shading_pdf_dictionary() {
2201        let start = Point::new(0.0, 0.0);
2202        let end = Point::new(100.0, 50.0);
2203        let shading = AxialShading::linear_gradient(
2204            "TestPDF".to_string(),
2205            start,
2206            end,
2207            Color::red(),
2208            Color::blue(),
2209        )
2210        .with_extend(true, false);
2211
2212        let dict = shading.to_pdf_dictionary().unwrap();
2213
2214        if let Some(Object::Integer(shading_type)) = dict.get("ShadingType") {
2215            assert_eq!(*shading_type, 2); // Axial type
2216        }
2217
2218        if let Some(Object::Array(coords)) = dict.get("Coords") {
2219            assert_eq!(coords.len(), 4);
2220        }
2221
2222        if let Some(Object::Array(extend)) = dict.get("Extend") {
2223            assert_eq!(extend.len(), 2);
2224            if let (Object::Boolean(start_extend), Object::Boolean(end_extend)) =
2225                (&extend[0], &extend[1])
2226            {
2227                assert!(*start_extend);
2228                assert!(!(*end_extend));
2229            }
2230        }
2231    }
2232
2233    // ── Issue #297: real /Function, /ColorSpace and the `sh` paint path ──
2234
2235    /// Extract the C0/C1 arrays of a Type 2 function dictionary as f64 vecs.
2236    fn type2_c0_c1(func: &Dictionary) -> (Vec<f64>, Vec<f64>) {
2237        let extract = |key: &str| -> Vec<f64> {
2238            match func.get(key) {
2239                Some(Object::Array(a)) => a
2240                    .iter()
2241                    .map(|o| match o {
2242                        Object::Real(v) => *v,
2243                        Object::Integer(v) => *v as f64,
2244                        _ => panic!("{key} component is not numeric"),
2245                    })
2246                    .collect(),
2247                other => panic!("{key} is not an array: {other:?}"),
2248            }
2249        };
2250        (extract("C0"), extract("C1"))
2251    }
2252
2253    #[test]
2254    fn test_axial_two_stops_emits_real_type2_function() {
2255        // 2 stops red→blue must produce a Type 2 (exponential) function whose
2256        // endpoints carry the actual stop colours, not a placeholder integer.
2257        let shading = AxialShading::linear_gradient(
2258            "G".to_string(),
2259            Point::new(0.0, 0.0),
2260            Point::new(100.0, 0.0),
2261            Color::red(),
2262            Color::blue(),
2263        );
2264        let dict = shading.to_pdf_dictionary().unwrap();
2265
2266        // /ColorSpace is REQUIRED by ISO 32000-1 §8.7.4.3 Table 78 — was missing.
2267        assert_eq!(
2268            dict.get("ColorSpace"),
2269            Some(&Object::Name("DeviceRGB".to_string())),
2270            "axial shading must declare /ColorSpace"
2271        );
2272
2273        // /Function must be a real function dictionary, not Object::Integer(1).
2274        let func = match dict.get("Function") {
2275            Some(Object::Dictionary(d)) => d,
2276            other => panic!("/Function must be a dictionary, got {other:?}"),
2277        };
2278        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(2)));
2279        let (c0, c1) = type2_c0_c1(func);
2280        assert_eq!(c0, vec![1.0, 0.0, 0.0], "C0 must be red");
2281        assert_eq!(c1, vec![0.0, 0.0, 1.0], "C1 must be blue");
2282        assert_eq!(func.get("N"), Some(&Object::Real(1.0)));
2283        assert_eq!(
2284            func.get("Domain"),
2285            Some(&Object::Array(vec![Object::Real(0.0), Object::Real(1.0)]))
2286        );
2287    }
2288
2289    #[test]
2290    fn test_axial_three_stops_emits_type3_stitching() {
2291        // 3 stops must produce a Type 3 stitching function wrapping 2 Type 2
2292        // subfunctions, with /Bounds at the interior stop and /Encode [0 1 0 1].
2293        let shading = AxialShading::new(
2294            "G".to_string(),
2295            Point::new(0.0, 0.0),
2296            Point::new(100.0, 0.0),
2297            vec![
2298                ColorStop::new(0.0, Color::red()),
2299                ColorStop::new(0.5, Color::green()),
2300                ColorStop::new(1.0, Color::blue()),
2301            ],
2302        );
2303        let dict = shading.to_pdf_dictionary().unwrap();
2304        let func = match dict.get("Function") {
2305            Some(Object::Dictionary(d)) => d,
2306            other => panic!("/Function must be a dictionary, got {other:?}"),
2307        };
2308        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(3)));
2309        assert_eq!(
2310            func.get("Bounds"),
2311            Some(&Object::Array(vec![Object::Real(0.5)])),
2312            "interior stop position is the only bound"
2313        );
2314        assert_eq!(
2315            func.get("Encode"),
2316            Some(&Object::Array(vec![
2317                Object::Real(0.0),
2318                Object::Real(1.0),
2319                Object::Real(0.0),
2320                Object::Real(1.0),
2321            ]))
2322        );
2323        let subfuncs = match func.get("Functions") {
2324            Some(Object::Array(a)) => a,
2325            other => panic!("/Functions must be an array, got {other:?}"),
2326        };
2327        assert_eq!(subfuncs.len(), 2, "two segments for three stops");
2328        // First subfunction red→green.
2329        let f0 = match &subfuncs[0] {
2330            Object::Dictionary(d) => d,
2331            other => panic!("subfunction 0 not a dict: {other:?}"),
2332        };
2333        let (c0, c1) = type2_c0_c1(f0);
2334        assert_eq!(c0, vec![1.0, 0.0, 0.0]);
2335        assert_eq!(c1, vec![0.0, 1.0, 0.0]);
2336    }
2337
2338    #[test]
2339    fn test_axial_gray_stops_emit_devicegray_function() {
2340        // Uniform Gray stops must keep DeviceGray (1 component), not promote to RGB.
2341        let shading = AxialShading::linear_gradient(
2342            "G".to_string(),
2343            Point::new(0.0, 0.0),
2344            Point::new(10.0, 0.0),
2345            Color::black(),
2346            Color::white(),
2347        );
2348        let dict = shading.to_pdf_dictionary().unwrap();
2349        assert_eq!(
2350            dict.get("ColorSpace"),
2351            Some(&Object::Name("DeviceGray".to_string()))
2352        );
2353        let func = match dict.get("Function") {
2354            Some(Object::Dictionary(d)) => d,
2355            other => panic!("/Function must be a dictionary, got {other:?}"),
2356        };
2357        let (c0, c1) = type2_c0_c1(func);
2358        assert_eq!(c0, vec![0.0], "black");
2359        assert_eq!(c1, vec![1.0], "white");
2360    }
2361
2362    #[test]
2363    fn test_axial_cmyk_stops_emit_devicecmyk_function() {
2364        // Uniform CMYK stops keep DeviceCMYK with 4-component C0/C1.
2365        let shading = AxialShading::linear_gradient(
2366            "G".to_string(),
2367            Point::new(0.0, 0.0),
2368            Point::new(10.0, 0.0),
2369            Color::Cmyk(1.0, 0.0, 0.0, 0.0),
2370            Color::Cmyk(0.0, 1.0, 0.0, 0.0),
2371        );
2372        let dict = shading.to_pdf_dictionary().unwrap();
2373        assert_eq!(
2374            dict.get("ColorSpace"),
2375            Some(&Object::Name("DeviceCMYK".to_string()))
2376        );
2377        let func = match dict.get("Function") {
2378            Some(Object::Dictionary(d)) => d,
2379            other => panic!("/Function must be a dictionary, got {other:?}"),
2380        };
2381        let (c0, c1) = type2_c0_c1(func);
2382        assert_eq!(c0, vec![1.0, 0.0, 0.0, 0.0], "C0 = cyan, 4 components");
2383        assert_eq!(c1, vec![0.0, 1.0, 0.0, 0.0], "C1 = magenta, 4 components");
2384    }
2385
2386    #[test]
2387    fn test_axial_four_stops_type3_has_three_subfunctions_two_bounds() {
2388        let shading = AxialShading::new(
2389            "G".to_string(),
2390            Point::new(0.0, 0.0),
2391            Point::new(100.0, 0.0),
2392            vec![
2393                ColorStop::new(0.0, Color::red()),
2394                ColorStop::new(0.3, Color::green()),
2395                ColorStop::new(0.7, Color::blue()),
2396                ColorStop::new(1.0, Color::white()),
2397            ],
2398        );
2399        let dict = shading.to_pdf_dictionary().unwrap();
2400        let func = match dict.get("Function") {
2401            Some(Object::Dictionary(d)) => d,
2402            other => panic!("/Function must be a dictionary, got {other:?}"),
2403        };
2404        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(3)));
2405        let subfuncs = match func.get("Functions") {
2406            Some(Object::Array(a)) => a,
2407            other => panic!("/Functions array expected, got {other:?}"),
2408        };
2409        assert_eq!(subfuncs.len(), 3, "4 stops → 3 segments");
2410        assert_eq!(
2411            func.get("Bounds"),
2412            Some(&Object::Array(vec![Object::Real(0.3), Object::Real(0.7)])),
2413            "two interior bounds at the middle stops"
2414        );
2415        assert_eq!(
2416            func.get("Encode"),
2417            Some(&Object::Array(vec![
2418                Object::Real(0.0),
2419                Object::Real(1.0),
2420                Object::Real(0.0),
2421                Object::Real(1.0),
2422                Object::Real(0.0),
2423                Object::Real(1.0),
2424            ]))
2425        );
2426    }
2427
2428    #[test]
2429    fn test_single_stop_emits_constant_type2() {
2430        // A lone stop is valid (validate() only rejects empty) → constant colour.
2431        let shading = AxialShading::new(
2432            "G".to_string(),
2433            Point::new(0.0, 0.0),
2434            Point::new(10.0, 0.0),
2435            vec![ColorStop::new(0.0, Color::Rgb(0.2, 0.4, 0.6))],
2436        );
2437        let func = match shading.to_pdf_dictionary().unwrap().get("Function") {
2438            Some(Object::Dictionary(d)) => d.clone(),
2439            other => panic!("/Function must be a dictionary, got {other:?}"),
2440        };
2441        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(2)));
2442        let (c0, c1) = type2_c0_c1(&func);
2443        assert_eq!(c0, c1, "constant colour: C0 == C1");
2444        assert_eq!(c0, vec![0.2, 0.4, 0.6]);
2445    }
2446
2447    #[test]
2448    fn test_mixed_color_spaces_promote_to_rgb() {
2449        // Mixing Gray and RGB stops must promote the whole shading to DeviceRGB.
2450        let shading = AxialShading::new(
2451            "G".to_string(),
2452            Point::new(0.0, 0.0),
2453            Point::new(10.0, 0.0),
2454            vec![
2455                ColorStop::new(0.0, Color::Gray(0.5)),
2456                ColorStop::new(1.0, Color::Rgb(1.0, 0.0, 0.0)),
2457            ],
2458        );
2459        let dict = shading.to_pdf_dictionary().unwrap();
2460        assert_eq!(
2461            dict.get("ColorSpace"),
2462            Some(&Object::Name("DeviceRGB".to_string()))
2463        );
2464        let func = match dict.get("Function") {
2465            Some(Object::Dictionary(d)) => d,
2466            other => panic!("/Function must be a dictionary, got {other:?}"),
2467        };
2468        let (c0, c1) = type2_c0_c1(func);
2469        assert_eq!(c0, vec![0.5, 0.5, 0.5], "gray 0.5 promoted to RGB");
2470        assert_eq!(c1, vec![1.0, 0.0, 0.0]);
2471    }
2472
2473    #[test]
2474    fn test_radial_emits_real_function_and_colorspace() {
2475        let center = Point::new(50.0, 50.0);
2476        let shading = RadialShading::radial_gradient(
2477            "R".to_string(),
2478            center,
2479            0.0,
2480            25.0,
2481            Color::cyan(),
2482            Color::magenta(),
2483        );
2484        let dict = shading.to_pdf_dictionary().unwrap();
2485        assert_eq!(
2486            dict.get("ColorSpace"),
2487            Some(&Object::Name("DeviceRGB".to_string()))
2488        );
2489        let func = match dict.get("Function") {
2490            Some(Object::Dictionary(d)) => d,
2491            other => panic!("/Function must be a dictionary, got {other:?}"),
2492        };
2493        assert_eq!(func.get("FunctionType"), Some(&Object::Integer(2)));
2494    }
2495
2496    #[test]
2497    fn test_shading_pattern_inlines_real_shading_not_placeholder() {
2498        // Issue #297 C: /Shading must be the real shading dict, never Integer(1).
2499        let axial = AxialShading::linear_gradient(
2500            "P".to_string(),
2501            Point::new(0.0, 0.0),
2502            Point::new(100.0, 0.0),
2503            Color::red(),
2504            Color::blue(),
2505        );
2506        let pattern = ShadingPattern::new("SP1".to_string(), ShadingDefinition::Axial(axial));
2507        let dict = pattern.to_pdf_pattern_dictionary().unwrap();
2508        assert_eq!(dict.get("PatternType"), Some(&Object::Integer(2)));
2509        let shading = match dict.get("Shading") {
2510            Some(Object::Dictionary(d)) => d,
2511            other => panic!("/Shading must be an inline dict, got {other:?}"),
2512        };
2513        assert_eq!(shading.get("ShadingType"), Some(&Object::Integer(2)));
2514        assert!(
2515            matches!(shading.get("Function"), Some(Object::Dictionary(_))),
2516            "inlined shading must carry a real /Function"
2517        );
2518    }
2519
2520    #[test]
2521    fn test_radial_shading_pdf_dictionary() {
2522        let center = Point::new(50.0, 50.0);
2523        let shading = RadialShading::radial_gradient(
2524            "TestRadialPDF".to_string(),
2525            center,
2526            10.0,
2527            30.0,
2528            Color::yellow(),
2529            Color::red(),
2530        );
2531
2532        let dict = shading.to_pdf_dictionary().unwrap();
2533
2534        if let Some(Object::Integer(shading_type)) = dict.get("ShadingType") {
2535            assert_eq!(*shading_type, 3); // Radial type
2536        }
2537
2538        if let Some(Object::Array(coords)) = dict.get("Coords") {
2539            assert_eq!(coords.len(), 6); // [x0 y0 r0 x1 y1 r1]
2540        }
2541    }
2542}