stet-pdf 0.4.0

PDF output device for stet PostScript interpreter
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
// stet - A PostScript Interpreter
// Copyright (c) 2026 Scott Bowman
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! Convert display list images to PDF image XObjects.
//!
//! Preserves native color spaces (DeviceGray, DeviceRGB, DeviceCMYK, ICCBased,
//! Indexed) for PDF fidelity. Imagemasks are stored as 1-bit stencils.

use std::sync::Arc;
use stet_graphics::color::DeviceColor;
use stet_graphics::device::{ImageColorSpace, ImageParams, TintLookupTable};

/// A prepared image XObject ready for inclusion in a PDF.
pub struct ImageXObject {
    /// Raw sample data in the native color space (or 1-bit mask data).
    pub sample_data: Vec<u8>,
    /// Raw alpha channel (only if image has transparency from Type 3 masked images).
    pub smask_data: Option<Vec<u8>>,
    pub width: u32,
    pub height: u32,
    /// PDF color space name or array.
    pub pdf_color_space: PdfColorSpace,
    /// Bits per component (8 for most, 1 for imagemask).
    pub bits_per_component: u32,
    /// True if this is an imagemask (1-bit stencil).
    pub is_imagemask: bool,
    /// Fill color for imagemask. Stored as the full `DeviceColor` so the
    /// writer can emit it as `k` / `g` / `rg` matching the source paint's
    /// color space (preserving `native_cmyk` for overprint correctness on
    /// the round-trip — `r g b rg` would drop the CMYK channels and let
    /// PDF's OPM-1 painters reinterpret the masked region).
    pub mask_color: Option<DeviceColor>,
    /// Optional Separation/DeviceN paint for an imagemask. When `Some`, the
    /// writer emits the mask fill as `/CSn cs + tint scn` instead of the
    /// process-color fallback, preserving the spot identity required for
    /// correct spot-on-process overprint compositing on the round-trip.
    pub mask_spot_color: Option<stet_graphics::device::SpotColor>,
    /// ImageType 4 color key mask ranges.
    pub color_key_mask: Option<Vec<u8>>,
    /// ICC profile data to embed (if ICCBased color space).
    pub icc_profile: Option<IccProfileData>,
}

/// PDF color space representation.
#[derive(Clone, Debug)]
pub enum PdfColorSpace {
    DeviceGray,
    DeviceRGB,
    DeviceCMYK,
    /// ICCBased — needs profile stream reference (set during XObject build).
    ICCBased {
        n: u32,
    },
    /// Indexed — base space + hival + lookup table.
    Indexed {
        base: Box<PdfColorSpace>,
        hival: u32,
        lookup: Vec<u8>,
    },
    /// Separation — name + alt space + tint lookup table (full emission in item 1.3).
    Separation {
        name: Vec<u8>,
        alt: Box<PdfColorSpace>,
        tint_table: Arc<TintLookupTable>,
    },
    /// DeviceN — names + alt space + tint lookup table (full emission in item 1.3).
    DeviceN {
        names: Vec<Vec<u8>>,
        alt: Box<PdfColorSpace>,
        tint_table: Arc<TintLookupTable>,
    },
}

impl PdfColorSpace {
    /// Number of color components in this color space.
    pub fn num_components(&self) -> usize {
        match self {
            PdfColorSpace::DeviceGray => 1,
            PdfColorSpace::DeviceRGB => 3,
            PdfColorSpace::DeviceCMYK => 4,
            PdfColorSpace::ICCBased { n } => *n as usize,
            PdfColorSpace::Indexed { .. } => 1,
            PdfColorSpace::Separation { .. } => 1,
            PdfColorSpace::DeviceN { tint_table, .. } => tint_table.num_inputs as usize,
        }
    }
}

/// ICC profile data for embedding.
#[derive(Clone)]
pub struct IccProfileData {
    pub data: Vec<u8>,
    pub n: u32,
}

/// Convert raw sample data from a display list image to a PDF-ready XObject.
pub fn convert_image(sample_data: &[u8], params: &ImageParams) -> ImageXObject {
    match &params.color_space {
        ImageColorSpace::Mask {
            color,
            polarity,
            spot_color,
        } => convert_imagemask(sample_data, params, color, *polarity, spot_color.as_ref()),
        ImageColorSpace::PreconvertedRGBA => convert_preconverted_rgba(sample_data, params),
        ImageColorSpace::DeviceGray => ImageXObject {
            sample_data: sample_data.to_vec(),
            smask_data: None,
            width: params.width,
            height: params.height,
            pdf_color_space: PdfColorSpace::DeviceGray,
            bits_per_component: 8,
            is_imagemask: false,
            mask_color: None,
            mask_spot_color: None,
            color_key_mask: params.mask_color.clone(),
            icc_profile: None,
        },
        ImageColorSpace::DeviceRGB => ImageXObject {
            sample_data: sample_data.to_vec(),
            smask_data: None,
            width: params.width,
            height: params.height,
            pdf_color_space: PdfColorSpace::DeviceRGB,
            bits_per_component: 8,
            is_imagemask: false,
            mask_color: None,
            mask_spot_color: None,
            color_key_mask: params.mask_color.clone(),
            icc_profile: None,
        },
        ImageColorSpace::DeviceCMYK => ImageXObject {
            sample_data: sample_data.to_vec(),
            smask_data: None,
            width: params.width,
            height: params.height,
            pdf_color_space: PdfColorSpace::DeviceCMYK,
            bits_per_component: 8,
            is_imagemask: false,
            mask_color: None,
            mask_spot_color: None,
            color_key_mask: params.mask_color.clone(),
            icc_profile: None,
        },
        ImageColorSpace::ICCBased {
            n, profile_data, ..
        } => ImageXObject {
            sample_data: sample_data.to_vec(),
            smask_data: None,
            width: params.width,
            height: params.height,
            pdf_color_space: PdfColorSpace::ICCBased { n: *n },
            bits_per_component: 8,
            is_imagemask: false,
            mask_color: None,
            mask_spot_color: None,
            color_key_mask: params.mask_color.clone(),
            icc_profile: Some(IccProfileData {
                data: (**profile_data).clone(),
                n: *n,
            }),
        },
        ImageColorSpace::Indexed {
            base,
            hival,
            lookup,
        } => {
            let (pdf_base, icc_profile) = match base.as_ref() {
                ImageColorSpace::DeviceGray => (PdfColorSpace::DeviceGray, None),
                ImageColorSpace::DeviceCMYK => (PdfColorSpace::DeviceCMYK, None),
                ImageColorSpace::ICCBased {
                    n, profile_data, ..
                } => (
                    PdfColorSpace::ICCBased { n: *n },
                    Some(IccProfileData {
                        data: (**profile_data).clone(),
                        n: *n,
                    }),
                ),
                ImageColorSpace::Separation {
                    name,
                    alt_space,
                    tint_table,
                } => (
                    PdfColorSpace::Separation {
                        name: name.clone(),
                        alt: Box::new(image_cs_to_pdf_cs(alt_space)),
                        tint_table: tint_table.clone(),
                    },
                    None,
                ),
                ImageColorSpace::DeviceN {
                    names,
                    alt_space,
                    tint_table,
                } => (
                    PdfColorSpace::DeviceN {
                        names: names.clone(),
                        alt: Box::new(image_cs_to_pdf_cs(alt_space)),
                        tint_table: tint_table.clone(),
                    },
                    None,
                ),
                _ => (PdfColorSpace::DeviceRGB, None),
            };
            ImageXObject {
                sample_data: sample_data.to_vec(),
                smask_data: None,
                width: params.width,
                height: params.height,
                pdf_color_space: PdfColorSpace::Indexed {
                    base: Box::new(pdf_base),
                    hival: *hival,
                    lookup: lookup.clone(),
                },
                bits_per_component: 8,
                is_imagemask: false,
                mask_color: None,
                mask_spot_color: None,
                color_key_mask: params.mask_color.clone(),
                icc_profile,
            }
        }
        ImageColorSpace::Separation {
            name,
            alt_space,
            tint_table,
        } => {
            let pdf_alt = image_cs_to_pdf_cs(alt_space);
            ImageXObject {
                sample_data: sample_data.to_vec(),
                smask_data: None,
                width: params.width,
                height: params.height,
                pdf_color_space: PdfColorSpace::Separation {
                    name: name.clone(),
                    alt: Box::new(pdf_alt),
                    tint_table: tint_table.clone(),
                },
                bits_per_component: 8,
                is_imagemask: false,
                mask_color: None,
                mask_spot_color: None,
                color_key_mask: params.mask_color.clone(),
                icc_profile: None,
            }
        }
        ImageColorSpace::DeviceN {
            names,
            alt_space,
            tint_table,
        } => {
            let pdf_alt = image_cs_to_pdf_cs(alt_space);
            ImageXObject {
                sample_data: sample_data.to_vec(),
                smask_data: None,
                width: params.width,
                height: params.height,
                pdf_color_space: PdfColorSpace::DeviceN {
                    names: names.clone(),
                    alt: Box::new(pdf_alt),
                    tint_table: tint_table.clone(),
                },
                bits_per_component: 8,
                is_imagemask: false,
                mask_color: None,
                mask_spot_color: None,
                color_key_mask: params.mask_color.clone(),
                icc_profile: None,
            }
        }
        // CIE-based spaces: convert through CIE pipeline to sRGB
        ImageColorSpace::CIEBasedABC { params: cie_params } => {
            let npixels = (params.width * params.height) as usize;
            let mut rgb = Vec::with_capacity(npixels * 3);
            for i in 0..npixels {
                let si = i * 3;
                let a = sample_data.get(si).copied().unwrap_or(0) as f64 / 255.0;
                let b = sample_data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0;
                let c = sample_data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0;
                let color = DeviceColor::from_cie_abc(a, b, c, cie_params);
                rgb.push((color.r * 255.0).round().clamp(0.0, 255.0) as u8);
                rgb.push((color.g * 255.0).round().clamp(0.0, 255.0) as u8);
                rgb.push((color.b * 255.0).round().clamp(0.0, 255.0) as u8);
            }
            ImageXObject {
                sample_data: rgb,
                smask_data: None,
                width: params.width,
                height: params.height,
                pdf_color_space: PdfColorSpace::DeviceRGB,
                bits_per_component: 8,
                is_imagemask: false,
                mask_color: None,
                mask_spot_color: None,
                color_key_mask: params.mask_color.clone(),
                icc_profile: None,
            }
        }
        ImageColorSpace::CIEBasedA { params: cie_params } => {
            let npixels = (params.width * params.height) as usize;
            let mut rgb = Vec::with_capacity(npixels * 3);
            for i in 0..npixels {
                let val = sample_data.get(i).copied().unwrap_or(0) as f64 / 255.0;
                let color = DeviceColor::from_cie_a(val, cie_params);
                rgb.push((color.r * 255.0).round().clamp(0.0, 255.0) as u8);
                rgb.push((color.g * 255.0).round().clamp(0.0, 255.0) as u8);
                rgb.push((color.b * 255.0).round().clamp(0.0, 255.0) as u8);
            }
            ImageXObject {
                sample_data: rgb,
                smask_data: None,
                width: params.width,
                height: params.height,
                pdf_color_space: PdfColorSpace::DeviceRGB,
                bits_per_component: 8,
                is_imagemask: false,
                mask_color: None,
                mask_spot_color: None,
                color_key_mask: params.mask_color.clone(),
                icc_profile: None,
            }
        }
        ImageColorSpace::Lab { range, .. } => {
            let npixels = (params.width * params.height) as usize;
            let mut rgb = Vec::with_capacity(npixels * 3);
            let a_span = range[1] - range[0];
            let b_span = range[3] - range[2];
            for i in 0..npixels {
                let si = i * 3;
                let l = sample_data.get(si).copied().unwrap_or(0) as f64 / 255.0 * 100.0;
                let a = sample_data.get(si + 1).copied().unwrap_or(0) as f64 / 255.0 * a_span
                    + range[0];
                let b = sample_data.get(si + 2).copied().unwrap_or(0) as f64 / 255.0 * b_span
                    + range[2];
                let color = DeviceColor::from_lab(l, a, b, range);
                rgb.push((color.r * 255.0).round().clamp(0.0, 255.0) as u8);
                rgb.push((color.g * 255.0).round().clamp(0.0, 255.0) as u8);
                rgb.push((color.b * 255.0).round().clamp(0.0, 255.0) as u8);
            }
            ImageXObject {
                sample_data: rgb,
                smask_data: None,
                width: params.width,
                height: params.height,
                pdf_color_space: PdfColorSpace::DeviceRGB,
                bits_per_component: 8,
                is_imagemask: false,
                mask_color: None,
                mask_spot_color: None,
                color_key_mask: params.mask_color.clone(),
                icc_profile: None,
            }
        }
        _ => ImageXObject {
            sample_data: sample_data.to_vec(),
            smask_data: None,
            width: params.width,
            height: params.height,
            pdf_color_space: PdfColorSpace::DeviceGray,
            bits_per_component: 8,
            is_imagemask: false,
            mask_color: None,
            mask_spot_color: None,
            color_key_mask: params.mask_color.clone(),
            icc_profile: None,
        },
    }
}

/// Convert an imagemask to PDF XObject (1-bit stencil).
fn convert_imagemask(
    raw_bits: &[u8],
    params: &ImageParams,
    color: &DeviceColor,
    polarity: bool,
    spot_color: Option<&stet_graphics::device::SpotColor>,
) -> ImageXObject {
    // PDF imagemask Decode: [1 0] means bit=1 paints (our polarity=true).
    // If polarity=false (bit=0 paints), we need Decode [0 1] which is the PDF default,
    // OR we can invert the bits. We'll pass the mask color and let the content
    // stream set the fill color.
    let mask_data = if polarity {
        // bit=1 paints — this matches PDF [1 0] decode. Use data as-is.
        raw_bits.to_vec()
    } else {
        // bit=0 paints — invert all bits so bit=1 paints in PDF space
        raw_bits.iter().map(|b| !b).collect()
    };

    ImageXObject {
        sample_data: mask_data,
        smask_data: None,
        width: params.width,
        height: params.height,
        pdf_color_space: PdfColorSpace::DeviceGray,
        bits_per_component: 1,
        is_imagemask: true,
        mask_color: Some(color.clone()),
        mask_spot_color: spot_color.cloned(),
        color_key_mask: None,
        icc_profile: None,
    }
}

/// Map an ImageColorSpace to the corresponding PdfColorSpace (for alt-space usage).
fn image_cs_to_pdf_cs(cs: &ImageColorSpace) -> PdfColorSpace {
    match cs {
        ImageColorSpace::DeviceGray => PdfColorSpace::DeviceGray,
        ImageColorSpace::DeviceRGB => PdfColorSpace::DeviceRGB,
        ImageColorSpace::DeviceCMYK => PdfColorSpace::DeviceCMYK,
        _ => PdfColorSpace::DeviceRGB, // fallback
    }
}

/// Convert pre-converted RGBA data to PDF (extract RGB + optional SMask).
fn convert_preconverted_rgba(rgba: &[u8], params: &ImageParams) -> ImageXObject {
    let npixels = (params.width * params.height) as usize;

    let has_alpha = rgba.chunks_exact(4).any(|px| px[3] != 255);

    let mut rgb = Vec::with_capacity(npixels * 3);
    for px in rgba.chunks_exact(4) {
        rgb.push(px[0]);
        rgb.push(px[1]);
        rgb.push(px[2]);
    }

    let smask_data = if has_alpha {
        Some(rgba.chunks_exact(4).map(|px| px[3]).collect())
    } else {
        None
    };

    ImageXObject {
        sample_data: rgb,
        smask_data,
        width: params.width,
        height: params.height,
        pdf_color_space: PdfColorSpace::DeviceRGB,
        bits_per_component: 8,
        is_imagemask: false,
        mask_color: None,
        mask_spot_color: None,
        color_key_mask: None,
        icc_profile: None,
    }
}