Skip to main content

docling_pdf/render/
color.rs

1//! Colour spaces (ISO 32000-1, 8.6) → device RGB in `[0, 1]`.
2//!
3//! CMYK follows docling-parse's `color::cmyk_to_rgb` (`parse/utils/color/
4//! device_cmyk.h`): a Yule–Nielsen-corrected Neugebauer model fitted to a
5//! coated-stock press, not the naive `1 − min(1, c + k)`. It makes `0 0 0 1 k`
6//! text a warm near-black (35, 31, 32) rather than pure black, which is what
7//! the reference renders show; the layout model's inputs are compared against
8//! those renders, so the same numbers are used here.
9
10use std::collections::HashMap;
11
12use lopdf::{Dictionary, Document, Object};
13
14use super::function::Function;
15use super::objects::{as_dict, as_stream, deref, get, get_int, name, nums, resource};
16
17#[derive(Debug, Clone)]
18pub enum ColorSpace {
19    DeviceGray,
20    DeviceRGB,
21    DeviceCMYK,
22    /// CIE L*a*b* with its white point and `/Range`.
23    Lab {
24        wp: [f64; 3],
25        range: [f64; 4],
26    },
27    Indexed {
28        base: Box<ColorSpace>,
29        hival: usize,
30        lookup: Vec<u8>,
31    },
32    /// Separation / DeviceN: `n` tints through a function into `alt`.
33    Separation {
34        n: usize,
35        alt: Box<ColorSpace>,
36        tint: Option<Function>,
37        /// `/None`: paints nothing.
38        is_none: bool,
39        /// `/All`: paints every colorant — black at full tint.
40        is_all: bool,
41    },
42    /// A pattern colour space, with the base space of an uncoloured (type 2)
43    /// tiling pattern's colour operands.
44    Pattern(Option<Box<ColorSpace>>),
45}
46
47impl ColorSpace {
48    pub fn components(&self) -> usize {
49        match self {
50            ColorSpace::DeviceGray => 1,
51            ColorSpace::DeviceRGB => 3,
52            ColorSpace::DeviceCMYK => 4,
53            ColorSpace::Lab { .. } => 3,
54            ColorSpace::Indexed { .. } => 1,
55            ColorSpace::Separation { n, .. } => *n,
56            ColorSpace::Pattern(_) => 1,
57        }
58    }
59
60    /// The initial colour when the space is selected (`cs`): black, or all
61    /// colorants at 1.0 for Separation/DeviceN.
62    pub fn initial(&self) -> Vec<f64> {
63        match self {
64            ColorSpace::DeviceCMYK => vec![0.0, 0.0, 0.0, 1.0],
65            ColorSpace::Separation { n, .. } => vec![1.0; *n],
66            ColorSpace::Lab { .. } => vec![0.0, 0.0, 0.0],
67            ColorSpace::Indexed { .. } => vec![0.0],
68            other => vec![0.0; other.components()],
69        }
70    }
71
72    /// The default `/Decode` range of image samples in this space for `bpc`
73    /// bits: `[0 1]` per component, `[0 2ᵇᵖᶜ−1]` for Indexed, the `/Range`
74    /// for Lab.
75    pub fn default_decode(&self, bpc: u32) -> Vec<f64> {
76        match self {
77            ColorSpace::Indexed { .. } => vec![0.0, ((1u64 << bpc) - 1) as f64],
78            ColorSpace::Lab { range, .. } => {
79                vec![0.0, 100.0, range[0], range[1], range[2], range[3]]
80            }
81            other => (0..other.components()).flat_map(|_| [0.0, 1.0]).collect(),
82        }
83    }
84
85    /// Resolve a colour space object; `res` is the resources dictionary named
86    /// spaces are looked up in.
87    pub fn parse(doc: &Document, obj: &Object, res: Option<&Dictionary>) -> Option<ColorSpace> {
88        Self::parse_depth(doc, obj, res, 0)
89    }
90
91    fn parse_depth(
92        doc: &Document,
93        obj: &Object,
94        res: Option<&Dictionary>,
95        depth: usize,
96    ) -> Option<ColorSpace> {
97        if depth > 6 {
98            return None;
99        }
100        match deref(doc, obj) {
101            Object::Name(n) => match n.as_slice() {
102                b"DeviceGray" | b"G" | b"CalGray" => Some(ColorSpace::DeviceGray),
103                b"DeviceRGB" | b"RGB" | b"CalRGB" => Some(ColorSpace::DeviceRGB),
104                b"DeviceCMYK" | b"CMYK" => Some(ColorSpace::DeviceCMYK),
105                b"Pattern" => Some(ColorSpace::Pattern(None)),
106                b"Indexed" | b"I" => None,
107                other => {
108                    let o = resource(doc, res, b"ColorSpace", other)?;
109                    // A named resource is looked up once, without the resources
110                    // again (a self-referencing `/CS0 /CS0` would loop).
111                    Self::parse_depth(doc, o, None, depth + 1)
112                }
113            },
114            Object::Array(a) => {
115                let fam = a.first().map(|o| deref(doc, o)).and_then(name)?;
116                match fam {
117                    b"DeviceGray" | b"G" | b"CalGray" => Some(ColorSpace::DeviceGray),
118                    b"DeviceRGB" | b"RGB" | b"CalRGB" => Some(ColorSpace::DeviceRGB),
119                    b"DeviceCMYK" | b"CMYK" => Some(ColorSpace::DeviceCMYK),
120                    b"Lab" => {
121                        let d = a.get(1).and_then(|o| as_dict(doc, o));
122                        let wp = d
123                            .and_then(|d| get(doc, d, b"WhitePoint"))
124                            .and_then(|o| nums(doc, o))
125                            .filter(|v| v.len() == 3)
126                            .map(|v| [v[0], v[1], v[2]])
127                            .unwrap_or([0.9505, 1.0, 1.089]);
128                        let range = d
129                            .and_then(|d| get(doc, d, b"Range"))
130                            .and_then(|o| nums(doc, o))
131                            .filter(|v| v.len() == 4)
132                            .map(|v| [v[0], v[1], v[2], v[3]])
133                            .unwrap_or([-100.0, 100.0, -100.0, 100.0]);
134                        Some(ColorSpace::Lab { wp, range })
135                    }
136                    b"ICCBased" => {
137                        let s = a.get(1).and_then(|o| as_stream(doc, o));
138                        let n = s.and_then(|s| get_int(doc, &s.dict, b"N"));
139                        match n {
140                            Some(1) => Some(ColorSpace::DeviceGray),
141                            Some(4) => Some(ColorSpace::DeviceCMYK),
142                            Some(3) => Some(ColorSpace::DeviceRGB),
143                            _ => s
144                                .and_then(|s| s.dict.get(b"Alternate").ok())
145                                .and_then(|o| Self::parse_depth(doc, o, res, depth + 1))
146                                .or(Some(ColorSpace::DeviceRGB)),
147                        }
148                    }
149                    b"Indexed" | b"I" => {
150                        if a.len() < 4 {
151                            return None;
152                        }
153                        let base = Self::parse_depth(doc, &a[1], res, depth + 1)?;
154                        let hival = deref(doc, &a[2]).as_i64().unwrap_or(0).clamp(0, 255) as usize;
155                        let lookup = match deref(doc, &a[3]) {
156                            Object::String(s, _) => s.clone(),
157                            Object::Stream(s) => s.decompressed_content().ok()?,
158                            _ => return None,
159                        };
160                        Some(ColorSpace::Indexed {
161                            base: Box::new(base),
162                            hival,
163                            lookup,
164                        })
165                    }
166                    b"Separation" | b"DeviceN" => {
167                        let (n, names): (usize, Vec<Vec<u8>>) = match deref(doc, a.get(1)?) {
168                            Object::Name(nm) => (1, vec![nm.clone()]),
169                            Object::Array(arr) => (
170                                arr.len().max(1),
171                                arr.iter()
172                                    .filter_map(|o| name(deref(doc, o)).map(|n| n.to_vec()))
173                                    .collect(),
174                            ),
175                            _ => (1, Vec::new()),
176                        };
177                        let alt = a
178                            .get(2)
179                            .and_then(|o| Self::parse_depth(doc, o, res, depth + 1))
180                            .unwrap_or(ColorSpace::DeviceGray);
181                        let tint = a.get(3).and_then(|o| Function::parse(doc, o));
182                        let is_none =
183                            fam == b"Separation" && names.first().is_some_and(|n| n == b"None");
184                        let is_all =
185                            fam == b"Separation" && names.first().is_some_and(|n| n == b"All");
186                        Some(ColorSpace::Separation {
187                            n,
188                            alt: Box::new(alt),
189                            tint,
190                            is_none,
191                            is_all,
192                        })
193                    }
194                    b"Pattern" => {
195                        let base = a
196                            .get(1)
197                            .and_then(|o| Self::parse_depth(doc, o, res, depth + 1))
198                            .map(Box::new);
199                        Some(ColorSpace::Pattern(base))
200                    }
201                    b"DeviceRGBA" => Some(ColorSpace::DeviceRGB),
202                    _ => None,
203                }
204            }
205            _ => None,
206        }
207    }
208
209    /// Component values → RGB in `[0, 1]`. `None` for `/Separation /None`
210    /// (nothing is painted).
211    pub fn to_rgb(&self, v: &[f64]) -> Option<[f64; 3]> {
212        let c = |i: usize| v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0);
213        match self {
214            ColorSpace::DeviceGray => {
215                let g = c(0);
216                Some([g, g, g])
217            }
218            ColorSpace::DeviceRGB => Some([c(0), c(1), c(2)]),
219            ColorSpace::DeviceCMYK => Some(cmyk_to_rgb(c(0), c(1), c(2), c(3))),
220            ColorSpace::Lab { wp, range } => {
221                let l = v.first().copied().unwrap_or(0.0).clamp(0.0, 100.0);
222                let a = v.get(1).copied().unwrap_or(0.0).clamp(range[0], range[1]);
223                let b = v.get(2).copied().unwrap_or(0.0).clamp(range[2], range[3]);
224                Some(lab_to_rgb(l, a, b, wp))
225            }
226            ColorSpace::Indexed {
227                base,
228                hival,
229                lookup,
230            } => {
231                let idx = v.first().copied().unwrap_or(0.0).round().max(0.0) as usize;
232                let idx = idx.min(*hival);
233                let n = base.components();
234                let comps: Vec<f64> = (0..n)
235                    .map(|i| f64::from(*lookup.get(idx * n + i).unwrap_or(&0)) / 255.0)
236                    .collect();
237                // Lab lookups are byte-scaled over the range, not 0..1.
238                let comps = match &**base {
239                    ColorSpace::Lab { range, .. } => vec![
240                        comps[0] * 100.0,
241                        range[0] + comps.get(1).copied().unwrap_or(0.0) * (range[1] - range[0]),
242                        range[2] + comps.get(2).copied().unwrap_or(0.0) * (range[3] - range[2]),
243                    ],
244                    _ => comps,
245                };
246                base.to_rgb(&comps)
247            }
248            ColorSpace::Separation {
249                n,
250                alt,
251                tint,
252                is_none,
253                is_all,
254            } => {
255                if *is_none {
256                    return None;
257                }
258                if *is_all {
259                    let g = 1.0 - c(0);
260                    return Some([g, g, g]);
261                }
262                match tint {
263                    Some(f) => {
264                        let inputs: Vec<f64> = (0..*n).map(c).collect();
265                        let out = f.eval(&inputs);
266                        alt.to_rgb(&out)
267                    }
268                    // No usable tint transform: treat the first colorant as
269                    // an ink coverage of gray.
270                    None => {
271                        let g = 1.0 - c(0);
272                        Some([g, g, g])
273                    }
274                }
275            }
276            ColorSpace::Pattern(_) => Some([0.0, 0.0, 0.0]),
277        }
278    }
279}
280
281/// CIE L*a*b* (D50-ish white point `wp`) → sRGB.
282fn lab_to_rgb(l: f64, a: f64, b: f64, wp: &[f64; 3]) -> [f64; 3] {
283    let m = (l + 16.0) / 116.0;
284    let ll = m + a / 500.0;
285    let n = m - b / 200.0;
286    let g = |x: f64| {
287        if x >= 6.0 / 29.0 {
288            x * x * x
289        } else {
290            108.0 / 841.0 * (x - 4.0 / 29.0)
291        }
292    };
293    let x = wp[0] * g(ll);
294    let y = wp[1] * g(m);
295    let z = wp[2] * g(n);
296    // XYZ → linear sRGB (D65 matrix; the white-point mismatch is small).
297    let r = 3.2406 * x - 1.5372 * y - 0.4986 * z;
298    let gg = -0.9689 * x + 1.8758 * y + 0.0415 * z;
299    let bb = 0.0557 * x - 0.2040 * y + 1.0570 * z;
300    [linear_to_srgb(r), linear_to_srgb(gg), linear_to_srgb(bb)]
301}
302
303fn srgb_to_linear(v: f64) -> f64 {
304    if v <= 0.04045 {
305        v / 12.92
306    } else {
307        ((v + 0.055) / 1.055).powf(2.4)
308    }
309}
310
311fn linear_to_srgb(v: f64) -> f64 {
312    let v = v.clamp(0.0, 1.0);
313    if v <= 0.0031308 {
314        12.92 * v
315    } else {
316        1.055 * v.powf(1.0 / 2.4) - 0.055
317    }
318}
319
320struct CmykModel {
321    encoded_positive: [[f64; 3]; 8],
322    negative: [[f64; 3]; 8],
323    yule_nielsen: [f64; 3],
324    ink_tone: [f64; 3],
325    black: [[f64; 3]; 9],
326}
327
328fn cmyk_model() -> &'static CmykModel {
329    static MODEL: std::sync::OnceLock<CmykModel> = std::sync::OnceLock::new();
330    MODEL.get_or_init(|| {
331        let yule_nielsen = [1.553623, 1.348579, 1.536033];
332        let corner: [[f64; 3]; 8] = [
333            [1.000000, 1.000000, 1.000000],  // paper
334            [-0.179803, 0.419937, 0.868289], // C
335            [0.869084, -0.001406, 0.268871], // M
336            [0.039807, 0.023510, 0.277328],  // C + M
337            [1.052223, 0.853703, 0.007227],  // Y
338            [-0.074325, 0.403406, 0.078689], // C + Y
339            [0.879638, 0.012292, 0.011953],  // M + Y
340            [0.043065, 0.031199, 0.032550],  // C + M + Y
341        ];
342        let mut encoded_positive = [[0.0; 3]; 8];
343        let mut negative = [[0.0; 3]; 8];
344        for i in 0..8 {
345            for j in 0..3 {
346                let v = corner[i][j];
347                encoded_positive[i][j] = v.max(0.0).powf(1.0 / yule_nielsen[j]);
348                negative[i][j] = v.min(0.0);
349            }
350        }
351        let ramp: [[f64; 3]; 9] = [
352            [255.0, 255.0, 255.0],
353            [225.0, 226.0, 228.0],
354            [199.0, 200.0, 202.0],
355            [173.0, 174.0, 178.0],
356            [147.0, 149.0, 152.0],
357            [123.0, 125.0, 128.0],
358            [99.0, 99.0, 102.0],
359            [69.0, 70.0, 71.0],
360            [35.0, 31.0, 32.0],
361        ];
362        let mut black = [[0.0; 3]; 9];
363        for i in 0..9 {
364            for j in 0..3 {
365                black[i][j] = srgb_to_linear(ramp[i][j] / 255.0);
366            }
367        }
368        CmykModel {
369            encoded_positive,
370            negative,
371            yule_nielsen,
372            ink_tone: [1.176647, 1.212366, 1.145622],
373            black,
374        }
375    })
376}
377
378/// docling-parse's `color::cmyk_to_rgb`, inputs and outputs in `[0, 1]`.
379pub fn cmyk_to_rgb(c: f64, m: f64, y: f64, k: f64) -> [f64; 3] {
380    let model = cmyk_model();
381    let clamp = |v: f64| v.clamp(0.0, 1.0);
382    let area = [
383        clamp(c).powf(1.0 / model.ink_tone[0]),
384        clamp(m).powf(1.0 / model.ink_tone[1]),
385        clamp(y).powf(1.0 / model.ink_tone[2]),
386    ];
387    let mut weight = [0.0; 8];
388    for (i, w) in weight.iter_mut().enumerate() {
389        *w = (if i & 1 != 0 { area[0] } else { 1.0 - area[0] })
390            * (if i & 2 != 0 { area[1] } else { 1.0 - area[1] })
391            * (if i & 4 != 0 { area[2] } else { 1.0 - area[2] });
392    }
393    let kx = clamp(k) * 8.0;
394    let k0 = (kx as usize).min(7);
395    let kf = kx - k0 as f64;
396    let mut out = [0.0; 3];
397    for (j, o) in out.iter_mut().enumerate() {
398        let mut mixed = 0.0;
399        let mut offset = 0.0;
400        for (i, wt) in weight.iter().enumerate() {
401            mixed += wt * model.encoded_positive[i][j];
402            offset += wt * model.negative[i][j];
403        }
404        let linear = mixed.max(0.0).powf(model.yule_nielsen[j]) + offset;
405        let transmittance = model.black[k0][j] * (1.0 - kf) + model.black[k0 + 1][j] * kf;
406        *o = linear_to_srgb(linear * transmittance);
407    }
408    out
409}
410
411/// A per-document cache of CMYK → RGB bytes (the conversion is nine `powf`
412/// calls; photographic CMYK images call it per pixel).
413#[derive(Default)]
414pub struct CmykCache {
415    map: HashMap<u32, [u8; 3]>,
416}
417
418impl CmykCache {
419    pub fn rgb8(&mut self, c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
420        let key = u32::from_be_bytes([c, m, y, k]);
421        if let Some(v) = self.map.get(&key) {
422            return *v;
423        }
424        let rgb = cmyk_to_rgb(
425            f64::from(c) / 255.0,
426            f64::from(m) / 255.0,
427            f64::from(y) / 255.0,
428            f64::from(k) / 255.0,
429        );
430        let out = [to_u8(rgb[0]), to_u8(rgb[1]), to_u8(rgb[2])];
431        if self.map.len() < 1 << 16 {
432            self.map.insert(key, out);
433        }
434        out
435    }
436}
437
438/// `lround(255 · v)` clamped.
439pub fn to_u8(v: f64) -> u8 {
440    (v.clamp(0.0, 1.0) * 255.0).round() as u8
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn cmyk_black_is_docling_parses_warm_black() {
449        let k = cmyk_to_rgb(0.0, 0.0, 0.0, 1.0);
450        assert_eq!([to_u8(k[0]), to_u8(k[1]), to_u8(k[2])], [35, 31, 32]);
451        let w = cmyk_to_rgb(0.0, 0.0, 0.0, 0.0);
452        assert_eq!([to_u8(w[0]), to_u8(w[1]), to_u8(w[2])], [255, 255, 255]);
453        let c = cmyk_to_rgb(1.0, 0.0, 0.0, 0.0);
454        assert!(c[2] > c[0], "cyan is blue-ish: {c:?}");
455    }
456
457    #[test]
458    fn lab_white_and_black() {
459        let w = lab_to_rgb(100.0, 0.0, 0.0, &[0.9505, 1.0, 1.089]);
460        assert!(w.iter().all(|v| *v > 0.97), "{w:?}");
461        let k = lab_to_rgb(0.0, 0.0, 0.0, &[0.9505, 1.0, 1.089]);
462        assert!(k.iter().all(|v| *v < 0.02), "{k:?}");
463    }
464}