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}