Skip to main content

stet_pdf_reader/resources/
function.rs

1// stet-pdf-reader
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF Function evaluator (Types 0, 2, 3, 4).
6
7use crate::error::PdfError;
8use crate::objects::{PdfDict, PdfObj};
9use crate::resolver::Resolver;
10
11/// Maximum nesting depth when building a function from `/Functions`.
12///
13/// Bounds recursion through inline (non-reference) sub-function dictionaries,
14/// which the cycle guard in [`PdfFunction::parse_guarded`] cannot catch
15/// because they have no object number to track.
16const MAX_FUNCTION_DEPTH: u32 = 32;
17
18/// A parsed PDF function.
19#[derive(Clone, Debug)]
20pub enum PdfFunction {
21    /// Type 0: Sampled function.
22    Sampled {
23        domain: Vec<[f64; 2]>,
24        range: Vec<[f64; 2]>,
25        size: Vec<u32>,
26        bps: u32,
27        encode: Vec<[f64; 2]>,
28        decode: Vec<[f64; 2]>,
29        samples: Vec<f64>,
30        n_outputs: usize,
31    },
32    /// Type 2: Exponential interpolation.
33    Exponential {
34        domain: Vec<[f64; 2]>,
35        range: Vec<[f64; 2]>,
36        c0: Vec<f64>,
37        c1: Vec<f64>,
38        n: f64,
39    },
40    /// Type 3: Stitching function.
41    Stitching {
42        domain: Vec<[f64; 2]>,
43        range: Vec<[f64; 2]>,
44        functions: Vec<PdfFunction>,
45        bounds: Vec<f64>,
46        encode: Vec<[f64; 2]>,
47    },
48    /// Type 4: PostScript calculator.
49    Calculator {
50        domain: Vec<[f64; 2]>,
51        range: Vec<[f64; 2]>,
52        tokens: Vec<CalcToken>,
53    },
54    /// Array of functions whose outputs are concatenated.
55    /// Used when a shading's /Function is an array of per-component functions.
56    Composite { functions: Vec<PdfFunction> },
57}
58
59/// Token for Type 4 calculator functions.
60#[derive(Clone, Debug)]
61pub enum CalcToken {
62    Number(f64),
63    Bool(bool),
64    // Arithmetic
65    Add,
66    Sub,
67    Mul,
68    Div,
69    Idiv,
70    Mod,
71    Neg,
72    Abs,
73    Ceiling,
74    Floor,
75    Round,
76    Truncate,
77    Sqrt,
78    Exp,
79    Ln,
80    Log,
81    Sin,
82    Cos,
83    Atan,
84    // Relational/boolean
85    Eq,
86    Ne,
87    Gt,
88    Ge,
89    Lt,
90    Le,
91    And,
92    Or,
93    Xor,
94    Not,
95    Bitshift,
96    // Stack
97    Dup,
98    Exch,
99    Pop,
100    Copy,
101    Index,
102    Roll,
103    // Conditional
104    If(Vec<CalcToken>),
105    IfElse(Vec<CalcToken>, Vec<CalcToken>),
106    // Conversion
107    Cvi,
108    Cvr,
109    True,
110    False,
111}
112
113impl PdfFunction {
114    /// Parse a PDF function from a dict/stream object.
115    pub fn parse(obj: &PdfObj, resolver: &Resolver) -> Result<Self, PdfError> {
116        Self::parse_guarded(obj, resolver, &mut Vec::new(), 0)
117    }
118
119    /// [`Self::parse`], carrying the cycle and depth guards for `/Functions`.
120    ///
121    /// A Type 3 stitching function's `/Functions` entries are parsed
122    /// recursively, so a function that references itself — directly or through
123    /// a ring of siblings — would recurse forever and abort the process on a
124    /// stack overflow. `active` holds the object numbers on the current path;
125    /// re-entering one is a cycle. It is a *path* set, not a seen-set: entries
126    /// are popped on the way out, so the legitimate shape
127    /// `/Functions [7 0 R 7 0 R]` still parses.
128    ///
129    /// `depth` separately bounds nesting built from inline (non-reference)
130    /// sub-dictionaries, which cannot cycle but can still nest arbitrarily.
131    fn parse_guarded(
132        obj: &PdfObj,
133        resolver: &Resolver,
134        active: &mut Vec<u32>,
135        depth: u32,
136    ) -> Result<Self, PdfError> {
137        if depth >= MAX_FUNCTION_DEPTH {
138            return Err(PdfError::NestingTooDeep {
139                context: "function",
140                limit: MAX_FUNCTION_DEPTH,
141            });
142        }
143        if let PdfObj::Ref(num, gen_num) = obj {
144            if active.contains(num) {
145                return Err(PdfError::CircularReference(*num, *gen_num));
146            }
147            active.push(*num);
148            let result = Self::parse_resolved(obj, resolver, active, depth);
149            active.pop();
150            return result;
151        }
152        Self::parse_resolved(obj, resolver, active, depth)
153    }
154
155    /// Parse a function object whose reference (if any) is already on `active`.
156    fn parse_resolved(
157        obj: &PdfObj,
158        resolver: &Resolver,
159        active: &mut Vec<u32>,
160        depth: u32,
161    ) -> Result<Self, PdfError> {
162        let resolved = resolver.deref(obj)?;
163        let dict = resolved
164            .as_dict()
165            .ok_or(PdfError::Other("function is not a dict/stream".into()))?;
166
167        let fn_type =
168            dict.get_int(b"FunctionType")
169                .ok_or(PdfError::Other("function missing FunctionType".into()))? as i32;
170
171        let domain = parse_domain_range(dict, b"Domain")?;
172        let range = parse_domain_range(dict, b"Range").unwrap_or_default();
173
174        match fn_type {
175            0 => Self::parse_sampled(dict, obj, domain, range, resolver),
176            2 => Self::parse_exponential(dict, domain, range),
177            3 => Self::parse_stitching(dict, domain, range, resolver, active, depth),
178            4 => Self::parse_calculator(obj, domain, range, resolver),
179            _ => Err(PdfError::Other(format!(
180                "unsupported function type {fn_type}"
181            ))),
182        }
183    }
184
185    /// Evaluate the function for given inputs.
186    pub fn evaluate(&self, inputs: &[f64]) -> Vec<f64> {
187        match self {
188            Self::Sampled {
189                domain,
190                range,
191                size,
192                encode,
193                decode,
194                samples,
195                n_outputs,
196                ..
197            } => evaluate_sampled(
198                inputs, domain, range, size, encode, decode, samples, *n_outputs,
199            ),
200            Self::Exponential {
201                domain,
202                range,
203                c0,
204                c1,
205                n,
206            } => evaluate_exponential(inputs, domain, range, c0, c1, *n),
207            Self::Stitching {
208                domain,
209                range,
210                functions,
211                bounds,
212                encode,
213            } => evaluate_stitching(inputs, domain, range, functions, bounds, encode),
214            Self::Calculator {
215                domain,
216                range,
217                tokens,
218            } => evaluate_calculator(inputs, domain, range, tokens),
219            Self::Composite { functions } => {
220                let mut result = Vec::new();
221                for f in functions {
222                    result.extend(f.evaluate(inputs));
223                }
224                result
225            }
226        }
227    }
228
229    /// Create a composite function from an array of per-component functions.
230    pub fn composite(functions: Vec<PdfFunction>) -> Self {
231        Self::Composite { functions }
232    }
233
234    /// Get the first input dimension's domain [min, max].
235    pub fn domain_0(&self) -> [f64; 2] {
236        let d = match self {
237            Self::Sampled { domain, .. }
238            | Self::Exponential { domain, .. }
239            | Self::Stitching { domain, .. }
240            | Self::Calculator { domain, .. } => domain,
241            Self::Composite { functions } => {
242                return functions.first().map_or([0.0, 1.0], |f| f.domain_0());
243            }
244        };
245        d.first().copied().unwrap_or([0.0, 1.0])
246    }
247
248    /// Number of output values.
249    pub fn n_outputs(&self) -> usize {
250        match self {
251            Self::Sampled { n_outputs, .. } => *n_outputs,
252            Self::Exponential { c0, .. } => c0.len(),
253            Self::Stitching {
254                range, functions, ..
255            } => {
256                if !range.is_empty() {
257                    range.len()
258                } else if let Some(f) = functions.first() {
259                    f.n_outputs()
260                } else {
261                    1
262                }
263            }
264            Self::Calculator { range, .. } => range.len(),
265            Self::Composite { functions } => functions.iter().map(|f| f.n_outputs()).sum(),
266        }
267    }
268
269    /// Collect input values where this function has discontinuities (stitching bounds).
270    /// Returns values in the function's input domain, NOT normalized to `[0,1]`.
271    pub fn discontinuity_positions(&self) -> Vec<f64> {
272        let mut positions = Vec::new();
273        self.collect_discontinuities(&mut positions);
274        positions
275    }
276
277    fn collect_discontinuities(&self, out: &mut Vec<f64>) {
278        match self {
279            Self::Stitching {
280                domain,
281                bounds,
282                functions,
283                encode,
284                ..
285            } => {
286                let d = domain.first().copied().unwrap_or([0.0, 1.0]);
287                // Each bound is a potential discontinuity in the input domain
288                for &b in bounds {
289                    if b > d[0] && b < d[1] {
290                        out.push(b);
291                    }
292                }
293                // Also recurse into sub-functions: their internal discontinuities
294                // need to be mapped back to the parent's input domain
295                for (k, f) in functions.iter().enumerate() {
296                    let sub_discs = f.discontinuity_positions();
297                    if sub_discs.is_empty() {
298                        continue;
299                    }
300                    let enc = encode.get(k).copied().unwrap_or([0.0, 1.0]);
301                    let d_lo = if k == 0 { d[0] } else { bounds[k - 1] };
302                    let d_hi = if k >= bounds.len() { d[1] } else { bounds[k] };
303                    for sd in sub_discs {
304                        // Reverse the encode mapping: x_enc -> x in parent domain
305                        // x_enc = interpolate(x, d_lo, d_hi, enc[0], enc[1])
306                        // so x = interpolate(x_enc, enc[0], enc[1], d_lo, d_hi)
307                        // But sd is in the sub-function's domain, which is what x_enc
308                        // gets clamped to. We need to invert through encode.
309                        if (enc[1] - enc[0]).abs() < 1e-15 {
310                            continue;
311                        }
312                        let x = d_lo + (sd - enc[0]) * (d_hi - d_lo) / (enc[1] - enc[0]);
313                        if x > d[0] && x < d[1] {
314                            out.push(x);
315                        }
316                    }
317                }
318            }
319            Self::Composite { functions } => {
320                for f in functions {
321                    f.collect_discontinuities(out);
322                }
323            }
324            _ => {}
325        }
326    }
327
328    /// Minimum number of samples needed to faithfully reproduce this function.
329    /// For Type 0 (sampled) functions, returns the first dimension's sample count.
330    /// For stitching functions, sums the sub-functions' sample counts.
331    /// For other types, returns 0 (use caller's default).
332    pub fn min_samples(&self) -> usize {
333        match self {
334            Self::Sampled { size, .. } => size.first().copied().unwrap_or(0) as usize,
335            Self::Stitching { functions, .. } => {
336                functions.iter().map(|f| f.min_samples().max(2)).sum()
337            }
338            Self::Composite { functions } => {
339                functions.iter().map(|f| f.min_samples()).max().unwrap_or(0)
340            }
341            _ => 0,
342        }
343    }
344
345    fn parse_sampled(
346        dict: &PdfDict,
347        obj: &PdfObj,
348        domain: Vec<[f64; 2]>,
349        range: Vec<[f64; 2]>,
350        resolver: &Resolver,
351    ) -> Result<Self, PdfError> {
352        let size: Vec<u32> = dict
353            .get_array(b"Size")
354            .ok_or(PdfError::Other("sampled function missing Size".into()))?
355            .iter()
356            .filter_map(|o| o.as_int().map(|n| n as u32))
357            .collect();
358
359        let bps = dict
360            .get_int(b"BitsPerSample")
361            .ok_or(PdfError::Other("missing BitsPerSample".into()))? as u32;
362
363        let n_outputs = range.len();
364
365        let encode = if let Ok(enc) = parse_domain_range(dict, b"Encode") {
366            enc
367        } else {
368            size.iter().map(|s| [0.0, (*s as f64) - 1.0]).collect()
369        };
370
371        let decode = if let Ok(dec) = parse_domain_range(dict, b"Decode") {
372            dec
373        } else {
374            range.clone()
375        };
376
377        // Read sample data
378        let data = resolver.stream_data_from_obj(obj)?;
379        let max_val = ((1u64 << bps) - 1) as f64;
380        let total_samples: usize = size.iter().map(|s| *s as usize).product::<usize>() * n_outputs;
381        let mut samples = Vec::with_capacity(total_samples);
382
383        let mut bit_offset = 0usize;
384        for _ in 0..total_samples {
385            let byte_idx = bit_offset / 8;
386            let bit_idx = bit_offset % 8;
387            let mut val = 0u64;
388            let mut bits_left = bps;
389            let mut cur_byte = byte_idx;
390            let mut cur_bit = bit_idx;
391
392            while bits_left > 0 && cur_byte < data.len() {
393                let avail = 8 - cur_bit as u32;
394                let take = bits_left.min(avail);
395                let shift = avail - take;
396                let mask = ((1u64 << take) - 1) << shift;
397                val = (val << take) | ((data[cur_byte] as u64 & mask) >> shift);
398                bits_left -= take;
399                cur_bit = 0;
400                cur_byte += 1;
401            }
402
403            samples.push(val as f64 / max_val);
404            bit_offset += bps as usize;
405        }
406
407        Ok(Self::Sampled {
408            domain,
409            range,
410            size,
411            bps,
412            encode,
413            decode,
414            samples,
415            n_outputs,
416        })
417    }
418
419    fn parse_exponential(
420        dict: &PdfDict,
421        domain: Vec<[f64; 2]>,
422        range: Vec<[f64; 2]>,
423    ) -> Result<Self, PdfError> {
424        let n = dict
425            .get_f64(b"N")
426            .ok_or(PdfError::Other("exponential function missing N".into()))?;
427
428        let n_outputs = if !range.is_empty() { range.len() } else { 1 };
429
430        let c0 = dict
431            .get_array(b"C0")
432            .map(|arr| arr.iter().filter_map(|o| o.as_f64()).collect())
433            .unwrap_or_else(|| vec![0.0; n_outputs]);
434
435        let c1 = dict
436            .get_array(b"C1")
437            .map(|arr| arr.iter().filter_map(|o| o.as_f64()).collect())
438            .unwrap_or_else(|| vec![1.0; n_outputs]);
439
440        Ok(Self::Exponential {
441            domain,
442            range,
443            c0,
444            c1,
445            n,
446        })
447    }
448
449    fn parse_stitching(
450        dict: &PdfDict,
451        domain: Vec<[f64; 2]>,
452        range: Vec<[f64; 2]>,
453        resolver: &Resolver,
454        active: &mut Vec<u32>,
455        depth: u32,
456    ) -> Result<Self, PdfError> {
457        // /Functions, /Bounds, /Encode may be indirect references
458        let fn_arr = if let Some(arr) = dict.get_array(b"Functions") {
459            arr.to_vec()
460        } else if let Some(obj) = dict.get(b"Functions") {
461            match resolver.deref(obj)? {
462                PdfObj::Array(arr) => arr,
463                _ => {
464                    return Err(PdfError::Other(
465                        "stitching Functions is not an array".into(),
466                    ));
467                }
468            }
469        } else {
470            return Err(PdfError::Other("stitching missing Functions".into()));
471        };
472
473        let mut functions = Vec::with_capacity(fn_arr.len());
474        for fn_obj in &fn_arr {
475            functions.push(PdfFunction::parse_guarded(
476                fn_obj,
477                resolver,
478                active,
479                depth + 1,
480            )?);
481        }
482
483        let bounds_arr = if let Some(arr) = dict.get_array(b"Bounds") {
484            arr.to_vec()
485        } else if let Some(obj) = dict.get(b"Bounds") {
486            match resolver.deref(obj)? {
487                PdfObj::Array(arr) => arr,
488                _ => Vec::new(),
489            }
490        } else {
491            return Err(PdfError::Other("stitching missing Bounds".into()));
492        };
493        let bounds: Vec<f64> = bounds_arr.iter().filter_map(|o| o.as_f64()).collect();
494
495        let encode = parse_domain_range_resolved(dict, b"Encode", resolver)
496            .or_else(|_| parse_domain_range(dict, b"Encode"))
497            .unwrap_or_else(|_| functions.iter().map(|_| [0.0, 1.0]).collect());
498
499        Ok(Self::Stitching {
500            domain,
501            range,
502            functions,
503            bounds,
504            encode,
505        })
506    }
507
508    fn parse_calculator(
509        obj: &PdfObj,
510        domain: Vec<[f64; 2]>,
511        range: Vec<[f64; 2]>,
512        resolver: &Resolver,
513    ) -> Result<Self, PdfError> {
514        let data = resolver.stream_data_from_obj(obj)?;
515        let code = std::str::from_utf8(&data)
516            .map_err(|_| PdfError::Other("calculator function: invalid UTF-8".into()))?;
517        let tokens = parse_calc_tokens(code)?;
518        Ok(Self::Calculator {
519            domain,
520            range,
521            tokens,
522        })
523    }
524}
525
526// === Parse helpers ===
527
528fn parse_domain_range(dict: &PdfDict, key: &[u8]) -> Result<Vec<[f64; 2]>, PdfError> {
529    let arr = dict
530        .get_array(key)
531        .ok_or_else(|| PdfError::Other(format!("missing /{}", String::from_utf8_lossy(key))))?;
532    let vals: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
533    Ok(vals
534        .chunks(2)
535        .map(|c| [c[0], c.get(1).copied().unwrap_or(c[0])])
536        .collect())
537}
538
539/// Like `parse_domain_range` but resolves indirect references first.
540fn parse_domain_range_resolved(
541    dict: &PdfDict,
542    key: &[u8],
543    resolver: &Resolver,
544) -> Result<Vec<[f64; 2]>, PdfError> {
545    if dict.get_array(key).is_some() {
546        return parse_domain_range(dict, key);
547    }
548    let obj = dict
549        .get(key)
550        .ok_or_else(|| PdfError::Other(format!("missing /{}", String::from_utf8_lossy(key))))?;
551    let resolved = resolver.deref(obj)?;
552    let arr = match &resolved {
553        PdfObj::Array(a) => a,
554        _ => {
555            return Err(PdfError::Other(format!(
556                "/{} is not an array",
557                String::from_utf8_lossy(key)
558            )));
559        }
560    };
561    let vals: Vec<f64> = arr.iter().filter_map(|o| o.as_f64()).collect();
562    Ok(vals
563        .chunks(2)
564        .map(|c| [c[0], c.get(1).copied().unwrap_or(c[0])])
565        .collect())
566}
567
568// === Evaluation ===
569
570fn clamp(x: f64, lo: f64, hi: f64) -> f64 {
571    x.max(lo).min(hi)
572}
573
574fn interpolate(x: f64, x_min: f64, x_max: f64, y_min: f64, y_max: f64) -> f64 {
575    if (x_max - x_min).abs() < 1e-30 {
576        return y_min;
577    }
578    y_min + (x - x_min) * (y_max - y_min) / (x_max - x_min)
579}
580
581#[allow(clippy::too_many_arguments)]
582fn evaluate_sampled(
583    inputs: &[f64],
584    domain: &[[f64; 2]],
585    range: &[[f64; 2]],
586    size: &[u32],
587    encode: &[[f64; 2]],
588    decode: &[[f64; 2]],
589    samples: &[f64],
590    n_outputs: usize,
591) -> Vec<f64> {
592    let n_inputs = domain.len();
593
594    // Clamp and encode inputs
595    let mut encoded = Vec::with_capacity(n_inputs);
596    for i in 0..n_inputs.min(inputs.len()) {
597        let x = clamp(inputs[i], domain[i][0], domain[i][1]);
598        let e = interpolate(x, domain[i][0], domain[i][1], encode[i][0], encode[i][1]);
599        let e = clamp(e, 0.0, (size[i] as f64) - 1.0);
600        encoded.push(e);
601    }
602
603    // For 1D input, simple linear interpolation
604    if n_inputs == 1 && !encoded.is_empty() {
605        let e = encoded[0];
606        let i0 = e.floor() as usize;
607        let i1 = (i0 + 1).min(size[0] as usize - 1);
608        let frac = e - e.floor();
609
610        let mut result = Vec::with_capacity(n_outputs);
611        for j in 0..n_outputs {
612            let s0 = samples.get(i0 * n_outputs + j).copied().unwrap_or(0.0);
613            let s1 = samples.get(i1 * n_outputs + j).copied().unwrap_or(0.0);
614            let val = s0 + frac * (s1 - s0);
615            let decoded = if j < decode.len() {
616                interpolate(val, 0.0, 1.0, decode[j][0], decode[j][1])
617            } else {
618                val
619            };
620            let clamped = if j < range.len() {
621                clamp(decoded, range[j][0], range[j][1])
622            } else {
623                decoded
624            };
625            result.push(clamped);
626        }
627        return result;
628    }
629
630    // Multi-dimensional: multilinear interpolation
631    // For N inputs, interpolate across 2^N corners of the hypercube
632    let n = n_inputs.min(encoded.len());
633
634    // Compute floor indices and fractional parts for each dimension
635    let mut i0s = Vec::with_capacity(n);
636    let mut fracs = Vec::with_capacity(n);
637    for dim in 0..n {
638        let e = encoded[dim];
639        let lo = e.floor() as usize;
640        let lo = lo.min(size[dim] as usize - 2); // ensure lo+1 is valid
641        i0s.push(lo);
642        fracs.push(e - lo as f64);
643    }
644
645    // Compute strides for each dimension.
646    // PDF spec: first input varies fastest, so dim 0 has the smallest stride.
647    let mut strides = vec![0usize; n];
648    strides[0] = n_outputs;
649    for dim in 1..n {
650        strides[dim] = strides[dim - 1] * size[dim - 1] as usize;
651    }
652
653    // Iterate over 2^n corners and accumulate weighted contributions
654    let n_corners = 1usize << n;
655    let mut result = vec![0.0f64; n_outputs];
656    for corner in 0..n_corners {
657        let mut weight = 1.0f64;
658        let mut index = 0usize;
659        for dim in 0..n {
660            if corner & (1 << dim) != 0 {
661                weight *= fracs[dim];
662                index += (i0s[dim] + 1) * strides[dim];
663            } else {
664                weight *= 1.0 - fracs[dim];
665                index += i0s[dim] * strides[dim];
666            }
667        }
668        for (j, r) in result.iter_mut().enumerate() {
669            *r += weight * samples.get(index + j).copied().unwrap_or(0.0);
670        }
671    }
672
673    // Decode and clamp
674    for j in 0..n_outputs {
675        if j < decode.len() {
676            result[j] = interpolate(result[j], 0.0, 1.0, decode[j][0], decode[j][1]);
677        }
678        if j < range.len() {
679            result[j] = clamp(result[j], range[j][0], range[j][1]);
680        }
681    }
682    result
683}
684
685fn evaluate_exponential(
686    inputs: &[f64],
687    domain: &[[f64; 2]],
688    range: &[[f64; 2]],
689    c0: &[f64],
690    c1: &[f64],
691    n: f64,
692) -> Vec<f64> {
693    let x = if !inputs.is_empty() && !domain.is_empty() {
694        clamp(inputs[0], domain[0][0], domain[0][1])
695    } else {
696        0.0
697    };
698
699    let x_n = x.powf(n);
700    let mut result = Vec::with_capacity(c0.len());
701    for i in 0..c0.len() {
702        let val = c0[i] + x_n * (c1.get(i).copied().unwrap_or(1.0) - c0[i]);
703        let clamped = if i < range.len() {
704            clamp(val, range[i][0], range[i][1])
705        } else {
706            val
707        };
708        result.push(clamped);
709    }
710    result
711}
712
713fn evaluate_stitching(
714    inputs: &[f64],
715    domain: &[[f64; 2]],
716    range: &[[f64; 2]],
717    functions: &[PdfFunction],
718    bounds: &[f64],
719    encode: &[[f64; 2]],
720) -> Vec<f64> {
721    if functions.is_empty() {
722        return vec![0.0];
723    }
724
725    let x = if !inputs.is_empty() && !domain.is_empty() {
726        clamp(inputs[0], domain[0][0], domain[0][1])
727    } else {
728        0.0
729    };
730
731    // Find which sub-function to use
732    let mut k = 0;
733    for (i, &b) in bounds.iter().enumerate() {
734        if x < b {
735            k = i;
736            break;
737        }
738        k = i + 1;
739    }
740    k = k.min(functions.len() - 1);
741
742    // Determine domain bounds for this sub-function
743    let d_lo = if k == 0 {
744        domain.first().map(|d| d[0]).unwrap_or(0.0)
745    } else {
746        bounds[k - 1]
747    };
748    let d_hi = if k >= bounds.len() {
749        domain.first().map(|d| d[1]).unwrap_or(1.0)
750    } else {
751        bounds[k]
752    };
753
754    // Encode
755    let enc = encode.get(k).copied().unwrap_or([0.0, 1.0]);
756    let x_enc = interpolate(x, d_lo, d_hi, enc[0], enc[1]);
757
758    let mut result = functions[k].evaluate(&[x_enc]);
759
760    // Clamp to range
761    for (i, val) in result.iter_mut().enumerate() {
762        if i < range.len() {
763            *val = clamp(*val, range[i][0], range[i][1]);
764        }
765    }
766    result
767}
768
769fn evaluate_calculator(
770    inputs: &[f64],
771    domain: &[[f64; 2]],
772    range: &[[f64; 2]],
773    tokens: &[CalcToken],
774) -> Vec<f64> {
775    // Clamp inputs to domain
776    let mut stack: Vec<f64> = Vec::with_capacity(16);
777    for (i, &x) in inputs.iter().enumerate() {
778        let clamped = if i < domain.len() {
779            clamp(x, domain[i][0], domain[i][1])
780        } else {
781            x
782        };
783        stack.push(clamped);
784    }
785
786    execute_calc_tokens(&mut stack, tokens);
787
788    // Clamp outputs to range
789    let n_out = range.len();
790    let mut result = Vec::with_capacity(n_out);
791    for i in 0..n_out {
792        let val = if i < stack.len() {
793            stack[stack.len() - n_out + i]
794        } else {
795            0.0
796        };
797        result.push(clamp(val, range[i][0], range[i][1]));
798    }
799    result
800}
801
802fn execute_calc_tokens(stack: &mut Vec<f64>, tokens: &[CalcToken]) {
803    for token in tokens {
804        match token {
805            CalcToken::Number(n) => stack.push(*n),
806            CalcToken::Bool(b) => stack.push(if *b { 1.0 } else { 0.0 }),
807            CalcToken::True => stack.push(1.0),
808            CalcToken::False => stack.push(0.0),
809
810            // Arithmetic
811            CalcToken::Add => bin_op(stack, |a, b| a + b),
812            CalcToken::Sub => bin_op(stack, |a, b| a - b),
813            CalcToken::Mul => bin_op(stack, |a, b| a * b),
814            CalcToken::Div => bin_op(stack, |a, b| if b != 0.0 { a / b } else { 0.0 }),
815            CalcToken::Idiv => bin_op(stack, |a, b| {
816                if b != 0.0 {
817                    ((a as i64) / (b as i64)) as f64
818                } else {
819                    0.0
820                }
821            }),
822            CalcToken::Mod => bin_op(stack, |a, b| {
823                if b != 0.0 {
824                    ((a as i64) % (b as i64)) as f64
825                } else {
826                    0.0
827                }
828            }),
829            CalcToken::Neg => un_op(stack, |a| -a),
830            CalcToken::Abs => un_op(stack, |a| a.abs()),
831            CalcToken::Ceiling => un_op(stack, |a| a.ceil()),
832            CalcToken::Floor => un_op(stack, |a| a.floor()),
833            CalcToken::Round => un_op(stack, |a| a.round()),
834            CalcToken::Truncate => un_op(stack, |a| a.trunc()),
835            CalcToken::Sqrt => un_op(stack, |a| a.sqrt()),
836            CalcToken::Exp => bin_op(stack, |a, b| a.powf(b)),
837            CalcToken::Ln => un_op(stack, |a| a.ln()),
838            CalcToken::Log => un_op(stack, |a| a.log10()),
839            CalcToken::Sin => un_op(stack, |a| a.to_radians().sin()),
840            CalcToken::Cos => un_op(stack, |a| a.to_radians().cos()),
841            CalcToken::Atan => bin_op(stack, |a, b| {
842                let deg = a.atan2(b).to_degrees();
843                if deg < 0.0 { deg + 360.0 } else { deg }
844            }),
845
846            // Relational
847            CalcToken::Eq => bin_op(stack, |a, b| if (a - b).abs() < 1e-10 { 1.0 } else { 0.0 }),
848            CalcToken::Ne => bin_op(stack, |a, b| if (a - b).abs() >= 1e-10 { 1.0 } else { 0.0 }),
849            CalcToken::Gt => bin_op(stack, |a, b| if a > b { 1.0 } else { 0.0 }),
850            CalcToken::Ge => bin_op(stack, |a, b| if a >= b { 1.0 } else { 0.0 }),
851            CalcToken::Lt => bin_op(stack, |a, b| if a < b { 1.0 } else { 0.0 }),
852            CalcToken::Le => bin_op(stack, |a, b| if a <= b { 1.0 } else { 0.0 }),
853            CalcToken::And => bin_op(stack, |a, b| ((a as i64) & (b as i64)) as f64),
854            CalcToken::Or => bin_op(stack, |a, b| ((a as i64) | (b as i64)) as f64),
855            CalcToken::Xor => bin_op(stack, |a, b| ((a as i64) ^ (b as i64)) as f64),
856            CalcToken::Not => un_op(stack, |a| if a == 0.0 { 1.0 } else { 0.0 }),
857            CalcToken::Bitshift => bin_op(stack, |a, b| {
858                let n = a as i64;
859                let shift = b as i32;
860                if shift > 0 {
861                    (n << shift) as f64
862                } else {
863                    (n >> (-shift)) as f64
864                }
865            }),
866
867            // Stack
868            CalcToken::Dup => {
869                if let Some(&top) = stack.last() {
870                    stack.push(top);
871                }
872            }
873            CalcToken::Exch => {
874                let len = stack.len();
875                if len >= 2 {
876                    stack.swap(len - 1, len - 2);
877                }
878            }
879            CalcToken::Pop => {
880                stack.pop();
881            }
882            CalcToken::Copy => {
883                if let Some(&n) = stack.last() {
884                    stack.pop();
885                    let n = n as usize;
886                    let len = stack.len();
887                    if n <= len {
888                        let items: Vec<f64> = stack[len - n..].to_vec();
889                        stack.extend_from_slice(&items);
890                    }
891                }
892            }
893            CalcToken::Index => {
894                if let Some(&n) = stack.last() {
895                    stack.pop();
896                    let idx = n as usize;
897                    let len = stack.len();
898                    if idx < len {
899                        stack.push(stack[len - 1 - idx]);
900                    }
901                }
902            }
903            CalcToken::Roll => {
904                let len = stack.len();
905                if len >= 2 {
906                    let j = stack.pop().unwrap() as i32;
907                    let n = stack.pop().unwrap() as usize;
908                    if n > 0 && n <= stack.len() {
909                        let start = stack.len() - n;
910                        let j = ((j % n as i32) + n as i32) as usize % n;
911                        let mut temp: Vec<f64> = stack[start..].to_vec();
912                        temp.rotate_right(j);
913                        stack[start..].copy_from_slice(&temp);
914                    }
915                }
916            }
917
918            // Conditional
919            CalcToken::If(body) => {
920                if let Some(&cond) = stack.last() {
921                    stack.pop();
922                    if cond != 0.0 {
923                        execute_calc_tokens(stack, body);
924                    }
925                }
926            }
927            CalcToken::IfElse(if_body, else_body) => {
928                if let Some(&cond) = stack.last() {
929                    stack.pop();
930                    if cond != 0.0 {
931                        execute_calc_tokens(stack, if_body);
932                    } else {
933                        execute_calc_tokens(stack, else_body);
934                    }
935                }
936            }
937
938            // Conversion
939            CalcToken::Cvi => un_op(stack, |a| a.trunc()),
940            CalcToken::Cvr => {} // already f64
941        }
942    }
943}
944
945fn bin_op(stack: &mut Vec<f64>, f: impl FnOnce(f64, f64) -> f64) {
946    if stack.len() >= 2 {
947        let b = stack.pop().unwrap();
948        let a = stack.pop().unwrap();
949        stack.push(f(a, b));
950    }
951}
952
953fn un_op(stack: &mut Vec<f64>, f: impl FnOnce(f64) -> f64) {
954    if let Some(a) = stack.pop() {
955        stack.push(f(a));
956    }
957}
958
959// === Token parser for Type 4 calculator ===
960
961fn parse_calc_tokens(code: &str) -> Result<Vec<CalcToken>, PdfError> {
962    let code = code.trim();
963    // Strip outer { }
964    let code = if code.starts_with('{') && code.ends_with('}') {
965        &code[1..code.len() - 1]
966    } else {
967        code
968    };
969
970    parse_token_sequence(code, 0)
971}
972
973/// Maximum `{`-nesting depth in a Type 4 calculator function.
974///
975/// `parse_token_sequence` recurses once per procedure body, so an unbounded
976/// `{{{{…` would exhaust the native stack and abort the process. PLRM-style
977/// calculator functions are `if`/`ifelse` trees only a few levels deep; 64
978/// leaves ample headroom.
979const MAX_CALC_DEPTH: u32 = 64;
980
981/// Parse a run of calculator tokens.
982///
983/// `depth` counts the enclosing `{` procedure bodies; see [`MAX_CALC_DEPTH`].
984fn parse_token_sequence(code: &str, depth: u32) -> Result<Vec<CalcToken>, PdfError> {
985    if depth >= MAX_CALC_DEPTH {
986        return Err(PdfError::NestingTooDeep {
987            context: "calculator function procedure",
988            limit: MAX_CALC_DEPTH,
989        });
990    }
991    let mut tokens = Vec::new();
992    let mut chars = code.chars().peekable();
993
994    while let Some(&ch) = chars.peek() {
995        if ch.is_whitespace() {
996            chars.next();
997            continue;
998        }
999
1000        if ch == '{' {
1001            chars.next();
1002            // Find matching }
1003            let body = collect_brace_body(&mut chars)?;
1004            let body_tokens = parse_token_sequence(&body, depth + 1)?;
1005
1006            // Check if next non-ws token is "if" or "ifelse"
1007            // Skip whitespace
1008            while chars.peek().is_some_and(|c| c.is_whitespace()) {
1009                chars.next();
1010            }
1011
1012            // Peek at next word
1013            let saved: String = chars.clone().collect();
1014            if saved.starts_with('{') {
1015                // This might be the if-body in an ifelse
1016                chars.next(); // skip {
1017                let else_body = collect_brace_body(&mut chars)?;
1018                let else_tokens = parse_token_sequence(&else_body, depth + 1)?;
1019                // Skip whitespace
1020                while chars.peek().is_some_and(|c| c.is_whitespace()) {
1021                    chars.next();
1022                }
1023                // Expect "ifelse"
1024                let word = collect_word(&mut chars);
1025                if word == "ifelse" {
1026                    tokens.push(CalcToken::IfElse(body_tokens, else_tokens));
1027                } else {
1028                    // Not ifelse — push both bodies and the word
1029                    tokens.push(CalcToken::If(body_tokens));
1030                    tokens.push(CalcToken::If(else_tokens));
1031                    if let Some(tok) = word_to_token(&word) {
1032                        tokens.push(tok);
1033                    }
1034                }
1035            } else {
1036                let word = collect_word(&mut chars);
1037                if word == "if" {
1038                    tokens.push(CalcToken::If(body_tokens));
1039                } else {
1040                    // Just a procedure body — shouldn't happen in Type 4, but handle gracefully
1041                    tokens.push(CalcToken::If(body_tokens));
1042                    if let Some(tok) = word_to_token(&word) {
1043                        tokens.push(tok);
1044                    }
1045                }
1046            }
1047            continue;
1048        }
1049
1050        // Collect a word
1051        let word = collect_word(&mut chars);
1052        if word.is_empty() {
1053            chars.next(); // skip unknown char
1054            continue;
1055        }
1056
1057        // Try as number first
1058        if let Ok(n) = word.parse::<f64>() {
1059            tokens.push(CalcToken::Number(n));
1060        } else if let Some(tok) = word_to_token(&word) {
1061            tokens.push(tok);
1062        }
1063        // else skip unknown
1064    }
1065
1066    Ok(tokens)
1067}
1068
1069fn collect_brace_body(
1070    chars: &mut std::iter::Peekable<std::str::Chars>,
1071) -> Result<String, PdfError> {
1072    let mut body = String::new();
1073    let mut depth = 1;
1074    for ch in chars.by_ref() {
1075        if ch == '{' {
1076            depth += 1;
1077            body.push(ch);
1078        } else if ch == '}' {
1079            depth -= 1;
1080            if depth == 0 {
1081                return Ok(body);
1082            }
1083            body.push(ch);
1084        } else {
1085            body.push(ch);
1086        }
1087    }
1088    Err(PdfError::Other(
1089        "unterminated { in calculator function".into(),
1090    ))
1091}
1092
1093fn collect_word(chars: &mut std::iter::Peekable<std::str::Chars>) -> String {
1094    let mut word = String::new();
1095    while let Some(&ch) = chars.peek() {
1096        if ch.is_whitespace() || ch == '{' || ch == '}' {
1097            break;
1098        }
1099        word.push(ch);
1100        chars.next();
1101    }
1102    word
1103}
1104
1105fn word_to_token(word: &str) -> Option<CalcToken> {
1106    Some(match word {
1107        "add" => CalcToken::Add,
1108        "sub" => CalcToken::Sub,
1109        "mul" => CalcToken::Mul,
1110        "div" => CalcToken::Div,
1111        "idiv" => CalcToken::Idiv,
1112        "mod" => CalcToken::Mod,
1113        "neg" => CalcToken::Neg,
1114        "abs" => CalcToken::Abs,
1115        "ceiling" => CalcToken::Ceiling,
1116        "floor" => CalcToken::Floor,
1117        "round" => CalcToken::Round,
1118        "truncate" => CalcToken::Truncate,
1119        "sqrt" => CalcToken::Sqrt,
1120        "exp" => CalcToken::Exp,
1121        "ln" => CalcToken::Ln,
1122        "log" => CalcToken::Log,
1123        "sin" => CalcToken::Sin,
1124        "cos" => CalcToken::Cos,
1125        "atan" => CalcToken::Atan,
1126        "eq" => CalcToken::Eq,
1127        "ne" => CalcToken::Ne,
1128        "gt" => CalcToken::Gt,
1129        "ge" => CalcToken::Ge,
1130        "lt" => CalcToken::Lt,
1131        "le" => CalcToken::Le,
1132        "and" => CalcToken::And,
1133        "or" => CalcToken::Or,
1134        "xor" => CalcToken::Xor,
1135        "not" => CalcToken::Not,
1136        "bitshift" => CalcToken::Bitshift,
1137        "dup" => CalcToken::Dup,
1138        "exch" => CalcToken::Exch,
1139        "pop" => CalcToken::Pop,
1140        "copy" => CalcToken::Copy,
1141        "index" => CalcToken::Index,
1142        "roll" => CalcToken::Roll,
1143        "cvi" => CalcToken::Cvi,
1144        "cvr" => CalcToken::Cvr,
1145        "true" => CalcToken::True,
1146        "false" => CalcToken::False,
1147        "if" | "ifelse" => return None, // handled by brace logic
1148        _ => return None,
1149    })
1150}
1151
1152#[cfg(test)]
1153mod tests {
1154    use super::*;
1155
1156    #[test]
1157    fn exponential_function() {
1158        let f = PdfFunction::Exponential {
1159            domain: vec![[0.0, 1.0]],
1160            range: vec![[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]],
1161            c0: vec![1.0, 0.0, 0.0],
1162            c1: vec![0.0, 0.0, 1.0],
1163            n: 1.0,
1164        };
1165        let result = f.evaluate(&[0.0]);
1166        assert_eq!(result, vec![1.0, 0.0, 0.0]);
1167
1168        let result = f.evaluate(&[1.0]);
1169        assert_eq!(result, vec![0.0, 0.0, 1.0]);
1170
1171        let result = f.evaluate(&[0.5]);
1172        assert!((result[0] - 0.5).abs() < 1e-10);
1173    }
1174
1175    #[test]
1176    fn calculator_simple() {
1177        let tokens = parse_calc_tokens("{ 2 mul }").unwrap();
1178        let f = PdfFunction::Calculator {
1179            domain: vec![[0.0, 1.0]],
1180            range: vec![[0.0, 2.0]],
1181            tokens,
1182        };
1183        let result = f.evaluate(&[0.5]);
1184        assert!((result[0] - 1.0).abs() < 1e-10);
1185    }
1186
1187    #[test]
1188    fn devicen_duotone_black_green_diag() {
1189        let code = "{1.000000 3 1 roll 1.000000 3 1 roll 1.000000 3 1 roll 1 index 1.000000 \
1190cvr exch sub 3 1 roll 6 -1 roll 1 index 0.500000 mul 1.000000 cvr \
1191exch sub mul 1.000000 cvr exch sub 6 1 roll 5 -1 roll 1 index \
11920.000000 mul 1.000000 cvr exch sub mul 1.000000 cvr exch sub 5 1 roll 4 \
1193-1 roll 1 index 1.000000 mul 1.000000 cvr exch sub mul 1.000000 cvr exch sub \
11944 1 roll 3 -1 roll 1 index 0.000000 mul 1.000000 cvr exch sub mul \
11951.000000 cvr exch sub 3 1 roll pop pop }";
1196        let tokens = parse_calc_tokens(code).unwrap();
1197        let f = PdfFunction::Calculator {
1198            domain: vec![[0.0, 1.0], [0.0, 1.0]],
1199            range: vec![[0.0, 1.0], [0.0, 1.0], [0.0, 1.0], [0.0, 1.0]],
1200            tokens,
1201        };
1202        for (b, g, label) in [
1203            (0.0, 0.0, "white"),
1204            (1.0, 0.0, "black only"),
1205            (0.0, 1.0, "green only"),
1206            (1.0, 1.0, "both full"),
1207            (0.5, 0.5, "both half"),
1208        ] {
1209            let r = f.evaluate(&[b, g]);
1210            eprintln!("{label} (b={b}, g={g}) -> CMYK={r:?}");
1211        }
1212        // Expected: black-only -> (0, 0, 0, 1); green-only -> (0.5, 0, 1, 0)
1213        let r = f.evaluate(&[1.0, 0.0]);
1214        assert!(
1215            (r[0]).abs() < 1e-6
1216                && (r[1]).abs() < 1e-6
1217                && (r[2]).abs() < 1e-6
1218                && (r[3] - 1.0).abs() < 1e-6,
1219            "black-only got CMYK={r:?}"
1220        );
1221        let r = f.evaluate(&[0.0, 1.0]);
1222        assert!(
1223            (r[0] - 0.5).abs() < 1e-6
1224                && (r[1]).abs() < 1e-6
1225                && (r[2] - 1.0).abs() < 1e-6
1226                && (r[3]).abs() < 1e-6,
1227            "green-only got CMYK={r:?}"
1228        );
1229    }
1230
1231    #[test]
1232    fn devicen_cyan_green_gradient() {
1233        let code = "{0 index 1.000000 cvr exch sub 3 1 roll 1.000000 3 1 roll 1.000000 3 \
12341 roll 1.000000 3 1 roll 6 -1 roll 2 index 0.500000 mul 1.000000 cvr \
1235exch sub mul 1.000000 cvr exch sub 6 1 roll 5 -1 roll 2 index \
12360.000000 mul 1.000000 cvr exch sub mul 1.000000 cvr exch sub 5 1 roll 4 \
1237-1 roll 2 index 1.000000 mul 1.000000 cvr exch sub mul 1.000000 cvr exch sub \
12384 1 roll 3 -1 roll 2 index 0.000000 mul 1.000000 cvr exch sub mul \
12391.000000 cvr exch sub 3 1 roll pop pop }";
1240        let tokens = parse_calc_tokens(code).unwrap();
1241        let f = PdfFunction::Calculator {
1242            domain: vec![[0.0, 1.0], [0.0, 1.0]],
1243            range: vec![[0.0, 1.0], [0.0, 1.0], [0.0, 1.0], [0.0, 1.0]],
1244            tokens,
1245        };
1246        for (g, c, label) in [
1247            (0.0, 0.0, "white"),
1248            (1.0, 0.0, "green only"),
1249            (0.0, 1.0, "cyan only"),
1250            (0.5, 0.5, "both half"),
1251        ] {
1252            let r = f.evaluate(&[g, c]);
1253            eprintln!("{label} (g={g}, c={c}) -> CMYK={r:?}");
1254        }
1255        let r = f.evaluate(&[1.0, 0.0]);
1256        assert!(
1257            (r[0] - 0.5).abs() < 1e-6 && (r[2] - 1.0).abs() < 1e-6,
1258            "green-only: CMYK={r:?}"
1259        );
1260        let r = f.evaluate(&[0.0, 1.0]);
1261        assert!(
1262            (r[0] - 1.0).abs() < 1e-6 && (r[1]).abs() < 1e-6 && (r[2]).abs() < 1e-6,
1263            "cyan-only: CMYK={r:?}"
1264        );
1265    }
1266
1267    #[test]
1268    fn devicen_duotone_via_tint_table() {
1269        use std::sync::Arc;
1270        use stet_graphics::device::TintLookupTable;
1271        let code = "{1.000000 3 1 roll 1.000000 3 1 roll 1.000000 3 1 roll 1 index 1.000000 \
1272cvr exch sub 3 1 roll 6 -1 roll 1 index 0.500000 mul 1.000000 cvr \
1273exch sub mul 1.000000 cvr exch sub 6 1 roll 5 -1 roll 1 index \
12740.000000 mul 1.000000 cvr exch sub mul 1.000000 cvr exch sub 5 1 roll 4 \
1275-1 roll 1 index 1.000000 mul 1.000000 cvr exch sub mul 1.000000 cvr exch sub \
12764 1 roll 3 -1 roll 1 index 0.000000 mul 1.000000 cvr exch sub mul \
12771.000000 cvr exch sub 3 1 roll pop pop }";
1278        let tokens = parse_calc_tokens(code).unwrap();
1279        let f = PdfFunction::Calculator {
1280            domain: vec![[0.0, 1.0], [0.0, 1.0]],
1281            range: vec![[0.0, 1.0], [0.0, 1.0], [0.0, 1.0], [0.0, 1.0]],
1282            tokens,
1283        };
1284        // Build a 64x64x4 tint table the way build_nd_tint_image_cs does
1285        let n_inputs = 2usize;
1286        let n_out = 4usize;
1287        let spd = 64u32;
1288        let total: usize = (spd as usize).pow(n_inputs as u32);
1289        let mut data = Vec::with_capacity(total * n_out);
1290        let mut inputs = vec![0.0f64; n_inputs];
1291        for idx in 0..total {
1292            let mut rem = idx;
1293            for d in (0..n_inputs).rev() {
1294                inputs[d] = (rem % spd as usize) as f64 / (spd - 1) as f64;
1295                rem /= spd as usize;
1296            }
1297            let out = f.evaluate(&inputs);
1298            for j in 0..n_out {
1299                data.push(out.get(j).copied().unwrap_or(0.0) as f32);
1300            }
1301        }
1302        let table = TintLookupTable {
1303            num_inputs: n_inputs as u32,
1304            num_outputs: n_out as u32,
1305            samples_per_dim: spd,
1306            data,
1307        };
1308        let mut out = vec![0.0f32; 4];
1309        for (b, g, label) in [
1310            (0.0, 0.0, "white"),
1311            (1.0, 0.0, "black only"),
1312            (0.0, 1.0, "green only"),
1313            (1.0, 1.0, "both full"),
1314            (0.5, 0.5, "both half"),
1315        ] {
1316            table.lookup_nd(&[b as f32, g as f32], &mut out);
1317            eprintln!("table {label} (b={b}, g={g}) -> CMYK={out:?}");
1318        }
1319        // Expected: green only at index ~1.0 second axis -> CMYK (0.5, 0, 1, 0)
1320        table.lookup_nd(&[0.0, 1.0], &mut out);
1321        assert!(
1322            (out[0] - 0.5).abs() < 0.02,
1323            "green-only via table: C={out:?}"
1324        );
1325        assert!(
1326            (out[2] - 1.0).abs() < 0.02,
1327            "green-only via table: Y={out:?}"
1328        );
1329    }
1330}