Skip to main content

stet_pdf_reader/resources/
shading.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! Shading (sh operator) → DisplayElement conversion.
6
7use crate::content::color_space::{
8    ResolvedColorSpace, components_to_device_color_icc, painted_channels_for_cs,
9    resolve_color_space_obj,
10};
11use crate::content::graphics_state::PdfGraphicsState;
12use crate::error::PdfError;
13use crate::objects::{PdfDict, PdfObj};
14use crate::resolver::Resolver;
15use crate::resources::function::PdfFunction;
16use std::sync::Arc;
17
18use stet_fonts::geometry::Matrix;
19use stet_graphics::device::{
20    AxialShadingParams, ColorStop, ImageColorSpace, ImageParams, MeshShadingParams,
21    PatchShadingParams, RadialShadingParams, ShadingColorSpace, SimpleColorSpace, SpotTintFunction,
22};
23use stet_graphics::display_list::{DisplayElement, DisplayList};
24use stet_graphics::icc::IccCache;
25
26/// Handle the `sh` operator: parse shading dict and emit display element.
27///
28/// `shading_obj` is the original PdfObj (needed for stream access in types 4-7).
29pub fn handle_shading(
30    shading_obj: &PdfObj,
31    dict: &PdfDict,
32    gstate: &PdfGraphicsState,
33    resolver: &Resolver,
34    display_list: &mut DisplayList,
35    icc_cache: &mut IccCache,
36) -> Result<(), PdfError> {
37    let shading_type =
38        dict.get_int(b"ShadingType")
39            .ok_or(PdfError::Other("shading missing ShadingType".into()))? as i32;
40
41    let bbox = parse_bbox(dict);
42    let extend = parse_extend(dict);
43
44    // Resolve the color space once, used by all shading types
45    let resolved_cs = resolve_shading_resolved_cs(dict, resolver);
46
47    // Background color: fill the entire paint area before the gradient (PDF spec 8.7.4.5.2).
48    // The caller clips the shading to the fill path, so a large rect is fine.
49    if let Some(bg_arr) = dict.get_array(b"Background") {
50        let comps: Vec<f64> = bg_arr.iter().filter_map(|o| o.as_f64()).collect();
51        let bg_color = components_to_device_color_icc(&resolved_cs, &comps, Some(icc_cache));
52        let mut params = gstate.fill_params(stet_graphics::color::FillRule::NonZeroWinding);
53        params.color = bg_color;
54        // Large rect in device space — the shading's clip constrains it
55        let mut path = stet_fonts::geometry::PsPath::new();
56        path.segments
57            .push(stet_fonts::geometry::PathSegment::MoveTo(-1e6, -1e6));
58        path.segments
59            .push(stet_fonts::geometry::PathSegment::LineTo(1e6, -1e6));
60        path.segments
61            .push(stet_fonts::geometry::PathSegment::LineTo(1e6, 1e6));
62        path.segments
63            .push(stet_fonts::geometry::PathSegment::LineTo(-1e6, 1e6));
64        path.segments
65            .push(stet_fonts::geometry::PathSegment::ClosePath);
66        display_list.push(DisplayElement::Fill { path, params });
67    }
68
69    match shading_type {
70        1 => handle_function_based(
71            dict,
72            gstate,
73            resolver,
74            display_list,
75            &resolved_cs,
76            icc_cache,
77        ),
78        2 => handle_axial(
79            dict,
80            gstate,
81            resolver,
82            display_list,
83            bbox,
84            extend,
85            &resolved_cs,
86            icc_cache,
87        ),
88        3 => handle_radial(
89            dict,
90            gstate,
91            resolver,
92            display_list,
93            bbox,
94            extend,
95            &resolved_cs,
96            icc_cache,
97        ),
98        4 | 5 => handle_mesh(
99            shading_obj,
100            dict,
101            gstate,
102            resolver,
103            display_list,
104            shading_type,
105            &resolved_cs,
106            icc_cache,
107        ),
108        6 | 7 => handle_patches(
109            shading_obj,
110            dict,
111            gstate,
112            resolver,
113            display_list,
114            shading_type,
115            &resolved_cs,
116            icc_cache,
117        ),
118        _ => Ok(()),
119    }
120}
121
122fn handle_function_based(
123    dict: &PdfDict,
124    gstate: &PdfGraphicsState,
125    resolver: &Resolver,
126    display_list: &mut DisplayList,
127    resolved_cs: &ResolvedColorSpace,
128    icc_cache: &mut IccCache,
129) -> Result<(), PdfError> {
130    let function = parse_shading_function(dict, resolver)?;
131
132    let domain = dict
133        .get_array(b"Domain")
134        .map(|a| {
135            let v: Vec<f64> = a.iter().filter_map(|o| o.as_f64()).collect();
136            if v.len() >= 4 {
137                [v[0], v[1], v[2], v[3]]
138            } else {
139                [0.0, 1.0, 0.0, 1.0]
140            }
141        })
142        .unwrap_or([0.0, 1.0, 0.0, 1.0]);
143
144    let shading_matrix = dict
145        .get_array(b"Matrix")
146        .map(|a| {
147            let v: Vec<f64> = a.iter().filter_map(|o| o.as_f64()).collect();
148            if v.len() >= 6 {
149                Matrix::new(v[0], v[1], v[2], v[3], v[4], v[5])
150            } else {
151                Matrix::identity()
152            }
153        })
154        .unwrap_or_else(Matrix::identity);
155
156    let domain_w = domain[1] - domain[0];
157    let domain_h = domain[3] - domain[2];
158    let domain_matrix = Matrix::new(domain_w, 0.0, 0.0, domain_h, domain[0], domain[2]);
159    let combined = gstate.ctm.concat(&shading_matrix).concat(&domain_matrix);
160
161    // Compute rasterization resolution from device-space dimensions.
162    // The combined matrix column vectors give the device extent of the
163    // unit square.  Match that so each rasterized pixel ≈ 1 device pixel.
164    let dev_w = (combined.a * combined.a + combined.b * combined.b).sqrt();
165    let dev_h = (combined.c * combined.c + combined.d * combined.d).sqrt();
166    let width = (dev_w.ceil() as u32).clamp(2, 2048);
167    let height = (dev_h.ceil() as u32).clamp(2, 2048);
168
169    let mut rgba = vec![255u8; (width * height * 4) as usize];
170
171    for row in 0..height {
172        for col in 0..width {
173            let x = domain[0] + (col as f64 + 0.5) / width as f64 * (domain[1] - domain[0]);
174            let y = domain[3] - (row as f64 + 0.5) / height as f64 * (domain[3] - domain[2]);
175            let components = function.evaluate(&[x, y]);
176            let color = components_to_device_color_icc(resolved_cs, &components, Some(icc_cache));
177            let idx = ((row * width + col) * 4) as usize;
178            rgba[idx] = (color.r * 255.0 + 0.5) as u8;
179            rgba[idx + 1] = (color.g * 255.0 + 0.5) as u8;
180            rgba[idx + 2] = (color.b * 255.0 + 0.5) as u8;
181        }
182    }
183
184    let image_matrix = Matrix::new(width as f64, 0.0, 0.0, -(height as f64), 0.0, height as f64);
185
186    display_list.push(DisplayElement::Image {
187        sample_data: std::sync::Arc::new(rgba),
188        params: ImageParams {
189            width,
190            height,
191            color_space: ImageColorSpace::PreconvertedRGBA,
192            bits_per_component: 8,
193            ctm: combined,
194            image_matrix,
195            interpolate: true,
196            mask_color: None,
197            alpha: 1.0,
198            blend_mode: 0,
199            overprint: false,
200            overprint_mode: 0,
201            opm_paired: false,
202            painted_channels: 0,
203            alpha_is_shape: false,
204            rendering_intent: 0,
205        },
206    });
207    Ok(())
208}
209
210#[allow(clippy::too_many_arguments)]
211fn handle_axial(
212    dict: &PdfDict,
213    gstate: &PdfGraphicsState,
214    resolver: &Resolver,
215    display_list: &mut DisplayList,
216    bbox: Option<[f64; 4]>,
217    extend: (bool, bool),
218    resolved_cs: &ResolvedColorSpace,
219    icc_cache: &mut IccCache,
220) -> Result<(), PdfError> {
221    let coords = dict
222        .get_array(b"Coords")
223        .ok_or(PdfError::Other("axial shading missing Coords".into()))?;
224    let vals: Vec<f64> = coords.iter().filter_map(|o| o.as_f64()).collect();
225    if vals.len() < 4 {
226        return Err(PdfError::Other("axial Coords needs 4 values".into()));
227    }
228
229    let function = parse_shading_function(dict, resolver)?;
230    let n_stops = function.min_samples().max(64).min(1024);
231    let color_stops = sample_function_to_stops_icc(&function, n_stops, resolved_cs, icc_cache);
232
233    // Keep coordinates in shading/user space, pass the CTM to the renderer.
234    // The renderer inverse-transforms device pixels to evaluate the gradient,
235    // correctly handling non-uniform scaling, rotation, and Y-flips.
236    let cs = resolved_cs_to_shading_cs(resolved_cs);
237
238    display_list.push(DisplayElement::AxialShading {
239        params: AxialShadingParams {
240            x0: vals[0],
241            y0: vals[1],
242            x1: vals[2],
243            y1: vals[3],
244            color_stops,
245            extend_start: extend.0,
246            extend_end: extend.1,
247            ctm: gstate.ctm,
248            bbox,
249            color_space: cs,
250            overprint: gstate.overprint,
251            overprint_mode: gstate.overprint_mode,
252            painted_channels: painted_channels_for_cs(resolved_cs),
253            alpha: gstate.fill_alpha,
254            blend_mode: gstate.blend_mode,
255            alpha_is_shape: gstate.alpha_is_shape,
256            spot_tint_blend: cs_has_spot_with_cmyk_alt(resolved_cs),
257        },
258    });
259    Ok(())
260}
261
262#[allow(clippy::too_many_arguments)]
263fn handle_radial(
264    dict: &PdfDict,
265    gstate: &PdfGraphicsState,
266    resolver: &Resolver,
267    display_list: &mut DisplayList,
268    bbox: Option<[f64; 4]>,
269    extend: (bool, bool),
270    resolved_cs: &ResolvedColorSpace,
271    icc_cache: &mut IccCache,
272) -> Result<(), PdfError> {
273    let coords = dict
274        .get_array(b"Coords")
275        .ok_or(PdfError::Other("radial shading missing Coords".into()))?;
276    let vals: Vec<f64> = coords.iter().filter_map(|o| o.as_f64()).collect();
277    if vals.len() < 6 {
278        return Err(PdfError::Other("radial Coords needs 6 values".into()));
279    }
280
281    let function = parse_shading_function(dict, resolver)?;
282    let n_stops = function.min_samples().max(64).min(1024);
283    let color_stops = sample_function_to_stops_icc(&function, n_stops, resolved_cs, icc_cache);
284
285    // Keep coordinates in user space; pass the CTM to the renderer so it can
286    // inverse-transform device pixels back to user space where circles are circular.
287    // This correctly handles non-uniform scaling and shear (circles → ellipses).
288    // BBox stays in user space too — the renderer transforms it via the CTM.
289    let cs = resolved_cs_to_shading_cs(resolved_cs);
290
291    display_list.push(DisplayElement::RadialShading {
292        params: RadialShadingParams {
293            x0: vals[0],
294            y0: vals[1],
295            r0: vals[2],
296            x1: vals[3],
297            y1: vals[4],
298            r1: vals[5],
299            color_stops,
300            extend_start: extend.0,
301            extend_end: extend.1,
302            ctm: gstate.ctm,
303            bbox,
304            color_space: cs,
305            overprint: gstate.overprint,
306            overprint_mode: gstate.overprint_mode,
307            painted_channels: painted_channels_for_cs(resolved_cs),
308            alpha: gstate.fill_alpha,
309            blend_mode: gstate.blend_mode,
310            alpha_is_shape: gstate.alpha_is_shape,
311            spot_tint_blend: cs_has_spot_with_cmyk_alt(resolved_cs),
312        },
313    });
314    Ok(())
315}
316
317/// True when a resolved color space is Separation/DeviceN with a CMYK
318/// alternate AND at least one non-process spot colorant.  See
319/// [`AxialShadingParams::spot_tint_blend`].
320fn cs_has_spot_with_cmyk_alt(cs: &ResolvedColorSpace) -> bool {
321    use stet_graphics::device::cmyk_channel_for_name;
322    match cs {
323        ResolvedColorSpace::Separation { name, alt, .. } => {
324            cmyk_channel_for_name(name) == 0
325                && matches!(alt.as_ref(), ResolvedColorSpace::DeviceCMYK)
326        }
327        ResolvedColorSpace::DeviceN { names, alt, .. } => {
328            matches!(alt.as_ref(), ResolvedColorSpace::DeviceCMYK)
329                && names.iter().any(|n| cmyk_channel_for_name(n) == 0)
330        }
331        _ => false,
332    }
333}
334
335#[allow(clippy::too_many_arguments)]
336fn handle_mesh(
337    shading_obj: &PdfObj,
338    dict: &PdfDict,
339    gstate: &PdfGraphicsState,
340    resolver: &Resolver,
341    display_list: &mut DisplayList,
342    shading_type: i32,
343    resolved_cs: &ResolvedColorSpace,
344    icc_cache: &mut IccCache,
345) -> Result<(), PdfError> {
346    let bpc = dict.get_int(b"BitsPerCoordinate").unwrap_or(8) as usize;
347    let bpco = dict.get_int(b"BitsPerComponent").unwrap_or(8) as usize;
348    let bpfl = dict.get_int(b"BitsPerFlag").unwrap_or(8) as usize;
349
350    let decode = dict
351        .get_array(b"Decode")
352        .map(|a| a.iter().filter_map(|o| o.as_f64()).collect::<Vec<_>>())
353        .unwrap_or_default();
354
355    let cs = resolved_cs_to_shading_cs(resolved_cs);
356    // Use the resolved color space's component count for parsing vertex data.
357    // For Indexed this is 1 (the palette index), even though the shading CS
358    // (after palette expansion) may have 3 or 4 components.
359    let cs_comps = resolved_cs.num_components();
360
361    // When a Function is present, vertex data has fewer color components per vertex —
362    // the function's input dimension, not the color space dimension. Parse with the
363    // function input count, then apply the function to expand to full color values.
364    let function = if dict.get(b"Function").is_some() {
365        parse_shading_function(dict, resolver).ok()
366    } else {
367        None
368    };
369    let n_comps = if function.is_some() {
370        // Function input dimension: inferred from Decode array (entries beyond the 4
371        // coordinate entries, each pair is one component)
372        let color_entries = decode.len().saturating_sub(4);
373        (color_entries / 2).max(1)
374    } else {
375        cs_comps
376    };
377
378    let data = resolver.stream_data_from_obj(shading_obj)?;
379
380    let mut triangles = match shading_type {
381        4 => {
382            stet_graphics::mesh_shading::parse_type4_mesh(&data, bpc, bpco, bpfl, &decode, n_comps)
383        }
384        5 => {
385            let vpr = dict.get_int(b"VerticesPerRow").unwrap_or(2) as usize;
386            stet_graphics::mesh_shading::parse_type5_mesh(&data, bpc, bpco, &decode, n_comps, vpr)
387        }
388        _ => return Ok(()),
389    };
390
391    // Build per-pixel color LUT for single-input function-based meshes.
392    // PDF spec says vertex colors are linearly interpolated, but for non-linear
393    // functions (e.g., stitching with thresholds), interpolating raw function
394    // inputs per-pixel then applying the function produces correct results.
395    let color_lut = if let Some(ref func) = function {
396        if n_comps == 1 {
397            // Get the color decode range (the last pair in the Decode array)
398            let d_min = decode.get(4).copied().unwrap_or(0.0);
399            let d_max = decode.get(5).copied().unwrap_or(1.0);
400            let d_range = (d_max - d_min).abs().max(1e-10);
401
402            // Sample the function at 256 evenly-spaced points
403            let lut_size = 256;
404            let mut lut = Vec::with_capacity(lut_size);
405            for i in 0..lut_size {
406                let t = i as f64 / (lut_size - 1) as f64;
407                let input = d_min + t * (d_max - d_min);
408                let components = func.evaluate(&[input]);
409                let color =
410                    components_to_device_color_icc(resolved_cs, &components, Some(icc_cache));
411                lut.push(color);
412            }
413
414            // Normalize vertex raw values to [0, 1] for LUT indexing
415            for t in &mut triangles {
416                for v in [&mut t.v0, &mut t.v1, &mut t.v2] {
417                    let raw = v.raw_components[0];
418                    let normalized = ((raw - d_min) / d_range).clamp(0.0, 1.0);
419                    v.raw_components = vec![normalized];
420                }
421            }
422
423            Some(std::sync::Arc::new(lut))
424        } else {
425            None
426        }
427    } else {
428        None
429    };
430
431    // Apply shading function to expand vertex colors (for vertex DeviceColor
432    // and for renderers that don't use the LUT path)
433    if let Some(ref func) = function {
434        if color_lut.is_some() {
435            // LUT path: evaluate function at each vertex's normalized raw value
436            // to populate vertex colors (needed by PDF output device)
437            let d_min = decode.get(4).copied().unwrap_or(0.0);
438            let d_max = decode.get(5).copied().unwrap_or(1.0);
439            for t in &mut triangles {
440                for v in [&mut t.v0, &mut t.v1, &mut t.v2] {
441                    let input = d_min + v.raw_components[0] * (d_max - d_min);
442                    let expanded = func.evaluate(&[input]);
443                    let color =
444                        components_to_device_color_icc(resolved_cs, &expanded, Some(icc_cache));
445                    v.color = color;
446                }
447            }
448        } else {
449            for t in &mut triangles {
450                t.v0.raw_components = func.evaluate(&t.v0.raw_components);
451                t.v1.raw_components = func.evaluate(&t.v1.raw_components);
452                t.v2.raw_components = func.evaluate(&t.v2.raw_components);
453            }
454        }
455    }
456
457    if color_lut.is_none() {
458        // Convert vertex colors through ICC profile (non-LUT path)
459        for t in &mut triangles {
460            t.v0.color =
461                components_to_device_color_icc(resolved_cs, &t.v0.raw_components, Some(icc_cache));
462            t.v1.color =
463                components_to_device_color_icc(resolved_cs, &t.v1.raw_components, Some(icc_cache));
464            t.v2.color =
465                components_to_device_color_icc(resolved_cs, &t.v2.raw_components, Some(icc_cache));
466        }
467    }
468
469    // Transform vertices through CTM
470    for t in &mut triangles {
471        let (x, y) = gstate.ctm.transform_point(t.v0.x, t.v0.y);
472        t.v0.x = x;
473        t.v0.y = y;
474        let (x, y) = gstate.ctm.transform_point(t.v1.x, t.v1.y);
475        t.v1.x = x;
476        t.v1.y = y;
477        let (x, y) = gstate.ctm.transform_point(t.v2.x, t.v2.y);
478        t.v2.x = x;
479        t.v2.y = y;
480    }
481
482    let bbox = parse_bbox(dict);
483    let device_bbox = transform_bbox(&bbox, &gstate.ctm);
484
485    display_list.push(DisplayElement::MeshShading {
486        params: MeshShadingParams {
487            triangles,
488            ctm: Matrix::identity(),
489            bbox: device_bbox,
490            color_space: cs,
491            overprint: gstate.overprint,
492            overprint_mode: gstate.overprint_mode,
493            painted_channels: painted_channels_for_cs(resolved_cs),
494            color_lut,
495            alpha: gstate.fill_alpha,
496            blend_mode: gstate.blend_mode,
497            alpha_is_shape: gstate.alpha_is_shape,
498        },
499    });
500    Ok(())
501}
502
503#[allow(clippy::too_many_arguments)]
504fn handle_patches(
505    shading_obj: &PdfObj,
506    dict: &PdfDict,
507    gstate: &PdfGraphicsState,
508    resolver: &Resolver,
509    display_list: &mut DisplayList,
510    shading_type: i32,
511    resolved_cs: &ResolvedColorSpace,
512    icc_cache: &mut IccCache,
513) -> Result<(), PdfError> {
514    let bpc = dict.get_int(b"BitsPerCoordinate").unwrap_or(8) as usize;
515    let bpco = dict.get_int(b"BitsPerComponent").unwrap_or(8) as usize;
516    let bpfl = dict.get_int(b"BitsPerFlag").unwrap_or(8) as usize;
517
518    let decode = dict
519        .get_array(b"Decode")
520        .map(|a| a.iter().filter_map(|o| o.as_f64()).collect::<Vec<_>>())
521        .unwrap_or_default();
522
523    let cs = resolved_cs_to_shading_cs(resolved_cs);
524    // Use the resolved color space's component count for parsing vertex data.
525    // For Indexed this is 1 (the palette index), even though the shading CS
526    // (after palette expansion) may have 3 or 4 components.
527    let cs_comps = resolved_cs.num_components();
528
529    let function = if dict.get(b"Function").is_some() {
530        parse_shading_function(dict, resolver).ok()
531    } else {
532        None
533    };
534    let n_comps = if function.is_some() {
535        let color_entries = decode.len().saturating_sub(4);
536        (color_entries / 2).max(1)
537    } else {
538        cs_comps
539    };
540
541    let data = resolver.stream_data_from_obj(shading_obj)?;
542
543    let mut patches = match shading_type {
544        6 => stet_graphics::mesh_shading::parse_type6_patches(
545            &data, bpc, bpco, bpfl, &decode, n_comps,
546        ),
547        7 => stet_graphics::mesh_shading::parse_type7_patches(
548            &data, bpc, bpco, bpfl, &decode, n_comps,
549        ),
550        _ => return Ok(()),
551    };
552
553    // For single-input function-based patches, build a per-pixel LUT
554    // (matching the mesh shading approach) so non-linear functions (e.g. N=3)
555    // produce correct color transitions.  Corner raw_colors keep the
556    // normalized function input for bilinear interpolation in the renderer.
557    let color_lut = if let Some(ref func) = function {
558        if n_comps == 1 {
559            let d_min = decode.get(4).copied().unwrap_or(0.0);
560            let d_max = decode.get(5).copied().unwrap_or(1.0);
561            let d_range = (d_max - d_min).abs().max(1e-10);
562
563            let lut_size = 256;
564            let mut lut = Vec::with_capacity(lut_size);
565            for i in 0..lut_size {
566                let t = i as f64 / (lut_size - 1) as f64;
567                let input = d_min + t * (d_max - d_min);
568                let components = func.evaluate(&[input]);
569                let color =
570                    components_to_device_color_icc(resolved_cs, &components, Some(icc_cache));
571                lut.push(color);
572            }
573
574            // Normalize vertex raw values to [0, 1] for LUT indexing
575            for p in &mut patches {
576                for i in 0..4 {
577                    let raw = p.raw_colors[i][0];
578                    let normalized = ((raw - d_min) / d_range).clamp(0.0, 1.0);
579                    p.raw_colors[i] = vec![normalized];
580                    // Set corner color from LUT for fallback rendering
581                    let idx = (normalized * 255.0).round() as usize;
582                    p.colors[i] = lut[idx.min(255)].clone();
583                }
584            }
585            Some(std::sync::Arc::new(lut))
586        } else {
587            // Multi-input function: apply at corners only
588            for p in &mut patches {
589                for i in 0..4 {
590                    p.raw_colors[i] = func.evaluate(&p.raw_colors[i]);
591                }
592            }
593            for p in &mut patches {
594                for i in 0..4 {
595                    p.colors[i] = components_to_device_color_icc(
596                        resolved_cs,
597                        &p.raw_colors[i],
598                        Some(icc_cache),
599                    );
600                }
601            }
602            None
603        }
604    } else {
605        // No function: convert direct corner colors through ICC
606        for p in &mut patches {
607            for i in 0..4 {
608                p.colors[i] =
609                    components_to_device_color_icc(resolved_cs, &p.raw_colors[i], Some(icc_cache));
610            }
611        }
612        None
613    };
614
615    // Transform patch control points through CTM
616    for p in &mut patches {
617        for pt in &mut p.points {
618            let (x, y) = gstate.ctm.transform_point(pt.0, pt.1);
619            pt.0 = x;
620            pt.1 = y;
621        }
622    }
623
624    let bbox = parse_bbox(dict);
625    let device_bbox = transform_bbox(&bbox, &gstate.ctm);
626
627    display_list.push(DisplayElement::PatchShading {
628        params: PatchShadingParams {
629            patches,
630            ctm: Matrix::identity(),
631            bbox: device_bbox,
632            color_space: cs,
633            overprint: gstate.overprint,
634            overprint_mode: gstate.overprint_mode,
635            painted_channels: painted_channels_for_cs(resolved_cs),
636            color_lut,
637            alpha: gstate.fill_alpha,
638            blend_mode: gstate.blend_mode,
639            alpha_is_shape: gstate.alpha_is_shape,
640        },
641    });
642    Ok(())
643}
644
645fn parse_shading_function(dict: &PdfDict, resolver: &Resolver) -> Result<PdfFunction, PdfError> {
646    let fn_obj = dict
647        .get(b"Function")
648        .ok_or(PdfError::Other("shading missing Function".into()))?;
649    let fn_obj = resolver.deref(fn_obj)?;
650    // Handle /Function null (invalid but seen in the wild)
651    if matches!(fn_obj, PdfObj::Null) {
652        return Err(PdfError::Other("shading Function is null".into()));
653    }
654    if let PdfObj::Array(arr) = &fn_obj {
655        if arr.len() == 1 {
656            return PdfFunction::parse(&arr[0], resolver);
657        }
658        // Array of N functions: each produces 1 output component.
659        // Combine into a composite that concatenates all outputs.
660        // This is common for DeviceCMYK shadings (4 functions → 4 components).
661        if arr.len() > 1 {
662            let mut funcs = Vec::with_capacity(arr.len());
663            for item in arr {
664                funcs.push(PdfFunction::parse(item, resolver)?);
665            }
666            return Ok(PdfFunction::composite(funcs));
667        }
668    }
669    PdfFunction::parse(&fn_obj, resolver)
670}
671
672fn sample_function_to_stops_icc(
673    function: &PdfFunction,
674    n_samples: usize,
675    resolved_cs: &ResolvedColorSpace,
676    icc_cache: &mut IccCache,
677) -> Vec<ColorStop> {
678    // For Separation/DeviceN with DeviceCMYK alternate, extract the tint function
679    // so we can store tint-transformed CMYK values in raw_components (needed for
680    // overprint CMYK buffer tracking).
681    let cmyk_tint_fn = match resolved_cs {
682        ResolvedColorSpace::Separation { alt, tint_fn, .. }
683        | ResolvedColorSpace::DeviceN { alt, tint_fn, .. }
684            if matches!(**alt, ResolvedColorSpace::DeviceCMYK) =>
685        {
686            tint_fn.as_ref()
687        }
688        _ => None,
689    };
690
691    let [d_min, d_max] = function.domain_0();
692    let span = d_max - d_min;
693
694    // Collect discontinuity positions and convert to normalized t in [0,1].
695    // At each discontinuity we insert two samples (before and at) to produce a sharp edge.
696    let disc_positions = function.discontinuity_positions();
697    let mut disc_ts: Vec<f64> = disc_positions
698        .iter()
699        .filter_map(|&d| {
700            if span.abs() < 1e-15 {
701                return None;
702            }
703            let t = (d - d_min) / span;
704            if t > 0.0 && t < 1.0 { Some(t) } else { None }
705        })
706        .collect();
707    // `total_cmp`, not `partial_cmp().unwrap()`: the filter above happens to
708    // exclude NaN today (a NaN `t` fails both `>` and `<`), so the unwrap is
709    // not currently reachable — but that makes this function's safety depend
710    // on a caller-side invariant, and `total_cmp` costs nothing to be right
711    // unconditionally.
712    disc_ts.sort_by(f64::total_cmp);
713    disc_ts.dedup_by(|a, b| (*a - *b).abs() < 1e-12);
714
715    // Build sample positions: uniform grid + discontinuity pairs.
716    //
717    // `n_samples - 1` is the divisor, so a caller passing 1 would produce
718    // `0.0 / 0.0` — a NaN that then poisons the sort and every stop position.
719    // Callers currently clamp to `[64, 1024]`; clamp here too so the
720    // guarantee is local.
721    let n_samples = n_samples.max(2);
722    let mut sample_ts: Vec<f64> = (0..n_samples)
723        .map(|i| i as f64 / (n_samples - 1) as f64)
724        .collect();
725    let eps = 1e-10;
726    for &dt in &disc_ts {
727        sample_ts.push((dt - eps).max(0.0));
728        sample_ts.push(dt);
729    }
730    sample_ts.sort_by(f64::total_cmp);
731    sample_ts.dedup_by(|a, b| (*a - *b).abs() < 1e-14);
732
733    let is_spot_with_cmyk_alt = cmyk_tint_fn.is_some();
734
735    let mut stops = Vec::with_capacity(sample_ts.len());
736    for t in sample_ts {
737        let input = d_min + t * span;
738        let components = function.evaluate(&[input]);
739        let color = components_to_device_color_icc(resolved_cs, &components, Some(icc_cache));
740
741        // For DeviceN/Separation with CMYK alternate, store the tint-transformed
742        // 4-component CMYK values so the renderer can populate the CMYK tracking buffer.
743        // Also retain the pre-transform `components` as `source_components` so the
744        // PDF writer can emit a /Function whose output dimension matches the
745        // shading's source color space (1 channel for Separation, N for DeviceN).
746        let (raw_components, source_components) = if let Some(tint) = cmyk_tint_fn {
747            let cmyk = tint.evaluate(&components);
748            let raw = if cmyk.len() >= 4 {
749                cmyk[..4].to_vec()
750            } else {
751                components.clone()
752            };
753            (raw, components)
754        } else if is_spot_with_cmyk_alt {
755            // unreachable but keeps the type checker happy
756            (components.clone(), components)
757        } else {
758            (components, Vec::new())
759        };
760
761        stops.push(ColorStop {
762            position: t,
763            color,
764            raw_components,
765            source_components,
766        });
767    }
768    stops
769}
770
771/// Transform a user-space BBox to device space via CTM.
772fn transform_bbox(bbox: &Option<[f64; 4]>, ctm: &Matrix) -> Option<[f64; 4]> {
773    bbox.map(|b| {
774        let corners = [
775            ctm.transform_point(b[0], b[1]),
776            ctm.transform_point(b[2], b[1]),
777            ctm.transform_point(b[0], b[3]),
778            ctm.transform_point(b[2], b[3]),
779        ];
780        let x_min = corners.iter().map(|c| c.0).fold(f64::INFINITY, f64::min);
781        let y_min = corners.iter().map(|c| c.1).fold(f64::INFINITY, f64::min);
782        let x_max = corners
783            .iter()
784            .map(|c| c.0)
785            .fold(f64::NEG_INFINITY, f64::max);
786        let y_max = corners
787            .iter()
788            .map(|c| c.1)
789            .fold(f64::NEG_INFINITY, f64::max);
790        [x_min, y_min, x_max, y_max]
791    })
792}
793
794fn parse_bbox(dict: &PdfDict) -> Option<[f64; 4]> {
795    dict.get_array(b"BBox").and_then(|arr| {
796        let vals: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
797        if vals.len() == 4 {
798            Some([vals[0], vals[1], vals[2], vals[3]])
799        } else {
800            None
801        }
802    })
803}
804
805fn parse_extend(dict: &PdfDict) -> (bool, bool) {
806    dict.get_array(b"Extend")
807        .and_then(|arr| {
808            if arr.len() == 2 {
809                let a = matches!(arr[0], PdfObj::Bool(true));
810                let b = matches!(arr[1], PdfObj::Bool(true));
811                Some((a, b))
812            } else {
813                None
814            }
815        })
816        .unwrap_or((false, false))
817}
818
819/// Resolve the shading's /ColorSpace to a ResolvedColorSpace.
820fn resolve_shading_resolved_cs(dict: &PdfDict, resolver: &Resolver) -> ResolvedColorSpace {
821    if let Some(cs_obj) = dict.get(b"ColorSpace")
822        && let Ok(resolved) = resolve_color_space_obj(cs_obj, resolver)
823    {
824        return resolved;
825    }
826    ResolvedColorSpace::DeviceRGB
827}
828
829/// Convert ResolvedColorSpace to ShadingColorSpace for the display list.
830/// ICCBased colors are already converted through the profile at stop/pixel level,
831/// so we map them to the equivalent device space for the renderer.
832fn resolved_cs_to_shading_cs(cs: &ResolvedColorSpace) -> ShadingColorSpace {
833    match cs {
834        ResolvedColorSpace::DeviceGray => ShadingColorSpace::DeviceGray,
835        ResolvedColorSpace::DeviceRGB => ShadingColorSpace::DeviceRGB,
836        ResolvedColorSpace::DeviceCMYK => ShadingColorSpace::DeviceCMYK,
837        // ICCBased: preserve profile info so the renderer can convert
838        // interpolated colors per-grid-point for accurate patch shading.
839        ResolvedColorSpace::ICCBased {
840            n,
841            profile_hash: Some(hash),
842            profile_data: Some(data),
843            ..
844        } if *n != 1 && *n != 4 => ShadingColorSpace::ICCBased {
845            n: *n as u32,
846            profile_hash: *hash,
847            profile_data: Arc::clone(data),
848        },
849        ResolvedColorSpace::ICCBased { n, .. } => match n {
850            1 => ShadingColorSpace::DeviceGray,
851            4 => ShadingColorSpace::DeviceCMYK,
852            _ => ShadingColorSpace::DeviceRGB,
853        },
854        // Indexed: use the base color space for the display list element
855        ResolvedColorSpace::Indexed { base, .. } => resolved_cs_to_shading_cs(base),
856        // Separation/DeviceN with DeviceCMYK alternate: preserve the spot
857        // identity so the PDF writer can round-trip the source's spot
858        // /ColorSpace and the re-read display list re-acquires
859        // `spot_tint_blend: true` for correct compositing.
860        ResolvedColorSpace::Separation { name, alt, tint_fn }
861            if matches!(**alt, ResolvedColorSpace::DeviceCMYK) =>
862        {
863            match tint_fn {
864                Some(tint) => ShadingColorSpace::Separation {
865                    name: name.clone(),
866                    alternate: SimpleColorSpace::DeviceCMYK,
867                    tint_function: sample_tint_function_1d(tint, 16),
868                },
869                None => ShadingColorSpace::DeviceCMYK,
870            }
871        }
872        ResolvedColorSpace::DeviceN {
873            names,
874            alt,
875            tint_fn,
876        } if matches!(**alt, ResolvedColorSpace::DeviceCMYK) => match tint_fn {
877            Some(tint) => ShadingColorSpace::DeviceN {
878                names: names.clone(),
879                alternate: SimpleColorSpace::DeviceCMYK,
880                tint_function: sample_tint_function_nd(tint, names.len(), 8),
881            },
882            None => ShadingColorSpace::DeviceCMYK,
883        },
884        _ => ShadingColorSpace::DeviceRGB,
885    }
886}
887
888/// Sample a 1-component tint function on a uniform grid in `[0, 1]` and
889/// pack the CMYK outputs into a `SpotTintFunction`. The writer emits this
890/// back as a `FunctionType 0` sampled function inside the shading's
891/// `/ColorSpace [/Separation … <tintFunc>]` array.
892fn sample_tint_function_1d(tint: &PdfFunction, samples_per_dim: usize) -> SpotTintFunction {
893    let mut cmyk_samples = Vec::with_capacity(samples_per_dim * 4);
894    for i in 0..samples_per_dim {
895        let t = i as f64 / (samples_per_dim - 1).max(1) as f64;
896        let out = tint.evaluate(&[t]);
897        for k in 0..4 {
898            cmyk_samples.push(out.get(k).copied().unwrap_or(0.0));
899        }
900    }
901    SpotTintFunction {
902        input_dim: 1,
903        samples_per_dim,
904        cmyk_samples: Arc::new(cmyk_samples),
905    }
906}
907
908/// Sample an N-component tint function on a uniform N-dimensional grid in
909/// `[0, 1]^N`. Output ordering is row-major with the first input axis
910/// varying slowest (matching PDF's SampledFunction convention).
911fn sample_tint_function_nd(
912    tint: &PdfFunction,
913    input_dim: usize,
914    samples_per_dim: usize,
915) -> SpotTintFunction {
916    let total = samples_per_dim.pow(input_dim as u32);
917    let mut cmyk_samples = Vec::with_capacity(total * 4);
918    let mut input = vec![0.0_f64; input_dim];
919    for idx in 0..total {
920        // Decompose `idx` into per-axis indices with first axis as slowest.
921        let mut rem = idx;
922        for axis in (0..input_dim).rev() {
923            let i = rem % samples_per_dim;
924            rem /= samples_per_dim;
925            input[axis] = i as f64 / (samples_per_dim - 1).max(1) as f64;
926        }
927        let out = tint.evaluate(&input);
928        for k in 0..4 {
929            cmyk_samples.push(out.get(k).copied().unwrap_or(0.0));
930        }
931    }
932    SpotTintFunction {
933        input_dim,
934        samples_per_dim,
935        cmyk_samples: Arc::new(cmyk_samples),
936    }
937}