Skip to main content

stet_pdf/
pdf_device.rs

1// stet - A PostScript Interpreter
2// Copyright (c) 2026 Scott Bowman
3// SPDX-License-Identifier: Apache-2.0 OR MIT
4
5//! PDF output device — accumulates pages and writes a PDF file on finish().
6
7use stet_core::context::Context;
8use stet_core::device::OutputDevice;
9use stet_fonts::geometry::PsPath;
10use stet_graphics::device::{ClipParams, FillParams, ImageParams, StrokeParams};
11use stet_graphics::display_list::DisplayList;
12
13use std::collections::HashMap;
14
15use crate::content_stream::{self, ContentStreamResult, ShadingRef};
16use crate::font_embedder;
17use crate::font_tracker::FontTracker;
18use crate::image_ops::ImageXObject;
19use crate::pdf_objects::PdfObj;
20use crate::pdf_writer::PdfWriter;
21use crate::shading_ops;
22
23/// A single page's data. Display list is stored and content stream generated
24/// at finalize time when Context is available for font width extraction.
25struct PageData {
26    display_list: DisplayList,
27    width_pts: f64,
28    height_pts: f64,
29    page_w: u32,
30    page_h: u32,
31    dpi: f64,
32    trim_box: Option<(f64, f64, f64, f64)>,
33}
34
35/// PDF output device. Accumulates display lists per page and generates
36/// a single PDF file containing all pages on `finish()`.
37pub struct PdfDevice {
38    pages: Vec<PageData>,
39    page_w: u32,
40    page_h: u32,
41    dpi: f64,
42    output_path: Option<String>,
43    pending_trim_box: Option<(f64, f64, f64, f64)>,
44    /// ICC output profile bytes, retained for forward compatibility with a
45    /// future PDF/X-4 OutputIntent implementation. Currently unused.
46    #[allow(dead_code)]
47    output_profile: Option<Vec<u8>>,
48}
49
50impl PdfDevice {
51    /// Create a new PDF device with the given page dimensions and DPI.
52    pub fn new(width: u32, height: u32, dpi: f64) -> Self {
53        Self {
54            pages: Vec::new(),
55            page_w: width,
56            page_h: height,
57            dpi,
58            output_path: None,
59            pending_trim_box: None,
60            output_profile: None,
61        }
62    }
63
64    /// Set the trim box for the next page (in PDF points, lower-left origin).
65    pub fn set_trim_box(&mut self, llx: f64, lly: f64, urx: f64, ury: f64) {
66        self.pending_trim_box = Some((llx, lly, urx, ury));
67    }
68
69    /// Set an ICC output profile.
70    ///
71    /// Previously embedded as a PDF/X-3 OutputIntent, but the emitted output
72    /// contained transparency features (soft masks) that PDF/X-3 prohibits.
73    /// The OutputIntent emission path has been removed pending a correct
74    /// PDF/X-4 implementation; calling this currently has no effect on the
75    /// output. The setter is retained so the API is forward-compatible with
76    /// the eventual X-4 work.
77    #[deprecated(
78        note = "OutputIntent emission is temporarily disabled pending PDF/X-4 support; calling this has no effect"
79    )]
80    pub fn set_output_profile(&mut self, bytes: Vec<u8>) {
81        self.output_profile = Some(bytes);
82    }
83
84    /// Build the PDF document into a byte vector.
85    ///
86    /// Returns the complete PDF file contents. The device must have at least
87    /// one page (call after `finish()` or `finish_with_context()`).
88    pub fn take_pdf_bytes(&self) -> Option<Vec<u8>> {
89        if self.pages.is_empty() {
90            return None;
91        }
92        let (writer, catalog_ref, info_ref) = self.build_pdf(None).ok()?;
93        let mut buf = Vec::new();
94        writer
95            .write_pdf(&mut buf, catalog_ref, Some(info_ref))
96            .ok()?;
97        Some(buf)
98    }
99
100    /// Build the PDF document into a byte vector, using Context for font data.
101    pub fn take_pdf_bytes_with_context(&self, ctx: &Context) -> Option<Vec<u8>> {
102        if self.pages.is_empty() {
103            return None;
104        }
105        let (writer, catalog_ref, info_ref) = self.build_pdf(Some(ctx)).ok()?;
106        let mut buf = Vec::new();
107        writer
108            .write_pdf(&mut buf, catalog_ref, Some(info_ref))
109            .ok()?;
110        Some(buf)
111    }
112
113    /// Assemble all accumulated pages into a PDF and write to the output file.
114    fn write_pdf(&self, ctx: Option<&Context>) -> Result<(), String> {
115        let path = self.output_path.as_deref().ok_or("no output path set")?;
116        let (writer, catalog_ref, info_ref) = self.build_pdf(ctx)?;
117
118        let file = std::fs::File::create(path).map_err(|e| format!("create {}: {}", path, e))?;
119        let mut bw = std::io::BufWriter::new(file);
120        writer
121            .write_pdf(&mut bw, catalog_ref, Some(info_ref))
122            .map_err(|e| format!("write {}: {}", path, e))?;
123
124        eprintln!("PDF written: {} ({} pages)", path, self.pages.len());
125        Ok(())
126    }
127
128    /// Build the contents of the /Info dict. Starts with device defaults
129    /// (Producer + auto-derived Title + UTC CreationDate) and lets any
130    /// `/DOCINFO` pdfmark record on `ctx.pdfmark_buffer` override or
131    /// extend each key. The pdfmark buffer is *not* drained here; phases
132    /// past Phase 1 may want to consult it for separate concerns.
133    fn build_info_dict(&self, ctx: Option<&Context>) -> Vec<(Vec<u8>, PdfObj)> {
134        let docinfo = ctx.map(|c| collect_docinfo(c)).unwrap_or_default();
135
136        let producer = docinfo
137            .producer
138            .clone()
139            .unwrap_or_else(|| "stet".to_string());
140        let mut entries: Vec<(Vec<u8>, PdfObj)> = vec![(
141            b"Producer".to_vec(),
142            PdfObj::LitString(producer.into_bytes()),
143        )];
144
145        // Title — pdfmark wins; otherwise derive from filename.
146        let title = docinfo.title.clone().or_else(|| {
147            self.output_path
148                .as_deref()
149                .and_then(|p| std::path::Path::new(p).file_stem())
150                .and_then(|s| s.to_str())
151                .map(|s| s.to_string())
152        });
153        if let Some(t) = title {
154            entries.push((b"Title".to_vec(), PdfObj::LitString(t.into_bytes())));
155        }
156
157        for (key, value) in [
158            (&b"Author"[..], &docinfo.author),
159            (&b"Subject"[..], &docinfo.subject),
160            (&b"Keywords"[..], &docinfo.keywords),
161            (&b"Creator"[..], &docinfo.creator),
162        ] {
163            if let Some(v) = value {
164                entries.push((key.to_vec(), PdfObj::LitString(v.clone().into_bytes())));
165            }
166        }
167
168        // CreationDate — pdfmark override or default to "now in UTC".
169        let creation_date = docinfo
170            .creation_date_string()
171            .unwrap_or_else(default_now_pdf_date);
172        entries.push((
173            b"CreationDate".to_vec(),
174            PdfObj::LitString(creation_date.into_bytes()),
175        ));
176
177        if let Some(md) = docinfo.mod_date_string() {
178            entries.push((b"ModDate".to_vec(), PdfObj::LitString(md.into_bytes())));
179        }
180
181        if let Some(t) = docinfo.trapped {
182            let name: &[u8] = match t {
183                stet_core::pdfmark::TrappedState::True => b"True",
184                stet_core::pdfmark::TrappedState::False => b"False",
185                stet_core::pdfmark::TrappedState::Unknown => b"Unknown",
186                _ => b"Unknown",
187            };
188            entries.push((b"Trapped".to_vec(), PdfObj::Name(name.to_vec())));
189        }
190
191        entries
192    }
193
194    /// Build the PDF document, returning the writer and object refs.
195    fn build_pdf(&self, ctx: Option<&Context>) -> Result<(PdfWriter, u32, u32), String> {
196        let mut writer = PdfWriter::new();
197
198        // Pre-allocate catalog and pages objects
199        let catalog_ref = writer.alloc_obj();
200        let pages_ref = writer.alloc_obj();
201
202        // Document-level font tracker — shared across all pages
203        let mut font_tracker = FontTracker::new();
204
205        // First pass: build content streams and register fonts
206        let mut page_results: Vec<(ContentStreamResult, &PageData)> = Vec::new();
207        for page in &self.pages {
208            let result = content_stream::build_content_stream(
209                &page.display_list,
210                page.page_w,
211                page.page_h,
212                page.dpi,
213                ctx,
214                &mut font_tracker,
215            );
216            page_results.push((result, page));
217        }
218
219        // Embed each unique font once at document level
220        let font_obj_map: HashMap<String, u32> =
221            self.embed_all_fonts(&mut writer, &font_tracker, ctx);
222
223        // Pre-allocate page object numbers so annotations can reference
224        // their target pages by indirect ref before the page dict is
225        // written, and so /Annots arrays can be assembled at build time.
226        let page_refs: Vec<u32> = (0..page_results.len())
227            .map(|_| writer.alloc_obj())
228            .collect();
229
230        // Build per-page annotation objects up front so each page dict
231        // gets its /Annots array. Widget annotations are split off and
232        // emitted by `form_fields::write_form`, which owns the field
233        // tree they sit under; the rest go through the standard
234        // annotation path.
235        let mut per_page_annots: Vec<Vec<u32>> = ctx
236            .map(|c| {
237                let records: Vec<stet_core::pdfmark::AnnotationRecord> = c
238                    .pdfmark_buffer
239                    .records()
240                    .iter()
241                    .filter_map(|r| match r {
242                        stet_core::pdfmark::PdfMarkRecord::Annotation(rec) => Some(rec.clone()),
243                        _ => None,
244                    })
245                    .collect();
246                if records.is_empty() {
247                    return vec![Vec::new(); page_refs.len()];
248                }
249                crate::annotations::collect_per_page(&mut writer, &records, &page_refs)
250            })
251            .unwrap_or_else(|| vec![Vec::new(); page_refs.len()]);
252
253        // Form fields — Widget annotations + /FORM record assembled
254        // into /AcroForm. The output's per-page widget refs merge into
255        // per_page_annots above so each page's /Annots array carries
256        // both standard annotations and widget annotations.
257        let acroform_output = ctx.and_then(|c| {
258            let widgets: Vec<(usize, stet_core::pdfmark::AnnotationRecord)> = c
259                .pdfmark_buffer
260                .records()
261                .iter()
262                .enumerate()
263                .filter_map(|(i, r)| match r {
264                    stet_core::pdfmark::PdfMarkRecord::Annotation(rec)
265                        if matches!(
266                            rec.subtype,
267                            stet_core::pdfmark::AnnotationSubtype::Widget(_)
268                        ) =>
269                    {
270                        Some((i, rec.clone()))
271                    }
272                    _ => None,
273                })
274                .collect();
275            let form_record = c
276                .pdfmark_buffer
277                .records()
278                .iter()
279                .filter_map(|r| match r {
280                    stet_core::pdfmark::PdfMarkRecord::Form(rec) => Some(rec.clone()),
281                    _ => None,
282                })
283                .reduce(|acc, next| next.merge_over(&acc));
284            crate::form_fields::write_form(
285                &mut writer,
286                &widgets,
287                form_record.as_ref(),
288                page_refs.len(),
289            )
290        });
291        if let Some(out) = &acroform_output {
292            for (i, refs) in out.per_page_widget_refs.iter().enumerate() {
293                per_page_annots[i].extend(refs);
294            }
295        }
296
297        // Layer /PAGES (document-wide defaults) under /PAGE (per-page
298        // overrides) into one PageOverride per page. Later /PAGE
299        // records override earlier ones key-by-key, matching the same
300        // "later wins" rule we apply to /DOCINFO.
301        let per_page_overrides = compute_page_overrides(ctx, page_refs.len());
302
303        // Second pass: build page objects referencing shared font objects
304        for (i, (result, page)) in page_results.iter().enumerate() {
305            self.build_page(
306                &mut writer,
307                page,
308                pages_ref,
309                page_refs[i],
310                result,
311                &font_obj_map,
312                &mut font_tracker,
313                &per_page_annots[i],
314                &per_page_overrides[i],
315            )?;
316        }
317
318        // Pages object
319        writer.set_object(
320            pages_ref,
321            &PdfObj::Dict(vec![
322                (b"Type".to_vec(), PdfObj::name("Pages")),
323                (
324                    b"Kids".to_vec(),
325                    PdfObj::Array(page_refs.iter().map(|&r| PdfObj::Ref(r)).collect()),
326                ),
327                (b"Count".to_vec(), PdfObj::Int(page_refs.len() as i64)),
328            ]),
329        );
330
331        // Outlines — emitted from `/OUT pdfmark` records on the
332        // pdfmark buffer. Returns `None` when no /OUT records were
333        // issued, in which case /Catalog stays free of /Outlines.
334        let outlines_ref = ctx.and_then(|c| {
335            let records: Vec<stet_core::pdfmark::OutlineRecord> = c
336                .pdfmark_buffer
337                .records()
338                .iter()
339                .filter_map(|r| match r {
340                    stet_core::pdfmark::PdfMarkRecord::Outline(rec) => Some(rec.clone()),
341                    _ => None,
342                })
343                .collect();
344            if records.is_empty() {
345                return None;
346            }
347            let tree = stet_core::pdfmark::build_outline_tree(&records);
348            crate::outline::write_outline_tree(&mut writer, &tree, &page_refs)
349        });
350
351        // /Names — combined tree of /Dests (from /DEST records) and
352        // /EmbeddedFiles (from /EMBED records). Each leaf is built
353        // separately, then `write_names_root` combines them into one
354        // catalog-level dict.
355        let dests_leaf = ctx.and_then(|c| {
356            let records: Vec<stet_core::pdfmark::DestRecord> = c
357                .pdfmark_buffer
358                .records()
359                .iter()
360                .filter_map(|r| match r {
361                    stet_core::pdfmark::PdfMarkRecord::Dest(rec) => Some(rec.clone()),
362                    _ => None,
363                })
364                .collect();
365            crate::names::build_dests_leaf(&mut writer, &records, &page_refs)
366        });
367        let embedded_files_leaf = ctx.and_then(|c| {
368            let records: Vec<stet_core::pdfmark::EmbedRecord> = c
369                .pdfmark_buffer
370                .records()
371                .iter()
372                .filter_map(|r| match r {
373                    stet_core::pdfmark::PdfMarkRecord::Embed(rec) => Some(rec.clone()),
374                    _ => None,
375                })
376                .collect();
377            crate::attachments::build_embedded_files_leaf(&mut writer, &records)
378        });
379        let names_ref =
380            crate::names::write_names_root(&mut writer, dests_leaf, embedded_files_leaf);
381
382        // /VIEWERPREFERENCES — merge all records into one effective
383        // viewer-prefs bag, then split into the `/ViewerPreferences`
384        // indirect object plus the catalog-level `/PageLayout` and
385        // `/PageMode` entries which sit on `/Catalog` directly.
386        let merged_prefs = ctx.map(collect_viewer_prefs).unwrap_or_default();
387        let viewer_prefs_ref = crate::metadata::write_viewer_prefs(&mut writer, &merged_prefs);
388
389        // /Metadata — last record wins; emit the stream object.
390        let metadata_ref = ctx.and_then(|c| {
391            c.pdfmark_buffer
392                .records()
393                .iter()
394                .rev()
395                .find_map(|r| match r {
396                    stet_core::pdfmark::PdfMarkRecord::Metadata(rec) => Some(rec.clone()),
397                    _ => None,
398                })
399                .map(|rec| crate::metadata::write_xmp_metadata(&mut writer, &rec))
400        });
401
402        // Catalog
403        let mut catalog_entries = vec![
404            (b"Type".to_vec(), PdfObj::name("Catalog")),
405            (b"Pages".to_vec(), PdfObj::Ref(pages_ref)),
406        ];
407        if let Some(outline_ref) = outlines_ref {
408            catalog_entries.push((b"Outlines".to_vec(), PdfObj::Ref(outline_ref)));
409        }
410        if let Some(names_ref) = names_ref {
411            catalog_entries.push((b"Names".to_vec(), PdfObj::Ref(names_ref)));
412        }
413        if let Some(viewer_prefs_ref) = viewer_prefs_ref {
414            catalog_entries.push((b"ViewerPreferences".to_vec(), PdfObj::Ref(viewer_prefs_ref)));
415        }
416        // /PageLayout — only the producer-supplied value, validated.
417        if let Some(layout_bytes) = merged_prefs
418            .page_layout
419            .as_deref()
420            .and_then(crate::metadata::validated_page_layout)
421        {
422            catalog_entries.push((b"PageLayout".to_vec(), PdfObj::Name(layout_bytes.to_vec())));
423        }
424        // /PageMode — producer's /VIEWERPREFERENCES /PageMode wins;
425        // otherwise fall back to /UseOutlines when an outline tree
426        // exists so viewers open the bookmark pane by default.
427        let effective_page_mode: Option<Vec<u8>> = merged_prefs
428            .page_mode
429            .as_deref()
430            .and_then(crate::metadata::validated_page_mode)
431            .map(|v| v.to_vec())
432            .or_else(|| outlines_ref.map(|_| b"UseOutlines".to_vec()));
433        if let Some(mode) = effective_page_mode {
434            catalog_entries.push((b"PageMode".to_vec(), PdfObj::Name(mode)));
435        }
436        if let Some(metadata_ref) = metadata_ref {
437            catalog_entries.push((b"Metadata".to_vec(), PdfObj::Ref(metadata_ref)));
438        }
439        if let Some(out) = &acroform_output {
440            catalog_entries.push((b"AcroForm".to_vec(), PdfObj::Ref(out.acroform_ref)));
441        }
442
443        writer.set_object(catalog_ref, &PdfObj::Dict(catalog_entries));
444
445        // Info dictionary — start with device defaults, then let any
446        // /DOCINFO pdfmark records override or extend.
447        let info_ref = writer.alloc_obj();
448        let info_entries = self.build_info_dict(ctx);
449        writer.set_object(info_ref, &PdfObj::Dict(info_entries));
450
451        Ok((writer, catalog_ref, info_ref))
452    }
453
454    /// Embed all tracked fonts once at document level.
455    /// Returns a map from PDF font name (e.g. "F0") to the PDF object number.
456    fn embed_all_fonts(
457        &self,
458        writer: &mut PdfWriter,
459        font_tracker: &FontTracker,
460        ctx: Option<&Context>,
461    ) -> HashMap<String, u32> {
462        let mut map = HashMap::new();
463        for usage in font_tracker.fonts() {
464            let font_ref = if let Some(c) = ctx {
465                font_embedder::build_font_resource(writer, usage, c).unwrap_or_else(|| {
466                    let tu = font_embedder::build_tounicode_for_fallback(writer, usage, c);
467                    self.build_font_reference(writer, usage, tu)
468                })
469            } else {
470                self.build_font_reference(writer, usage, None)
471            };
472            map.insert(usage.pdf_name.clone(), font_ref);
473        }
474        map
475    }
476
477    /// Build PDF objects for a single page. The page's indirect object
478    /// number is pre-allocated by the caller (so annotations can target
479    /// the page before its dict is written), and the per-page
480    /// annotation refs are passed in for inclusion in the page's
481    /// `/Annots` array.
482    #[allow(clippy::too_many_arguments)]
483    fn build_page(
484        &self,
485        writer: &mut PdfWriter,
486        page: &PageData,
487        pages_ref: u32,
488        page_ref: u32,
489        result: &ContentStreamResult,
490        font_obj_map: &HashMap<String, u32>,
491        font_tracker: &mut FontTracker,
492        annot_refs: &[u32],
493        overrides: &EffectivePageOverride,
494    ) -> Result<(), String> {
495        let ContentStreamResult {
496            content,
497            images,
498            shading_refs,
499            used_font_names,
500            ext_gstate_dicts,
501            color_spaces,
502            pattern_refs,
503            pattern_cs_entries,
504            transfer_refs,
505            halftone_refs,
506            bg_ucr_refs,
507        } = result;
508
509        // Build image XObjects
510        let mut xobject_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
511        for (i, img) in images.iter().enumerate() {
512            let img_ref = self.build_image_xobject(writer, img);
513            xobject_entries.push((format!("Im{}", i).into_bytes(), PdfObj::Ref(img_ref)));
514        }
515
516        // Build shading objects
517        let mut shading_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
518        for (i, sh_ref) in shading_refs.iter().enumerate() {
519            let sh_obj = match sh_ref {
520                ShadingRef::Axial(p) => shading_ops::build_axial_shading(writer, p),
521                ShadingRef::Radial(p) => shading_ops::build_radial_shading(writer, p),
522                ShadingRef::Mesh(p) => shading_ops::build_mesh_shading(writer, p),
523                ShadingRef::Patch(p) => shading_ops::build_patch_shading(writer, p),
524            };
525            shading_entries.push((format!("Sh{}", i).into_bytes(), PdfObj::Ref(sh_obj)));
526        }
527
528        // Build per-page font resource references (pointing to shared document-level objects)
529        let mut font_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
530        for name in used_font_names {
531            if let Some(&obj_ref) = font_obj_map.get(name) {
532                font_entries.push((name.clone().into_bytes(), PdfObj::Ref(obj_ref)));
533            }
534        }
535
536        // Resources dict
537        let mut resources: Vec<(Vec<u8>, PdfObj)> = Vec::new();
538        if !font_entries.is_empty() {
539            resources.push((b"Font".to_vec(), PdfObj::Dict(font_entries)));
540        }
541        if !xobject_entries.is_empty() {
542            resources.push((b"XObject".to_vec(), PdfObj::Dict(xobject_entries)));
543        }
544        if !shading_entries.is_empty() {
545            resources.push((b"Shading".to_vec(), PdfObj::Dict(shading_entries)));
546        }
547
548        // Build ExtGState resources
549        if !ext_gstate_dicts.is_empty() {
550            let mut gs_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
551            for (i, gs_dict) in ext_gstate_dicts.iter().enumerate() {
552                // Rebuild entries (PdfObj doesn't derive Clone)
553                let mut entries: Vec<(Vec<u8>, PdfObj)> = gs_dict
554                    .entries
555                    .iter()
556                    .map(|(k, v)| {
557                        let obj = match v {
558                            PdfObj::Bool(b) => PdfObj::Bool(*b),
559                            PdfObj::Int(n) => PdfObj::Int(*n),
560                            PdfObj::Name(n) => PdfObj::Name(n.clone()),
561                            _ => PdfObj::Null,
562                        };
563                        (k.clone(), obj)
564                    })
565                    .collect();
566
567                // Check if this ExtGState has a transfer function reference
568                if let Some(tr) = transfer_refs.iter().find(|r| r.ext_gstate_idx == i) {
569                    let tr2_value = build_transfer_tr2(writer, &tr.tables, tr.is_color);
570                    entries.push((b"TR2".to_vec(), tr2_value));
571                }
572
573                // Check if this ExtGState has a halftone reference
574                if let Some(hr) = halftone_refs.iter().find(|r| r.ext_gstate_idx == i) {
575                    let ht_value = build_halftone_ht(writer, &hr.state);
576                    entries.push((b"HT".to_vec(), ht_value));
577                }
578
579                // Check if this ExtGState has BG/UCR references
580                if let Some(br) = bg_ucr_refs.iter().find(|r| r.ext_gstate_idx == i) {
581                    if let Some(ref bg) = br.state.bg {
582                        let func_ref = build_type0_function(writer, bg);
583                        entries.push((b"BG2".to_vec(), PdfObj::Ref(func_ref)));
584                    }
585                    if let Some(ref ucr) = br.state.ucr {
586                        let func_ref = build_type0_function_signed(writer, ucr);
587                        entries.push((b"UCR2".to_vec(), PdfObj::Ref(func_ref)));
588                    }
589                }
590
591                let gs_ref = writer.add_object(&PdfObj::Dict(entries));
592                gs_entries.push((format!("GS{}", i).into_bytes(), PdfObj::Ref(gs_ref)));
593            }
594            resources.push((b"ExtGState".to_vec(), PdfObj::Dict(gs_entries)));
595        }
596
597        // Build ColorSpace resources (for Separation/DeviceN fill/stroke colors)
598        let mut cs_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
599        for (name, spot_cs) in color_spaces {
600            let cs_obj = build_spot_colorspace(spot_cs, writer);
601            cs_entries.push((name.clone().into_bytes(), cs_obj));
602        }
603        // Add uncolored pattern color space entries (e.g., [/Pattern /DeviceRGB])
604        for (name, cs_obj) in pattern_cs_entries {
605            // Reconstruct PdfObj since it doesn't derive Clone
606            let obj = match cs_obj {
607                PdfObj::Array(items) => {
608                    let cloned: Vec<PdfObj> = items
609                        .iter()
610                        .map(|item| match item {
611                            PdfObj::Name(n) => PdfObj::Name(n.clone()),
612                            PdfObj::Int(n) => PdfObj::Int(*n),
613                            PdfObj::Real(n) => PdfObj::Real(*n),
614                            PdfObj::Ref(r) => PdfObj::Ref(*r),
615                            _ => PdfObj::Null,
616                        })
617                        .collect();
618                    PdfObj::Array(cloned)
619                }
620                _ => PdfObj::Null,
621            };
622            cs_entries.push((name.clone().into_bytes(), obj));
623        }
624        if !cs_entries.is_empty() {
625            resources.push((b"ColorSpace".to_vec(), PdfObj::Dict(cs_entries)));
626        }
627
628        // Build Pattern XObject resources
629        if !pattern_refs.is_empty() {
630            let mut pattern_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
631            for (i, pat_ref) in pattern_refs.iter().enumerate() {
632                let tile_result =
633                    content_stream::build_tile_content_stream(&pat_ref.tile, font_tracker);
634
635                // Build tile resources
636                let mut tile_resources: Vec<(Vec<u8>, PdfObj)> = Vec::new();
637
638                // Tile images
639                if !tile_result.images.is_empty() {
640                    let mut tile_xobj: Vec<(Vec<u8>, PdfObj)> = Vec::new();
641                    for (j, img) in tile_result.images.iter().enumerate() {
642                        let img_ref = self.build_image_xobject(writer, img);
643                        tile_xobj.push((format!("Im{}", j).into_bytes(), PdfObj::Ref(img_ref)));
644                    }
645                    tile_resources.push((b"XObject".to_vec(), PdfObj::Dict(tile_xobj)));
646                }
647
648                // Tile shadings
649                if !tile_result.shading_refs.is_empty() {
650                    let mut tile_sh: Vec<(Vec<u8>, PdfObj)> = Vec::new();
651                    for (j, sh_ref) in tile_result.shading_refs.iter().enumerate() {
652                        let sh_obj = match sh_ref {
653                            ShadingRef::Axial(p) => shading_ops::build_axial_shading(writer, p),
654                            ShadingRef::Radial(p) => shading_ops::build_radial_shading(writer, p),
655                            ShadingRef::Mesh(p) => shading_ops::build_mesh_shading(writer, p),
656                            ShadingRef::Patch(p) => shading_ops::build_patch_shading(writer, p),
657                        };
658                        tile_sh.push((format!("Sh{}", j).into_bytes(), PdfObj::Ref(sh_obj)));
659                    }
660                    tile_resources.push((b"Shading".to_vec(), PdfObj::Dict(tile_sh)));
661                }
662
663                // Tile fonts
664                if !tile_result.used_font_names.is_empty() {
665                    let mut tile_fonts: Vec<(Vec<u8>, PdfObj)> = Vec::new();
666                    for name in &tile_result.used_font_names {
667                        if let Some(&obj_ref) = font_obj_map.get(name) {
668                            tile_fonts.push((name.clone().into_bytes(), PdfObj::Ref(obj_ref)));
669                        }
670                    }
671                    if !tile_fonts.is_empty() {
672                        tile_resources.push((b"Font".to_vec(), PdfObj::Dict(tile_fonts)));
673                    }
674                }
675
676                // Tile ExtGState
677                if !tile_result.ext_gstate_dicts.is_empty() {
678                    let mut tile_gs: Vec<(Vec<u8>, PdfObj)> = Vec::new();
679                    for (j, gs_dict) in tile_result.ext_gstate_dicts.iter().enumerate() {
680                        let mut entries: Vec<(Vec<u8>, PdfObj)> = gs_dict
681                            .entries
682                            .iter()
683                            .map(|(k, v)| {
684                                let obj = match v {
685                                    PdfObj::Bool(b) => PdfObj::Bool(*b),
686                                    PdfObj::Int(n) => PdfObj::Int(*n),
687                                    PdfObj::Name(n) => PdfObj::Name(n.clone()),
688                                    _ => PdfObj::Null,
689                                };
690                                (k.clone(), obj)
691                            })
692                            .collect();
693                        if let Some(tr) = tile_result
694                            .transfer_refs
695                            .iter()
696                            .find(|r| r.ext_gstate_idx == j)
697                        {
698                            let tr2_value = build_transfer_tr2(writer, &tr.tables, tr.is_color);
699                            entries.push((b"TR2".to_vec(), tr2_value));
700                        }
701                        if let Some(hr) = tile_result
702                            .halftone_refs
703                            .iter()
704                            .find(|r| r.ext_gstate_idx == j)
705                        {
706                            let ht_value = build_halftone_ht(writer, &hr.state);
707                            entries.push((b"HT".to_vec(), ht_value));
708                        }
709                        if let Some(br) = tile_result
710                            .bg_ucr_refs
711                            .iter()
712                            .find(|r| r.ext_gstate_idx == j)
713                        {
714                            if let Some(ref bg) = br.state.bg {
715                                let func_ref = build_type0_function(writer, bg);
716                                entries.push((b"BG2".to_vec(), PdfObj::Ref(func_ref)));
717                            }
718                            if let Some(ref ucr) = br.state.ucr {
719                                let func_ref = build_type0_function_signed(writer, ucr);
720                                entries.push((b"UCR2".to_vec(), PdfObj::Ref(func_ref)));
721                            }
722                        }
723                        let gs_ref = writer.add_object(&PdfObj::Dict(entries));
724                        tile_gs.push((format!("GS{}", j).into_bytes(), PdfObj::Ref(gs_ref)));
725                    }
726                    tile_resources.push((b"ExtGState".to_vec(), PdfObj::Dict(tile_gs)));
727                }
728
729                // Tile color spaces
730                if !tile_result.color_spaces.is_empty() {
731                    let mut tile_cs: Vec<(Vec<u8>, PdfObj)> = Vec::new();
732                    for (name, spot_cs) in &tile_result.color_spaces {
733                        let cs_obj = build_spot_colorspace(spot_cs, writer);
734                        tile_cs.push((name.clone().into_bytes(), cs_obj));
735                    }
736                    tile_resources.push((b"ColorSpace".to_vec(), PdfObj::Dict(tile_cs)));
737                }
738
739                // Build Pattern stream object
740                let m = &pat_ref.pattern_matrix;
741                let pat_dict = vec![
742                    (b"Type".to_vec(), PdfObj::name("Pattern")),
743                    (b"PatternType".to_vec(), PdfObj::Int(1)),
744                    (
745                        b"PaintType".to_vec(),
746                        PdfObj::Int(pat_ref.paint_type as i64),
747                    ),
748                    (b"TilingType".to_vec(), PdfObj::Int(1)),
749                    (
750                        b"BBox".to_vec(),
751                        // Expand BBox slightly beyond XStep/YStep so adjacent tiles
752                        // overlap, eliminating hairline seam artifacts in PDF viewers.
753                        PdfObj::Array(vec![
754                            PdfObj::Real(pat_ref.bbox[0] - 0.5),
755                            PdfObj::Real(pat_ref.bbox[1] - 0.5),
756                            PdfObj::Real(pat_ref.bbox[2] + 0.5),
757                            PdfObj::Real(pat_ref.bbox[3] + 0.5),
758                        ]),
759                    ),
760                    (b"XStep".to_vec(), PdfObj::Real(pat_ref.xstep)),
761                    (b"YStep".to_vec(), PdfObj::Real(pat_ref.ystep)),
762                    (
763                        b"Matrix".to_vec(),
764                        PdfObj::Array(vec![
765                            PdfObj::Real(m.a),
766                            PdfObj::Real(m.b),
767                            PdfObj::Real(m.c),
768                            PdfObj::Real(m.d),
769                            PdfObj::Real(m.tx),
770                            PdfObj::Real(m.ty),
771                        ]),
772                    ),
773                    (b"Resources".to_vec(), PdfObj::Dict(tile_resources)),
774                ];
775
776                let pat_obj = writer.add_stream(pat_dict, &tile_result.content, true);
777                pattern_entries.push((format!("P{}", i).into_bytes(), PdfObj::Ref(pat_obj)));
778            }
779
780            resources.push((b"Pattern".to_vec(), PdfObj::Dict(pattern_entries)));
781        }
782
783        // Content stream
784        let content_ref = writer.add_stream(Vec::new(), content, true);
785
786        // Page object
787        let mut page_entries = vec![
788            (b"Type".to_vec(), PdfObj::name("Page")),
789            (b"Parent".to_vec(), PdfObj::Ref(pages_ref)),
790            (
791                b"MediaBox".to_vec(),
792                PdfObj::Array(vec![
793                    PdfObj::Int(0),
794                    PdfObj::Int(0),
795                    PdfObj::Real(page.width_pts),
796                    PdfObj::Real(page.height_pts),
797                ]),
798            ),
799            (b"Contents".to_vec(), PdfObj::Ref(content_ref)),
800            (b"Resources".to_vec(), PdfObj::Dict(resources)),
801        ];
802        // /CropBox / /BleedBox / /TrimBox / /ArtBox: pdfmark /PAGE or
803        // /PAGES wins; otherwise fall back to the device's pending
804        // trim_box (set via PdfDevice::set_trim_box).
805        let effective_trim = overrides.boxes.trim_box.or_else(|| {
806            page.trim_box
807                .map(|(llx, lly, urx, ury)| [llx, lly, urx, ury])
808        });
809        for (name, b) in [
810            (b"CropBox".as_slice(), overrides.boxes.crop_box),
811            (b"BleedBox".as_slice(), overrides.boxes.bleed_box),
812            (b"TrimBox".as_slice(), effective_trim),
813            (b"ArtBox".as_slice(), overrides.boxes.art_box),
814        ] {
815            if let Some([llx, lly, urx, ury]) = b {
816                page_entries.push((
817                    name.to_vec(),
818                    PdfObj::Array(vec![
819                        PdfObj::Real(llx),
820                        PdfObj::Real(lly),
821                        PdfObj::Real(urx),
822                        PdfObj::Real(ury),
823                    ]),
824                ));
825            }
826        }
827        if let Some(rotate) = overrides.rotate
828            && matches!(rotate, 0 | 90 | 180 | 270 | -90 | -180 | -270)
829        {
830            page_entries.push((b"Rotate".to_vec(), PdfObj::Int(rotate as i64)));
831        }
832        if !annot_refs.is_empty() {
833            page_entries.push((
834                b"Annots".to_vec(),
835                PdfObj::Array(annot_refs.iter().map(|r| PdfObj::Ref(*r)).collect()),
836            ));
837        }
838        if let Some(aa) = &overrides.additional_actions
839            && !aa.is_empty()
840        {
841            // Page-level /AA — open / close hooks. Page refs aren't
842            // available to action targets here (an /O /GoTo can land
843            // on any page; we use the empty page_refs slice so out-of-
844            // range refs short-circuit to None and the action drops).
845            let mut aa_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
846            if let Some(action) = &aa.on_open
847                && let Some(dict) = crate::outline::encode_action(action, &[])
848            {
849                aa_entries.push((b"O".to_vec(), dict));
850            }
851            if let Some(action) = &aa.on_close
852                && let Some(dict) = crate::outline::encode_action(action, &[])
853            {
854                aa_entries.push((b"C".to_vec(), dict));
855            }
856            if !aa_entries.is_empty() {
857                page_entries.push((b"AA".to_vec(), PdfObj::Dict(aa_entries)));
858            }
859        }
860        writer.set_object(page_ref, &PdfObj::Dict(page_entries));
861
862        Ok(())
863    }
864
865    /// Build a PDF font reference for a tracked font.
866    ///
867    /// For Standard 14 fonts, creates a simple Type1 font dict.
868    /// For other fonts, also creates a simple Type1 dict (no embedding yet).
869    /// Both include a ToUnicode CMap for searchability.
870    fn build_font_reference(
871        &self,
872        writer: &mut PdfWriter,
873        usage: &crate::font_tracker::FontUsage,
874        tounicode_override: Option<u32>,
875    ) -> u32 {
876        // Build ToUnicode CMap — use override if provided, otherwise fall back to naive mapping
877        let tounicode_ref = tounicode_override.or_else(|| self.build_tounicode_cmap(writer, usage));
878
879        let mut entries: Vec<(Vec<u8>, PdfObj)> = vec![
880            (b"Type".to_vec(), PdfObj::name("Font")),
881            (b"Subtype".to_vec(), PdfObj::name("Type1")),
882            (b"BaseFont".to_vec(), PdfObj::Name(usage.font_name.clone())),
883        ];
884
885        if !usage.is_standard_14 {
886            // For non-standard fonts, add Encoding
887            entries.push((b"Encoding".to_vec(), PdfObj::name("WinAnsiEncoding")));
888        }
889
890        if let Some(tu_ref) = tounicode_ref {
891            entries.push((b"ToUnicode".to_vec(), PdfObj::Ref(tu_ref)));
892        }
893
894        writer.add_object(&PdfObj::Dict(entries))
895    }
896
897    /// Build a ToUnicode CMap for a font.
898    ///
899    /// Maps character codes to Unicode based on common Adobe glyph naming.
900    /// For printable ASCII codes, maps code→Unicode directly (works for most
901    /// Latin text fonts). The full glyph-name-based mapping requires encoding
902    /// array access (deferred to font embedding phase).
903    fn build_tounicode_cmap(
904        &self,
905        writer: &mut PdfWriter,
906        usage: &crate::font_tracker::FontUsage,
907    ) -> Option<u32> {
908        use std::collections::HashMap;
909
910        let mut map: HashMap<u16, u16> = HashMap::new();
911
912        for &code in &usage.used_codes {
913            if code <= 255 {
914                // For ASCII range, assume code = Unicode (works for standard encodings)
915                if (0x20..=0x7E).contains(&code) {
916                    map.insert(code, code);
917                }
918            }
919        }
920
921        if map.is_empty() {
922            return None;
923        }
924
925        let font_name = String::from_utf8_lossy(&usage.font_name);
926        let cmap_data = generate_tounicode_cmap(&map, &font_name);
927        Some(writer.add_stream(Vec::new(), &cmap_data, true))
928    }
929
930    /// Build a PDF image XObject from prepared image data. Returns the object number.
931    fn build_image_xobject(&self, writer: &mut PdfWriter, img: &ImageXObject) -> u32 {
932        // Build SMask if present
933        let smask_ref = img.smask_data.as_ref().map(|smask_data| {
934            writer.add_stream(
935                vec![
936                    (b"Type".to_vec(), PdfObj::name("XObject")),
937                    (b"Subtype".to_vec(), PdfObj::name("Image")),
938                    (b"Width".to_vec(), PdfObj::Int(img.width as i64)),
939                    (b"Height".to_vec(), PdfObj::Int(img.height as i64)),
940                    (b"ColorSpace".to_vec(), PdfObj::name("DeviceGray")),
941                    (b"BitsPerComponent".to_vec(), PdfObj::Int(8)),
942                    (b"Interpolate".to_vec(), PdfObj::Bool(false)),
943                ],
944                smask_data,
945                true,
946            )
947        });
948
949        // Build ICC profile stream if needed
950        let icc_ref = img.icc_profile.as_ref().map(|icc| {
951            writer.add_stream(
952                vec![(b"N".to_vec(), PdfObj::Int(icc.n as i64))],
953                &icc.data,
954                true,
955            )
956        });
957
958        // Build PDF ColorSpace value
959        let cs_obj = build_pdf_colorspace(&img.pdf_color_space, icc_ref, writer);
960
961        // Main image XObject
962        let mut entries = vec![
963            (b"Type".to_vec(), PdfObj::name("XObject")),
964            (b"Subtype".to_vec(), PdfObj::name("Image")),
965            (b"Width".to_vec(), PdfObj::Int(img.width as i64)),
966            (b"Height".to_vec(), PdfObj::Int(img.height as i64)),
967        ];
968
969        if img.is_imagemask {
970            entries.push((b"ImageMask".to_vec(), PdfObj::Bool(true)));
971            // Imagemasks don't have ColorSpace or BitsPerComponent in the XObject
972            entries.push((
973                b"Decode".to_vec(),
974                PdfObj::Array(vec![PdfObj::Int(1), PdfObj::Int(0)]),
975            ));
976        } else {
977            entries.push((b"ColorSpace".to_vec(), cs_obj));
978            entries.push((
979                b"BitsPerComponent".to_vec(),
980                PdfObj::Int(img.bits_per_component as i64),
981            ));
982        }
983
984        entries.push((b"Interpolate".to_vec(), PdfObj::Bool(false)));
985
986        if let Some(smask) = smask_ref {
987            entries.push((b"SMask".to_vec(), PdfObj::Ref(smask)));
988        }
989
990        // Color key masking (ImageType 4): /Mask array of 2×n integers
991        if let Some(ref ckm) = img.color_key_mask {
992            let ncomp = img.pdf_color_space.num_components();
993            let mask_ints: Vec<PdfObj> = if ckm.len() == ncomp {
994                // Exact match: expand each value v to [v, v] range pair
995                ckm.iter()
996                    .flat_map(|&v| [PdfObj::Int(v as i64), PdfObj::Int(v as i64)])
997                    .collect()
998            } else {
999                // Range match: already in [min0, max0, min1, max1, ...] form
1000                ckm.iter().map(|&v| PdfObj::Int(v as i64)).collect()
1001            };
1002            entries.push((b"Mask".to_vec(), PdfObj::Array(mask_ints)));
1003        }
1004
1005        writer.add_stream(entries, &img.sample_data, true)
1006    }
1007}
1008
1009/// Build a PDF color space object from our enum.
1010fn build_pdf_colorspace(
1011    cs: &crate::image_ops::PdfColorSpace,
1012    icc_ref: Option<u32>,
1013    writer: &mut PdfWriter,
1014) -> PdfObj {
1015    use crate::image_ops::PdfColorSpace;
1016    match cs {
1017        PdfColorSpace::DeviceGray => PdfObj::name("DeviceGray"),
1018        PdfColorSpace::DeviceRGB => PdfObj::name("DeviceRGB"),
1019        PdfColorSpace::DeviceCMYK => PdfObj::name("DeviceCMYK"),
1020        PdfColorSpace::ICCBased { .. } => {
1021            if let Some(ref_num) = icc_ref {
1022                PdfObj::Array(vec![PdfObj::name("ICCBased"), PdfObj::Ref(ref_num)])
1023            } else {
1024                PdfObj::name("DeviceRGB") // fallback
1025            }
1026        }
1027        PdfColorSpace::Indexed {
1028            base,
1029            hival,
1030            lookup,
1031        } => {
1032            let base_obj = build_pdf_colorspace(base, None, writer);
1033            // Embed lookup table as a hex string stream
1034            let lookup_ref = writer.add_stream(Vec::new(), lookup, true);
1035            PdfObj::Array(vec![
1036                PdfObj::name("Indexed"),
1037                base_obj,
1038                PdfObj::Int(*hival as i64),
1039                PdfObj::Ref(lookup_ref),
1040            ])
1041        }
1042        PdfColorSpace::Separation {
1043            name,
1044            alt,
1045            tint_table,
1046        } => {
1047            let alt_obj = build_pdf_colorspace(alt, None, writer);
1048            let func_ref = build_tint_function(tint_table, writer);
1049            PdfObj::Array(vec![
1050                PdfObj::name("Separation"),
1051                PdfObj::Name(name.clone()),
1052                alt_obj,
1053                PdfObj::Ref(func_ref),
1054            ])
1055        }
1056        PdfColorSpace::DeviceN {
1057            names,
1058            alt,
1059            tint_table,
1060        } => {
1061            let alt_obj = build_pdf_colorspace(alt, None, writer);
1062            let func_ref = build_tint_function(tint_table, writer);
1063            let names_arr = PdfObj::Array(names.iter().map(|n| PdfObj::Name(n.clone())).collect());
1064            PdfObj::Array(vec![
1065                PdfObj::name("DeviceN"),
1066                names_arr,
1067                alt_obj,
1068                PdfObj::Ref(func_ref),
1069            ])
1070        }
1071    }
1072}
1073
1074/// Build a PDF Type 0 (sampled) function stream from a TintLookupTable.
1075/// Returns the object number of the function stream.
1076fn build_tint_function(
1077    table: &stet_graphics::device::TintLookupTable,
1078    writer: &mut PdfWriter,
1079) -> u32 {
1080    let ni = table.num_inputs as usize;
1081    let no = table.num_outputs as usize;
1082
1083    // Convert f32 data (0.0–1.0) to u8 samples (0–255).
1084    // Our TintLookupTable stores data in row-major order (last dimension varies fastest),
1085    // but PDF Type 0 functions require the first dimension to vary fastest.
1086    // For 1D, the order is the same. For ND, we must transpose.
1087    let spd = table.samples_per_dim as usize;
1088    let total_entries = spd.pow(ni as u32);
1089    let samples: Vec<u8> = if ni <= 1 {
1090        table
1091            .data
1092            .iter()
1093            .map(|&v| (v.clamp(0.0, 1.0) * 255.0) as u8)
1094            .collect()
1095    } else {
1096        // Reorder: iterate in PDF order (dim0 fastest) and look up in our order (dim0 slowest)
1097        let mut out = Vec::with_capacity(total_entries * no);
1098        for pdf_idx in 0..total_entries {
1099            // Decompose pdf_idx with dim0 varying fastest
1100            let mut coords = vec![0usize; ni];
1101            let mut rem = pdf_idx;
1102            for coord in coords.iter_mut() {
1103                *coord = rem % spd;
1104                rem /= spd;
1105            }
1106            // Convert to our row-major index (dim0 slowest, last dim fastest)
1107            let mut our_idx = 0;
1108            for coord in coords.iter() {
1109                our_idx = our_idx * spd + coord;
1110            }
1111            let base = our_idx * no;
1112            for c in 0..no {
1113                out.push((table.data[base + c].clamp(0.0, 1.0) * 255.0) as u8);
1114            }
1115        }
1116        out
1117    };
1118
1119    // Domain: [0 1] repeated for each input
1120    let mut domain = Vec::with_capacity(ni * 2);
1121    for _ in 0..ni {
1122        domain.push(PdfObj::Int(0));
1123        domain.push(PdfObj::Int(1));
1124    }
1125
1126    // Range: [0 1] repeated for each output
1127    let mut range = Vec::with_capacity(no * 2);
1128    for _ in 0..no {
1129        range.push(PdfObj::Int(0));
1130        range.push(PdfObj::Int(1));
1131    }
1132
1133    // Size: samples_per_dim repeated for each input dimension
1134    let size: Vec<PdfObj> = (0..ni)
1135        .map(|_| PdfObj::Int(table.samples_per_dim as i64))
1136        .collect();
1137
1138    let dict_entries = vec![
1139        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1140        (b"Domain".to_vec(), PdfObj::Array(domain)),
1141        (b"Range".to_vec(), PdfObj::Array(range)),
1142        (b"Size".to_vec(), PdfObj::Array(size)),
1143        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1144    ];
1145
1146    writer.add_stream(dict_entries, &samples, true)
1147}
1148
1149/// Build a PDF Separation or DeviceN color space array from a SpotColorSpace.
1150/// Returns a PdfObj (array) suitable for inclusion in the Resources/ColorSpace dict.
1151fn build_spot_colorspace(
1152    spot_cs: &stet_graphics::device::SpotColorSpace,
1153    writer: &mut PdfWriter,
1154) -> PdfObj {
1155    use stet_graphics::device::{SimpleColorSpace, SpotColorSpace};
1156    match spot_cs {
1157        SpotColorSpace::Separation {
1158            name,
1159            alt,
1160            tint_table,
1161        } => {
1162            let alt_obj = match alt {
1163                SimpleColorSpace::DeviceGray => PdfObj::name("DeviceGray"),
1164                SimpleColorSpace::DeviceRGB => PdfObj::name("DeviceRGB"),
1165                SimpleColorSpace::DeviceCMYK => PdfObj::name("DeviceCMYK"),
1166            };
1167            let func_ref = build_tint_function(tint_table, writer);
1168            PdfObj::Array(vec![
1169                PdfObj::name("Separation"),
1170                PdfObj::Name(name.clone()),
1171                alt_obj,
1172                PdfObj::Ref(func_ref),
1173            ])
1174        }
1175        SpotColorSpace::DeviceN {
1176            names,
1177            alt,
1178            tint_table,
1179        } => {
1180            let alt_obj = match alt {
1181                SimpleColorSpace::DeviceGray => PdfObj::name("DeviceGray"),
1182                SimpleColorSpace::DeviceRGB => PdfObj::name("DeviceRGB"),
1183                SimpleColorSpace::DeviceCMYK => PdfObj::name("DeviceCMYK"),
1184            };
1185            let func_ref = build_tint_function(tint_table, writer);
1186            let names_arr = PdfObj::Array(names.iter().map(|n| PdfObj::Name(n.clone())).collect());
1187            PdfObj::Array(vec![
1188                PdfObj::name("DeviceN"),
1189                names_arr,
1190                alt_obj,
1191                PdfObj::Ref(func_ref),
1192            ])
1193        }
1194        _ => PdfObj::name("DeviceRGB"),
1195    }
1196}
1197
1198/// Generate a ToUnicode CMap stream.
1199fn generate_tounicode_cmap(map: &std::collections::HashMap<u16, u16>, font_name: &str) -> Vec<u8> {
1200    use std::io::Write;
1201    let mut buf = Vec::new();
1202
1203    writeln!(buf, "/CIDInit /ProcSet findresource begin").unwrap();
1204    writeln!(buf, "12 dict begin").unwrap();
1205    writeln!(buf, "begincmap").unwrap();
1206    writeln!(buf, "/CIDSystemInfo <<").unwrap();
1207    writeln!(buf, "  /Registry (Adobe)").unwrap();
1208    writeln!(buf, "  /Ordering (UCS)").unwrap();
1209    writeln!(buf, "  /Supplement 0").unwrap();
1210    writeln!(buf, ">> def").unwrap();
1211    writeln!(buf, "/CMapName /{}-UCS def", font_name).unwrap();
1212    writeln!(buf, "/CMapType 2 def").unwrap();
1213    writeln!(buf, "1 begincodespacerange").unwrap();
1214    writeln!(buf, "<00> <FF>").unwrap();
1215    writeln!(buf, "endcodespacerange").unwrap();
1216
1217    let mut sorted: Vec<_> = map.iter().collect();
1218    sorted.sort_by_key(|&(&code, _)| code);
1219
1220    for chunk in sorted.chunks(100) {
1221        writeln!(buf, "{} beginbfchar", chunk.len()).unwrap();
1222        for &(&code, &unicode) in chunk {
1223            writeln!(buf, "<{:02X}> <{:04X}>", code, unicode).unwrap();
1224        }
1225        writeln!(buf, "endbfchar").unwrap();
1226    }
1227
1228    writeln!(buf, "endcmap").unwrap();
1229    writeln!(buf, "CMapName currentdict /CMap defineresource pop").unwrap();
1230    writeln!(buf, "end").unwrap();
1231    writeln!(buf, "end").unwrap();
1232
1233    buf
1234}
1235
1236impl OutputDevice for PdfDevice {
1237    fn fill_path(&mut self, _path: &PsPath, _params: &FillParams) {}
1238    fn stroke_path(&mut self, _path: &PsPath, _params: &StrokeParams) {}
1239    fn clip_path(&mut self, _path: &PsPath, _params: &ClipParams) {}
1240    fn init_clip(&mut self) {}
1241    fn erase_page(&mut self) {}
1242
1243    fn set_trim_box(&mut self, llx: f64, lly: f64, urx: f64, ury: f64) {
1244        self.pending_trim_box = Some((llx, lly, urx, ury));
1245    }
1246
1247    fn show_page(&mut self, _output_path: &str) -> Result<(), String> {
1248        Ok(())
1249    }
1250
1251    fn draw_image(&mut self, _sample_data: &[u8], _params: &ImageParams) {}
1252
1253    fn page_size(&self) -> (u32, u32) {
1254        (self.page_w, self.page_h)
1255    }
1256
1257    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
1258        // Capture output path from first page
1259        if self.output_path.is_none() {
1260            // Strip extension (.png or .pdf)
1261            let base = if let Some(pos) = output_path.rfind('.') {
1262                &output_path[..pos]
1263            } else {
1264                output_path
1265            };
1266            // Remove -NNNN page number suffix (e.g., "arc-0001" → "arc")
1267            let base = if base.len() >= 5 && base.as_bytes()[base.len() - 5] == b'-' {
1268                let suffix = &base[base.len() - 4..];
1269                if suffix.bytes().all(|b| b.is_ascii_digit()) {
1270                    &base[..base.len() - 5]
1271                } else {
1272                    base
1273                }
1274            } else {
1275                base
1276            };
1277            self.output_path = Some(format!("{}.pdf", base));
1278        }
1279
1280        let scale = 72.0 / self.dpi;
1281
1282        self.pages.push(PageData {
1283            display_list: list,
1284            width_pts: self.page_w as f64 * scale,
1285            height_pts: self.page_h as f64 * scale,
1286            page_w: self.page_w,
1287            page_h: self.page_h,
1288            dpi: self.dpi,
1289            trim_box: self.pending_trim_box.take(),
1290        });
1291
1292        Ok(())
1293    }
1294
1295    fn finish(&mut self) -> Result<(), String> {
1296        if self.pages.is_empty() {
1297            return Ok(());
1298        }
1299        self.write_pdf(None)
1300    }
1301
1302    fn finish_with_context(&mut self, ctx: &Context) -> Result<(), String> {
1303        if self.pages.is_empty() {
1304            return Ok(());
1305        }
1306        self.write_pdf(Some(ctx))
1307    }
1308
1309    fn as_any(&self) -> &dyn std::any::Any {
1310        self
1311    }
1312}
1313
1314/// Parse an ICC profile header to extract the number of components and description.
1315///
1316/// Returns (N, description) where N is derived from the color space signature
1317/// at bytes 16–19 and description is extracted from the `desc` or `mluc` tag.
1318///
1319/// Currently unused — kept for forward compatibility with the planned
1320/// PDF/X-4 OutputIntent implementation.
1321#[allow(dead_code)]
1322fn parse_icc_header(data: &[u8]) -> (u32, String) {
1323    let n = if data.len() >= 20 {
1324        match &data[16..20] {
1325            b"CMYK" => 4,
1326            b"RGB " => 3,
1327            b"GRAY" => 1,
1328            b"Lab " => 3,
1329            _ => 4, // assume CMYK for unknown
1330        }
1331    } else {
1332        4
1333    };
1334    let desc = extract_icc_description(data).unwrap_or_else(|| "Custom".to_string());
1335    (n, desc)
1336}
1337
1338/// Extract the profile description from an ICC profile's tag table.
1339///
1340/// Looks for the `desc` tag (v2, type 'desc') or `mluc` tag (v4, type 'mluc').
1341#[allow(dead_code)]
1342fn extract_icc_description(data: &[u8]) -> Option<String> {
1343    if data.len() < 132 {
1344        return None;
1345    }
1346    let tag_count = u32::from_be_bytes(data[128..132].try_into().ok()?) as usize;
1347    let tag_table_start = 132;
1348
1349    for i in 0..tag_count {
1350        let offset = tag_table_start + i * 12;
1351        if offset + 12 > data.len() {
1352            break;
1353        }
1354        let tag_sig = &data[offset..offset + 4];
1355        let tag_offset = u32::from_be_bytes(data[offset + 4..offset + 8].try_into().ok()?) as usize;
1356        let tag_size = u32::from_be_bytes(data[offset + 8..offset + 12].try_into().ok()?) as usize;
1357
1358        if tag_sig != b"desc" {
1359            continue;
1360        }
1361        if tag_offset + tag_size > data.len() || tag_size < 12 {
1362            return None;
1363        }
1364
1365        let type_sig = &data[tag_offset..tag_offset + 4];
1366        if type_sig == b"desc" {
1367            // ICC v2 'desc' type: u32 count at offset+8, ASCII string at offset+12
1368            let count =
1369                u32::from_be_bytes(data[tag_offset + 8..tag_offset + 12].try_into().ok()?) as usize;
1370            if count == 0 {
1371                return None;
1372            }
1373            let str_end = (tag_offset + 12 + count).min(tag_offset + tag_size);
1374            let s = &data[tag_offset + 12..str_end];
1375            // Trim trailing null bytes
1376            let s = s.split(|&b| b == 0).next().unwrap_or(s);
1377            return Some(String::from_utf8_lossy(s).to_string());
1378        } else if type_sig == b"mluc" {
1379            // ICC v4 'mluc' type: multi-localized Unicode
1380            if tag_size < 20 {
1381                return None;
1382            }
1383            let record_count =
1384                u32::from_be_bytes(data[tag_offset + 8..tag_offset + 12].try_into().ok()?) as usize;
1385            if record_count == 0 {
1386                return None;
1387            }
1388            // First record: language(2) + country(2) + length(4) + offset(4)
1389            let rec_base = tag_offset + 16;
1390            if rec_base + 12 > data.len() {
1391                return None;
1392            }
1393            let str_len =
1394                u32::from_be_bytes(data[rec_base + 4..rec_base + 8].try_into().ok()?) as usize;
1395            let str_off =
1396                u32::from_be_bytes(data[rec_base + 8..rec_base + 12].try_into().ok()?) as usize;
1397            let abs_off = tag_offset + str_off;
1398            if abs_off + str_len > data.len() || str_len < 2 {
1399                return None;
1400            }
1401            // UTF-16BE → String
1402            let utf16: Vec<u16> = data[abs_off..abs_off + str_len]
1403                .chunks_exact(2)
1404                .map(|c| u16::from_be_bytes([c[0], c[1]]))
1405                .collect();
1406            return Some(
1407                String::from_utf16_lossy(&utf16)
1408                    .trim_end_matches('\0')
1409                    .to_string(),
1410            );
1411        }
1412
1413        break;
1414    }
1415    None
1416}
1417
1418/// Build a PDF Type 0 (sampled) function stream from a 256-entry transfer table.
1419/// Returns the object number of the function stream.
1420fn build_type0_function(writer: &mut PdfWriter, table: &[f64]) -> u32 {
1421    let dict_entries = vec![
1422        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1423        (
1424            b"Domain".to_vec(),
1425            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1426        ),
1427        (
1428            b"Range".to_vec(),
1429            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1430        ),
1431        (
1432            b"Size".to_vec(),
1433            PdfObj::Array(vec![PdfObj::Int(table.len() as i64)]),
1434        ),
1435        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1436    ];
1437    let data: Vec<u8> = table
1438        .iter()
1439        .map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
1440        .collect();
1441    writer.add_stream(dict_entries, &data, false)
1442}
1443
1444/// Build the /TR2 value for an ExtGState dict from transfer function tables.
1445/// Returns a PdfObj (Ref for single function, Array for 4-component, or Name for identity).
1446fn build_transfer_tr2(
1447    writer: &mut PdfWriter,
1448    tables: &[Option<std::sync::Arc<Vec<f64>>>],
1449    is_color: bool,
1450) -> PdfObj {
1451    if is_color && tables.len() == 4 {
1452        // 4-component: [R, G, B, Gray], use /Identity for None entries
1453        let refs: Vec<PdfObj> = tables
1454            .iter()
1455            .map(|t| {
1456                if let Some(table) = t {
1457                    let func_ref = build_type0_function(writer, table);
1458                    PdfObj::Ref(func_ref)
1459                } else {
1460                    PdfObj::name("Identity")
1461                }
1462            })
1463            .collect();
1464        PdfObj::Array(refs)
1465    } else if !is_color && tables.len() == 1 {
1466        if let Some(ref table) = tables[0] {
1467            let func_ref = build_type0_function(writer, table);
1468            PdfObj::Ref(func_ref)
1469        } else {
1470            PdfObj::name("Identity")
1471        }
1472    } else {
1473        PdfObj::name("Identity")
1474    }
1475}
1476
1477/// Build a PDF Type 0 (sampled) function stream from a 256-entry table with signed range [-1,1].
1478/// Used for undercolor removal (UCR) functions.
1479fn build_type0_function_signed(writer: &mut PdfWriter, table: &[f64]) -> u32 {
1480    let dict_entries = vec![
1481        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1482        (
1483            b"Domain".to_vec(),
1484            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1485        ),
1486        (
1487            b"Range".to_vec(),
1488            PdfObj::Array(vec![PdfObj::Int(-1), PdfObj::Int(1)]),
1489        ),
1490        (
1491            b"Size".to_vec(),
1492            PdfObj::Array(vec![PdfObj::Int(table.len() as i64)]),
1493        ),
1494        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1495    ];
1496    // Encode [-1,1] → [0,255]: byte = (v + 1) / 2 * 255
1497    let data: Vec<u8> = table
1498        .iter()
1499        .map(|&v| ((v.clamp(-1.0, 1.0) + 1.0) / 2.0 * 255.0).round() as u8)
1500        .collect();
1501    writer.add_stream(dict_entries, &data, false)
1502}
1503
1504/// Build a PDF Type 4 (PostScript calculator) function from token bytes.
1505/// Domain is 2D [-1,1]×[-1,1], Range [0,1].
1506fn build_type4_function(writer: &mut PdfWriter, tokens: &[u8]) -> u32 {
1507    let dict_entries = vec![
1508        (b"FunctionType".to_vec(), PdfObj::Int(4)),
1509        (
1510            b"Domain".to_vec(),
1511            PdfObj::Array(vec![
1512                PdfObj::Int(-1),
1513                PdfObj::Int(1),
1514                PdfObj::Int(-1),
1515                PdfObj::Int(1),
1516            ]),
1517        ),
1518        (
1519            b"Range".to_vec(),
1520            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1521        ),
1522    ];
1523    writer.add_stream(dict_entries, tokens, false)
1524}
1525
1526/// Build a PDF Type 0 (sampled) 2D function from a 64×64 sample table.
1527/// Domain is [-1,1]×[-1,1], Range [0,1].
1528fn build_type0_function_2d(writer: &mut PdfWriter, table: &[f64]) -> u32 {
1529    let dict_entries = vec![
1530        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1531        (
1532            b"Domain".to_vec(),
1533            PdfObj::Array(vec![
1534                PdfObj::Int(-1),
1535                PdfObj::Int(1),
1536                PdfObj::Int(-1),
1537                PdfObj::Int(1),
1538            ]),
1539        ),
1540        (
1541            b"Range".to_vec(),
1542            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1543        ),
1544        (
1545            b"Size".to_vec(),
1546            PdfObj::Array(vec![PdfObj::Int(64), PdfObj::Int(64)]),
1547        ),
1548        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1549    ];
1550    let data: Vec<u8> = table
1551        .iter()
1552        .map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
1553        .collect();
1554    writer.add_stream(dict_entries, &data, false)
1555}
1556
1557/// Build a PDF halftone screen object (Type 1 halftone dict) from a HalftoneScreen.
1558/// Returns a PdfObj (either inline Dict or Ref to indirect object).
1559fn build_halftone_screen(
1560    writer: &mut PdfWriter,
1561    screen: &stet_graphics::device::HalftoneScreen,
1562) -> PdfObj {
1563    let spot_func = if let Some(ref tokens) = screen.type4_tokens {
1564        let func_ref = build_type4_function(writer, tokens);
1565        PdfObj::Ref(func_ref)
1566    } else if let Some(ref table) = screen.sampled_2d {
1567        let func_ref = build_type0_function_2d(writer, table);
1568        PdfObj::Ref(func_ref)
1569    } else {
1570        PdfObj::name("Default")
1571    };
1572
1573    let entries = vec![
1574        (b"Type".to_vec(), PdfObj::name("Halftone")),
1575        (b"HalftoneType".to_vec(), PdfObj::Int(1)),
1576        (b"Frequency".to_vec(), PdfObj::Real(screen.frequency)),
1577        (b"Angle".to_vec(), PdfObj::Real(screen.angle)),
1578        (b"SpotFunction".to_vec(), spot_func),
1579    ];
1580    let obj_ref = writer.add_object(&PdfObj::Dict(entries));
1581    PdfObj::Ref(obj_ref)
1582}
1583
1584/// Build the /HT value for an ExtGState dict from a HalftoneState.
1585fn build_halftone_ht(
1586    writer: &mut PdfWriter,
1587    state: &stet_graphics::device::HalftoneState,
1588) -> PdfObj {
1589    if let Some(ref color) = state.color {
1590        // Type 5 composite halftone
1591        let mut entries = vec![
1592            (b"Type".to_vec(), PdfObj::name("Halftone")),
1593            (b"HalftoneType".to_vec(), PdfObj::Int(5)),
1594        ];
1595        let component_names: [&[u8]; 4] = [b"Red", b"Green", b"Blue", b"Default"];
1596        for (i, screen_opt) in color.iter().enumerate() {
1597            if let Some(screen) = screen_opt {
1598                let ht_obj = build_halftone_screen(writer, screen);
1599                entries.push((component_names[i].to_vec(), ht_obj));
1600            }
1601        }
1602        let obj_ref = writer.add_object(&PdfObj::Dict(entries));
1603        PdfObj::Ref(obj_ref)
1604    } else if let Some(ref gray) = state.gray {
1605        build_halftone_screen(writer, gray)
1606    } else {
1607        PdfObj::name("Default")
1608    }
1609}
1610
1611/// Effective per-page override after layering /PAGES under /PAGE.
1612#[derive(Default, Clone)]
1613struct EffectivePageOverride {
1614    boxes: stet_core::pdfmark::PageBoxes,
1615    rotate: Option<i32>,
1616    additional_actions: Option<stet_core::pdfmark::PageAdditionalActions>,
1617}
1618
1619/// Walk the pdfmark buffer and compute one [`EffectivePageOverride`]
1620/// per page in `0..page_count`. Order of precedence per key:
1621/// 1. Last `/PAGE` for that specific page (later record wins).
1622/// 2. Last `/PAGES` (later document-wide record wins).
1623fn compute_page_overrides(ctx: Option<&Context>, page_count: usize) -> Vec<EffectivePageOverride> {
1624    use stet_core::pdfmark::{PageOverrideScope, PdfMarkRecord};
1625    let mut out = vec![EffectivePageOverride::default(); page_count];
1626    let Some(c) = ctx else {
1627        return out;
1628    };
1629    let mut all_boxes = stet_core::pdfmark::PageBoxes::default();
1630    let mut all_rotate: Option<i32> = None;
1631    let mut all_aa: Option<stet_core::pdfmark::PageAdditionalActions> = None;
1632    let mut per_page_boxes: Vec<stet_core::pdfmark::PageBoxes> =
1633        vec![stet_core::pdfmark::PageBoxes::default(); page_count];
1634    let mut per_page_rotate: Vec<Option<i32>> = vec![None; page_count];
1635    let mut per_page_aa: Vec<Option<stet_core::pdfmark::PageAdditionalActions>> =
1636        vec![None; page_count];
1637
1638    for record in c.pdfmark_buffer.records() {
1639        let PdfMarkRecord::PageOverride(rec) = record else {
1640            continue;
1641        };
1642        match rec.scope {
1643            PageOverrideScope::All => {
1644                all_boxes = rec.boxes.merge_over(&all_boxes);
1645                if rec.rotate.is_some() {
1646                    all_rotate = rec.rotate;
1647                }
1648                if let Some(new_aa) = &rec.additional_actions {
1649                    all_aa = Some(match all_aa {
1650                        Some(prev) => new_aa.merge_over(&prev),
1651                        None => new_aa.clone(),
1652                    });
1653                }
1654            }
1655            PageOverrideScope::Single(page) => {
1656                let idx = page as usize;
1657                if idx == 0 || idx > page_count {
1658                    continue;
1659                }
1660                let i = idx - 1;
1661                per_page_boxes[i] = rec.boxes.merge_over(&per_page_boxes[i]);
1662                if rec.rotate.is_some() {
1663                    per_page_rotate[i] = rec.rotate;
1664                }
1665                if let Some(new_aa) = &rec.additional_actions {
1666                    per_page_aa[i] = Some(match per_page_aa[i].clone() {
1667                        Some(prev) => new_aa.merge_over(&prev),
1668                        None => new_aa.clone(),
1669                    });
1670                }
1671            }
1672            _ => continue,
1673        }
1674    }
1675
1676    for i in 0..page_count {
1677        out[i].boxes = per_page_boxes[i].merge_over(&all_boxes);
1678        out[i].rotate = per_page_rotate[i].or(all_rotate);
1679        out[i].additional_actions = match (&per_page_aa[i], &all_aa) {
1680            (Some(p), Some(a)) => Some(p.merge_over(a)),
1681            (Some(p), None) => Some(p.clone()),
1682            (None, Some(a)) => Some(a.clone()),
1683            (None, None) => None,
1684        };
1685    }
1686    out
1687}
1688
1689/// Convert days since 1970-01-01 to (year, month, day).
1690fn days_to_ymd(days: u64) -> (u64, u64, u64) {
1691    // Civil calendar algorithm from Howard Hinnant
1692    let z = days + 719468;
1693    let era = z / 146097;
1694    let doe = z - era * 146097;
1695    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1696    let y = yoe + era * 400;
1697    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1698    let mp = (5 * doy + 2) / 153;
1699    let d = doy - (153 * mp + 2) / 5 + 1;
1700    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1701    let y = if m <= 2 { y + 1 } else { y };
1702    (y, m, d)
1703}
1704
1705/// Format the current wall-clock time as a PDF date string in UTC.
1706fn default_now_pdf_date() -> String {
1707    use std::time::SystemTime;
1708    let now = SystemTime::now()
1709        .duration_since(SystemTime::UNIX_EPOCH)
1710        .unwrap_or_default()
1711        .as_secs();
1712    let secs_per_day = 86400u64;
1713    let days = now / secs_per_day;
1714    let time_of_day = now % secs_per_day;
1715    let hours = time_of_day / 3600;
1716    let minutes = (time_of_day % 3600) / 60;
1717    let seconds = time_of_day % 60;
1718    let (year, month, day) = days_to_ymd(days);
1719    format!(
1720        "D:{:04}{:02}{:02}{:02}{:02}{:02}Z",
1721        year, month, day, hours, minutes, seconds
1722    )
1723}
1724
1725/// Merge every `/DOCINFO` pdfmark record on the buffer into a single
1726/// effective record. Later records override earlier ones key-by-key,
1727/// matching GhostScript pdfwrite's behaviour where multiple
1728/// `[ /DOCINFO pdfmark` blocks accumulate.
1729/// Merge every `/VIEWERPREFERENCES pdfmark` record into one effective
1730/// record. Later records override earlier ones key-by-key, matching
1731/// the same "later wins" rule we apply to `/DOCINFO`.
1732fn collect_viewer_prefs(ctx: &Context) -> stet_core::pdfmark::ViewerPrefsRecord {
1733    use stet_core::pdfmark::{PdfMarkRecord, ViewerPrefsRecord};
1734    let mut acc = ViewerPrefsRecord::default();
1735    for record in ctx.pdfmark_buffer.records() {
1736        if let PdfMarkRecord::ViewerPrefs(rec) = record {
1737            acc = rec.merge_over(&acc);
1738        }
1739    }
1740    acc
1741}
1742
1743fn collect_docinfo(ctx: &Context) -> stet_core::pdfmark::DocInfoRecord {
1744    let mut acc = stet_core::pdfmark::DocInfoRecord::default();
1745    for record in ctx.pdfmark_buffer.records() {
1746        let stet_core::pdfmark::PdfMarkRecord::DocInfo(rec) = record else {
1747            continue;
1748        };
1749        if let Some(v) = &rec.title {
1750            acc.title = Some(v.clone());
1751        }
1752        if let Some(v) = &rec.author {
1753            acc.author = Some(v.clone());
1754        }
1755        if let Some(v) = &rec.subject {
1756            acc.subject = Some(v.clone());
1757        }
1758        if let Some(v) = &rec.keywords {
1759            acc.keywords = Some(v.clone());
1760        }
1761        if let Some(v) = &rec.creator {
1762            acc.creator = Some(v.clone());
1763        }
1764        if let Some(v) = &rec.producer {
1765            acc.producer = Some(v.clone());
1766        }
1767        if let Some(v) = &rec.creation_date {
1768            acc.creation_date = Some(v.clone());
1769        }
1770        if let Some(v) = &rec.mod_date {
1771            acc.mod_date = Some(v.clone());
1772        }
1773        if let Some(v) = rec.trapped {
1774            acc.trapped = Some(v);
1775        }
1776    }
1777    acc
1778}