Skip to main content

oxidize_pdf/graphics/
page_color_space.rs

1//! Typed wrapper for page-level colour-space resource registration
2//! (ISO 32000-1 §8.6, Table 62).
3//!
4//! `Page::add_color_space` originally took a raw
5//! [`crate::objects::Object`], which leaked an internal serialization
6//! type across the public API and made the signature SemVer-fragile.
7//! This module introduces a small enum that models the two wire-format
8//! shapes a colour-space resource entry is allowed to take:
9//!
10//!   * A single `/Name` alias for a device space (ISO 32000-1 §8.6.4,
11//!     e.g. `/DeviceRGB`, `/DeviceCMYK`, `/Pattern`).
12//!   * A parameterised array `[/<family> <<params>>]` for calibrated
13//!     spaces (§8.6.5 `CalGray`, `CalRGB`, `Lab`, `ICCBased`).
14//!
15//! Indexed, Separation, and `DeviceN` spaces are intentionally out of
16//! scope for the v2.5.6 wrapper — those require longer tuple shapes
17//! (`[/Indexed base hival lookup]`, `[/Separation name alt tintFn]`,
18//! `[/DeviceN names alt tintFn attributes]`) that are better served by
19//! dedicated constructors added in a future SemVer-compatible superset
20//! (the enum is `#[non_exhaustive]` to preserve that option).
21
22use super::calibrated_color::{CalGrayColorSpace, CalRgbColorSpace};
23use super::color_profiles::{IccColorSpace, IccProfile};
24use super::lab_color::LabColorSpace;
25use crate::objects::{Dictionary, Object};
26use std::sync::Arc;
27
28/// A colour space eligible for registration on a [`crate::Page`] under
29/// `/Resources/ColorSpace/<name>`.
30///
31/// See the module-level docs for the ISO 32000-1 clauses this models.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34pub enum PageColorSpace {
35    /// A named device-space alias — emitted as a single `/Name` at the
36    /// resource slot (ISO 32000-1 §8.6.4). Use when the caller wants
37    /// to reference a device space via a numeric or symbolic alias
38    /// (e.g. `/CS1 /DeviceRGB`).
39    DeviceAlias(DeviceColorSpace),
40    /// A calibrated colour space — emitted as `[/<family> <<params>>]`
41    /// (ISO 32000-1 §8.6.5). The parameter dictionary is written
42    /// verbatim; callers are responsible for its content.
43    Parameterised {
44        /// Which calibrated family this entry represents.
45        family: ParameterisedFamily,
46        /// Parameter dictionary — e.g. `WhitePoint`, `Gamma`, `Matrix`
47        /// for CalRGB; `N`, `Alternate`, `Metadata` for ICCBased.
48        params: Dictionary,
49    },
50    /// An ICC-profile-backed colour space emitted as a conformant indirect
51    /// **stream**: `[/ICCBased <ref>]` where `<ref>` resolves to a stream
52    /// `<< /N n /Alternate … /Range … >> stream <profile bytes> endstream`
53    /// (ISO 32000-1 §8.6.5.5). Unlike [`Self::Parameterised`] with
54    /// [`ParameterisedFamily::IccBased`] — which can only express an inline
55    /// dict and therefore drops the profile bytes — this variant carries the
56    /// raw profile so the writer can embed it. The stream object id is
57    /// allocated by the writer at emit time (a stream cannot be inlined into a
58    /// resource dict), so the conversion goes through
59    /// [`Self::icc_stream_parts`], not [`Self::to_object`].
60    IccStream {
61        /// Number of colour components (`/N`: 1, 3, or 4).
62        n: u8,
63        /// Device colour space to fall back to (`/Alternate`). Must not be
64        /// `Pattern` (ISO 32000-1 §8.6.5.5 forbids it as an alternate space).
65        alternate: DeviceColorSpace,
66        /// Raw ICC profile bytes, written verbatim into the stream. Wrapped in
67        /// `Arc` so cloning a `PageColorSpace` (e.g. when a templated page is
68        /// reused across a document) shares the buffer instead of copying a
69        /// potentially large profile.
70        profile_data: Arc<Vec<u8>>,
71        /// Optional `/Range` for the components. Omitted from the stream when
72        /// it equals the ISO 32000-1 §8.6.5.5 Table 66 default (`[0 1]` per
73        /// component); see [`Self::icc_stream_parts`].
74        range: Option<Vec<f64>>,
75    },
76}
77
78/// The four device colour spaces addressable through
79/// [`PageColorSpace::DeviceAlias`] (ISO 32000-1 §8.6.4 device spaces
80/// + §8.7.3.1 Pattern colour space).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82#[non_exhaustive]
83pub enum DeviceColorSpace {
84    /// Single-channel grayscale (`/DeviceGray`).
85    Gray,
86    /// Three-channel RGB (`/DeviceRGB`).
87    Rgb,
88    /// Four-channel CMYK (`/DeviceCMYK`).
89    Cmyk,
90    /// Pattern colour space (`/Pattern`).
91    Pattern,
92}
93
94/// The calibrated colour-space families addressable through
95/// [`PageColorSpace::Parameterised`] (ISO 32000-1 §8.6.5).
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub enum ParameterisedFamily {
99    /// `CalGray` — CIE-based single-component (§8.6.5.1).
100    CalGray,
101    /// `CalRGB` — CIE-based three-component (§8.6.5.2).
102    CalRgb,
103    /// `Lab` — CIE 1976 L*a*b* (§8.6.5.4).
104    Lab,
105    /// `ICCBased` — ICC-profile-backed (§8.6.5.5).
106    IccBased,
107}
108
109impl DeviceColorSpace {
110    /// Returns the ISO 32000-1 §8.6 PDF name for this device space,
111    /// without the leading `/`.
112    pub const fn pdf_name(self) -> &'static str {
113        match self {
114            DeviceColorSpace::Gray => "DeviceGray",
115            DeviceColorSpace::Rgb => "DeviceRGB",
116            DeviceColorSpace::Cmyk => "DeviceCMYK",
117            DeviceColorSpace::Pattern => "Pattern",
118        }
119    }
120}
121
122impl ParameterisedFamily {
123    /// Returns the ISO 32000-1 §8.6.5 family name for this calibrated
124    /// colour space (the first element of the emitted array).
125    pub const fn pdf_name(self) -> &'static str {
126        match self {
127            ParameterisedFamily::CalGray => "CalGray",
128            ParameterisedFamily::CalRgb => "CalRGB",
129            ParameterisedFamily::Lab => "Lab",
130            ParameterisedFamily::IccBased => "ICCBased",
131        }
132    }
133}
134
135impl PageColorSpace {
136    /// Convert to the concrete [`Object`] shape the writer emits at
137    /// `/Resources/ColorSpace/<name>`.
138    ///
139    /// Device aliases become `Object::Name`; parameterised entries
140    /// become `Object::Array([Name, Dictionary])`. This keeps the
141    /// conversion in one place so wire-format decisions (e.g. whether
142    /// a future family needs a stream instead of a dict) live with the
143    /// enum they describe, not scattered across the writer.
144    pub(crate) fn to_object(&self) -> Object {
145        match self {
146            PageColorSpace::DeviceAlias(device) => Object::Name(device.pdf_name().to_string()),
147            PageColorSpace::Parameterised { family, params } => Object::Array(vec![
148                Object::Name(family.pdf_name().to_string()),
149                Object::Dictionary(params.clone()),
150            ]),
151            // `IccStream` has no inline `Object` form: a PDF stream MUST be an
152            // indirect object, so it cannot be inlined into the resource dict.
153            // The writer emits it via `icc_stream_parts` (allocating a stream
154            // object) and never routes `IccStream` through `to_object`. Reaching
155            // here is a programmer error — panic rather than silently emit a
156            // dict that drops the profile bytes.
157            PageColorSpace::IccStream { .. } => {
158                unreachable!("IccStream must be emitted via icc_stream_parts, not to_object")
159            }
160        }
161    }
162
163    /// If this is an [`Self::IccStream`], return the ICC stream dictionary
164    /// (`/N`, `/Alternate`, optional `/Range`) and the raw profile bytes for
165    /// the writer to emit as an indirect stream object. Returns `None` for the
166    /// inline (name / parameterised-dict) variants.
167    ///
168    /// `/Range` is omitted when it equals the ISO 32000-1 §8.6.5.5 Table 66
169    /// default (`[0 1]` per component), keeping the stream dict minimal for the
170    /// common device-range profiles while preserving non-default ranges (e.g.
171    /// Lab's `[0 100 -128 127 -128 127]`).
172    pub(crate) fn icc_stream_parts(&self) -> Option<(Dictionary, Vec<u8>)> {
173        match self {
174            PageColorSpace::IccStream {
175                n,
176                alternate,
177                profile_data,
178                range,
179            } => {
180                debug_assert!(
181                    !matches!(alternate, DeviceColorSpace::Pattern),
182                    "/Alternate must not be Pattern (ISO 32000-1 §8.6.5.5)"
183                );
184                let mut dict = Dictionary::new();
185                dict.set("N", Object::Integer(*n as i64));
186                dict.set("Alternate", Object::Name(alternate.pdf_name().to_string()));
187                if let Some(r) = range {
188                    // The ICCBased default range is `[0 1]` per component; only
189                    // emit `/Range` when the profile deviates from it.
190                    let default: Vec<f64> = (0..*n).flat_map(|_| [0.0, 1.0]).collect();
191                    if *r != default {
192                        dict.set(
193                            "Range",
194                            Object::Array(r.iter().map(|&x| Object::Real(x)).collect()),
195                        );
196                    }
197                }
198                Some((dict, (**profile_data).clone()))
199            }
200            _ => None,
201        }
202    }
203}
204
205impl From<&CalGrayColorSpace> for PageColorSpace {
206    /// Bridge a typed [`CalGrayColorSpace`] into a registrable colour space,
207    /// reusing the struct's own [`CalGrayColorSpace::params_dictionary`]
208    /// (ISO 32000-1 §8.6.5.1).
209    fn from(cs: &CalGrayColorSpace) -> Self {
210        PageColorSpace::Parameterised {
211            family: ParameterisedFamily::CalGray,
212            params: cs.params_dictionary(),
213        }
214    }
215}
216
217impl From<&CalRgbColorSpace> for PageColorSpace {
218    /// Bridge a typed [`CalRgbColorSpace`] into a registrable colour space
219    /// (ISO 32000-1 §8.6.5.2).
220    fn from(cs: &CalRgbColorSpace) -> Self {
221        PageColorSpace::Parameterised {
222            family: ParameterisedFamily::CalRgb,
223            params: cs.params_dictionary(),
224        }
225    }
226}
227
228impl From<&LabColorSpace> for PageColorSpace {
229    /// Bridge a typed [`LabColorSpace`] into a registrable colour space
230    /// (ISO 32000-1 §8.6.5.4).
231    fn from(cs: &LabColorSpace) -> Self {
232        PageColorSpace::Parameterised {
233            family: ParameterisedFamily::Lab,
234            params: cs.params_dictionary(),
235        }
236    }
237}
238
239impl From<&IccProfile> for PageColorSpace {
240    /// Bridge an [`IccProfile`] into a stream-backed ICC colour space
241    /// ([`PageColorSpace::IccStream`]), carrying the profile bytes so the
242    /// writer emits a conformant `/ICCBased` stream (ISO 32000-1 §8.6.5.5).
243    ///
244    /// `/Alternate` is derived from the profile's semantic
245    /// [`IccColorSpace`](crate::graphics::IccColorSpace), not just its component
246    /// count. `Lab` profiles fall back to `DeviceRGB` — the closest device space
247    /// (`DeviceColorSpace` has no `Lab` variant; `DeviceRGB` is spec-valid as an
248    /// alternate per §8.6.5.5). `Generic(n)` maps by component count, defaulting
249    /// to `DeviceRGB` for component counts with no exact device space (2, 5, …);
250    /// such alternates are a best-effort fallback only.
251    fn from(profile: &IccProfile) -> Self {
252        let alternate = match profile.color_space {
253            IccColorSpace::Gray => DeviceColorSpace::Gray,
254            IccColorSpace::Cmyk => DeviceColorSpace::Cmyk,
255            IccColorSpace::Rgb | IccColorSpace::Lab => DeviceColorSpace::Rgb,
256            IccColorSpace::Generic(n) => match n {
257                1 => DeviceColorSpace::Gray,
258                4 => DeviceColorSpace::Cmyk,
259                _ => DeviceColorSpace::Rgb,
260            },
261        };
262        PageColorSpace::IccStream {
263            n: profile.components,
264            alternate,
265            profile_data: Arc::new(profile.data.clone()),
266            range: profile.range.clone(),
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn device_alias_to_object_is_name() {
277        let obj = PageColorSpace::DeviceAlias(DeviceColorSpace::Cmyk).to_object();
278        match obj {
279            Object::Name(n) => assert_eq!(n, "DeviceCMYK"),
280            other => panic!("expected Name(DeviceCMYK), got {other:?}"),
281        }
282    }
283
284    #[test]
285    fn parameterised_to_object_is_two_element_array() {
286        let mut params = Dictionary::new();
287        params.set("Gamma", Object::Real(2.2));
288        let obj = PageColorSpace::Parameterised {
289            family: ParameterisedFamily::CalGray,
290            params,
291        }
292        .to_object();
293        match obj {
294            Object::Array(a) => {
295                assert_eq!(a.len(), 2);
296                assert!(matches!(&a[0], Object::Name(n) if n == "CalGray"));
297                assert!(matches!(&a[1], Object::Dictionary(_)));
298            }
299            other => panic!("expected two-element array, got {other:?}"),
300        }
301    }
302
303    #[test]
304    fn device_pdf_name_covers_all_variants() {
305        assert_eq!(DeviceColorSpace::Gray.pdf_name(), "DeviceGray");
306        assert_eq!(DeviceColorSpace::Rgb.pdf_name(), "DeviceRGB");
307        assert_eq!(DeviceColorSpace::Cmyk.pdf_name(), "DeviceCMYK");
308        assert_eq!(DeviceColorSpace::Pattern.pdf_name(), "Pattern");
309    }
310
311    #[test]
312    fn parameterised_pdf_name_covers_all_variants() {
313        assert_eq!(ParameterisedFamily::CalGray.pdf_name(), "CalGray");
314        assert_eq!(ParameterisedFamily::CalRgb.pdf_name(), "CalRGB");
315        assert_eq!(ParameterisedFamily::Lab.pdf_name(), "Lab");
316        assert_eq!(ParameterisedFamily::IccBased.pdf_name(), "ICCBased");
317    }
318}