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, HashSet};
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    /// `/Catalog /OutputIntents` records to emit. Populated by PDF→PDF
49    /// round-trip from the source PDF's intents; empty by default for the
50    /// PostScript-interpreter path.
51    output_intents: Vec<stet_graphics::document_structure::OutputIntentRecord>,
52    /// Whether to emit an implicit `0 0 W H re W n` clip at the top of each
53    /// content stream. Defaults to `true` for the PostScript-interpreter
54    /// path (where DL content can extend past page bounds and the rasterizer
55    /// clips implicitly). PDF→PDF round-trip sets this to `false` because the
56    /// source content was already authored within the page and the implicit
57    /// clip turns into a spurious top-level `Clip` element on re-read.
58    emit_page_box_clip: bool,
59}
60
61impl PdfDevice {
62    /// Create a new PDF device with the given page dimensions and DPI.
63    pub fn new(width: u32, height: u32, dpi: f64) -> Self {
64        Self {
65            pages: Vec::new(),
66            page_w: width,
67            page_h: height,
68            dpi,
69            output_path: None,
70            pending_trim_box: None,
71            output_profile: None,
72            output_intents: Vec::new(),
73            emit_page_box_clip: true,
74        }
75    }
76
77    /// Control whether to emit the implicit `0 0 W H re W n` page-box clip
78    /// at the top of each content stream. Default `true`; PDF→PDF round-trip
79    /// should set this to `false` so the writer faithfully reproduces the
80    /// source content without injecting a top-level clip.
81    pub fn set_emit_page_box_clip(&mut self, on: bool) {
82        self.emit_page_box_clip = on;
83    }
84
85    /// Set the `/Catalog /OutputIntents` chain to emit. Replaces any
86    /// previously installed records. Used by PDF→PDF round-trip to carry
87    /// the source PDF's PDF/X / PDF/A OutputIntent over to the output;
88    /// preserving this lets the renderer route DeviceGray / DeviceCMYK /
89    /// ICCBased fills through the same destination profile the source
90    /// declared, so a re-rendered output PDF colour-matches the input.
91    pub fn set_output_intents(
92        &mut self,
93        intents: Vec<stet_graphics::document_structure::OutputIntentRecord>,
94    ) {
95        self.output_intents = intents;
96    }
97
98    /// Set the trim box for the next page (in PDF points, lower-left origin).
99    pub fn set_trim_box(&mut self, llx: f64, lly: f64, urx: f64, ury: f64) {
100        self.pending_trim_box = Some((llx, lly, urx, ury));
101    }
102
103    /// Set the page dimensions used for the next page. The PS interpreter
104    /// path drives this implicitly through `setpagedevice` + device-factory
105    /// re-creation; direct API users (e.g. PDF→PDF rewriting) call this
106    /// before each `replay_and_show` so per-page sizes can vary across
107    /// pages in the same output PDF.
108    pub fn set_page_size(&mut self, width: u32, height: u32) {
109        self.page_w = width;
110        self.page_h = height;
111    }
112
113    /// Set an ICC output profile.
114    ///
115    /// Previously embedded as a PDF/X-3 OutputIntent, but the emitted output
116    /// contained transparency features (soft masks) that PDF/X-3 prohibits.
117    /// The OutputIntent emission path has been removed pending a correct
118    /// PDF/X-4 implementation; calling this currently has no effect on the
119    /// output. The setter is retained so the API is forward-compatible with
120    /// the eventual X-4 work.
121    #[deprecated(
122        note = "OutputIntent emission is temporarily disabled pending PDF/X-4 support; calling this has no effect"
123    )]
124    pub fn set_output_profile(&mut self, bytes: Vec<u8>) {
125        self.output_profile = Some(bytes);
126    }
127
128    /// Build the PDF document into a byte vector.
129    ///
130    /// Returns the complete PDF file contents. The device must have at least
131    /// one page (call after `finish()` or `finish_with_context()`).
132    pub fn take_pdf_bytes(&self) -> Option<Vec<u8>> {
133        if self.pages.is_empty() {
134            return None;
135        }
136        let (writer, catalog_ref, info_ref) = self.build_pdf(None).ok()?;
137        let mut buf = Vec::new();
138        writer
139            .write_pdf(&mut buf, catalog_ref, Some(info_ref))
140            .ok()?;
141        Some(buf)
142    }
143
144    /// Build the PDF document into a byte vector, using Context for font data.
145    pub fn take_pdf_bytes_with_context(&self, ctx: &Context) -> Option<Vec<u8>> {
146        if self.pages.is_empty() {
147            return None;
148        }
149        let (writer, catalog_ref, info_ref) = self.build_pdf(Some(ctx)).ok()?;
150        let mut buf = Vec::new();
151        writer
152            .write_pdf(&mut buf, catalog_ref, Some(info_ref))
153            .ok()?;
154        Some(buf)
155    }
156
157    /// Assemble all accumulated pages into a PDF and write to the output file.
158    fn write_pdf(&self, ctx: Option<&Context>) -> Result<(), String> {
159        let path = self.output_path.as_deref().ok_or("no output path set")?;
160        let (writer, catalog_ref, info_ref) = self.build_pdf(ctx)?;
161
162        let file = std::fs::File::create(path).map_err(|e| format!("create {}: {}", path, e))?;
163        let mut bw = std::io::BufWriter::new(file);
164        writer
165            .write_pdf(&mut bw, catalog_ref, Some(info_ref))
166            .map_err(|e| format!("write {}: {}", path, e))?;
167
168        eprintln!("PDF written: {} ({} pages)", path, self.pages.len());
169        Ok(())
170    }
171
172    /// The `/Producer` written when no `pdfmark` `/DOCINFO` overrides it.
173    ///
174    /// Carries the version, so a file can be traced to the build that wrote
175    /// it — the convention every other producer follows (Ghostscript writes
176    /// "GPL Ghostscript 10.05.1", Distiller "Acrobat Distiller 20.0"). A bare
177    /// "stet" tells a prepress operator chasing a rendering difference
178    /// nothing at all.
179    fn default_producer() -> String {
180        format!("stet {}", env!("CARGO_PKG_VERSION"))
181    }
182
183    /// Build the contents of the /Info dict. Starts with device defaults
184    /// (Producer + auto-derived Title + UTC CreationDate) and lets any
185    /// `/DOCINFO` pdfmark record on `ctx.doc_structure` override or
186    /// extend each key. The pdfmark buffer is *not* drained here; phases
187    /// past Phase 1 may want to consult it for separate concerns.
188    fn build_info_dict(&self, ctx: Option<&Context>) -> Vec<(Vec<u8>, PdfObj)> {
189        let docinfo = ctx.map(|c| collect_docinfo(c)).unwrap_or_default();
190
191        let producer = docinfo
192            .producer
193            .clone()
194            .unwrap_or_else(Self::default_producer);
195        let mut entries: Vec<(Vec<u8>, PdfObj)> = vec![(
196            b"Producer".to_vec(),
197            PdfObj::LitString(producer.into_bytes()),
198        )];
199
200        // Title — pdfmark wins; otherwise derive from filename.
201        let title = docinfo.title.clone().or_else(|| {
202            self.output_path
203                .as_deref()
204                .and_then(|p| std::path::Path::new(p).file_stem())
205                .and_then(|s| s.to_str())
206                .map(|s| s.to_string())
207        });
208        if let Some(t) = title {
209            entries.push((b"Title".to_vec(), PdfObj::LitString(t.into_bytes())));
210        }
211
212        for (key, value) in [
213            (&b"Author"[..], &docinfo.author),
214            (&b"Subject"[..], &docinfo.subject),
215            (&b"Keywords"[..], &docinfo.keywords),
216            (&b"Creator"[..], &docinfo.creator),
217        ] {
218            if let Some(v) = value {
219                entries.push((key.to_vec(), PdfObj::LitString(v.clone().into_bytes())));
220            }
221        }
222
223        // CreationDate — pdfmark override or default to "now in UTC".
224        let creation_date = docinfo
225            .creation_date_string()
226            .unwrap_or_else(default_now_pdf_date);
227        entries.push((
228            b"CreationDate".to_vec(),
229            PdfObj::LitString(creation_date.into_bytes()),
230        ));
231
232        if let Some(md) = docinfo.mod_date_string() {
233            entries.push((b"ModDate".to_vec(), PdfObj::LitString(md.into_bytes())));
234        }
235
236        if let Some(t) = docinfo.trapped {
237            let name: &[u8] = match t {
238                stet_graphics::document_structure::TrappedState::True => b"True",
239                stet_graphics::document_structure::TrappedState::False => b"False",
240                stet_graphics::document_structure::TrappedState::Unknown => b"Unknown",
241                _ => b"Unknown",
242            };
243            entries.push((b"Trapped".to_vec(), PdfObj::Name(name.to_vec())));
244        }
245
246        entries
247    }
248
249    /// Build the PDF document, returning the writer and object refs.
250    fn build_pdf(&self, ctx: Option<&Context>) -> Result<(PdfWriter, u32, u32), String> {
251        let mut writer = PdfWriter::new();
252
253        // Pre-allocate catalog and pages objects
254        let catalog_ref = writer.alloc_obj();
255        let pages_ref = writer.alloc_obj();
256
257        // Document-level font tracker — shared across all pages
258        let mut font_tracker = FontTracker::new();
259
260        // First pass: build content streams and register fonts
261        let mut page_results: Vec<(ContentStreamResult, &PageData)> = Vec::new();
262        for page in &self.pages {
263            let result = content_stream::build_content_stream(
264                &page.display_list,
265                page.page_w,
266                page.page_h,
267                page.dpi,
268                ctx,
269                &mut font_tracker,
270                self.emit_page_box_clip,
271            );
272            page_results.push((result, page));
273        }
274
275        // Embed each unique font once at document level
276        let font_obj_map: HashMap<String, u32> =
277            self.embed_all_fonts(&mut writer, &font_tracker, ctx);
278
279        // Collect document-level Optional-Content state from every
280        // page's OcgMarkerRef list and allocate one /OCG indirect per
281        // unique ocg_id. The Catalog's /OCProperties references all of
282        // them; per-page /Properties dicts (built in build_page below)
283        // map the page-local resource names (P0, P1, …) to these refs.
284        let mut ocg_id_to_ref: HashMap<u32, u32> = HashMap::new();
285        let mut ocg_default_off: HashSet<u32> = HashSet::new();
286        let mut ocg_order: Vec<u32> = Vec::new();
287        for (result, _) in &page_results {
288            for marker in &result.ocg_marker_refs {
289                let mut visit = |ocg_id: u32, default_visible: bool| {
290                    if let std::collections::hash_map::Entry::Vacant(e) =
291                        ocg_id_to_ref.entry(ocg_id)
292                    {
293                        let r = writer.add_object(&PdfObj::Dict(vec![
294                            (b"Type".to_vec(), PdfObj::name("OCG")),
295                            (
296                                b"Name".to_vec(),
297                                PdfObj::LitString(format!("Layer {}", ocg_id).into_bytes()),
298                            ),
299                        ]));
300                        e.insert(r);
301                        ocg_order.push(ocg_id);
302                        if !default_visible {
303                            ocg_default_off.insert(ocg_id);
304                        }
305                    }
306                };
307                collect_ocg_ids(&marker.visibility, &mut visit);
308            }
309        }
310
311        // Pre-allocate page object numbers so annotations can reference
312        // their target pages by indirect ref before the page dict is
313        // written, and so /Annots arrays can be assembled at build time.
314        let page_refs: Vec<u32> = (0..page_results.len())
315            .map(|_| writer.alloc_obj())
316            .collect();
317
318        // Build per-page annotation objects up front so each page dict
319        // gets its /Annots array. Widget annotations are split off and
320        // emitted by `form_fields::write_form`, which owns the field
321        // tree they sit under; the rest go through the standard
322        // annotation path.
323        let mut per_page_annots: Vec<Vec<u32>> = ctx
324            .map(|c| {
325                let records: Vec<stet_graphics::document_structure::AnnotationRecord> = c
326                    .doc_structure
327                    .records()
328                    .iter()
329                    .filter_map(|r| match r {
330                        stet_graphics::document_structure::StructuralRecord::Annotation(rec) => {
331                            Some(rec.clone())
332                        }
333                        _ => None,
334                    })
335                    .collect();
336                if records.is_empty() {
337                    return vec![Vec::new(); page_refs.len()];
338                }
339                crate::annotations::collect_per_page(&mut writer, &records, &page_refs)
340            })
341            .unwrap_or_else(|| vec![Vec::new(); page_refs.len()]);
342
343        // Form fields — Widget annotations + /FORM record assembled
344        // into /AcroForm. The output's per-page widget refs merge into
345        // per_page_annots above so each page's /Annots array carries
346        // both standard annotations and widget annotations.
347        let acroform_output = ctx.and_then(|c| {
348            let widgets: Vec<(usize, stet_graphics::document_structure::AnnotationRecord)> = c
349                .doc_structure
350                .records()
351                .iter()
352                .enumerate()
353                .filter_map(|(i, r)| match r {
354                    stet_graphics::document_structure::StructuralRecord::Annotation(rec)
355                        if matches!(
356                            rec.subtype,
357                            stet_graphics::document_structure::AnnotationSubtype::Widget(_)
358                        ) =>
359                    {
360                        Some((i, rec.clone()))
361                    }
362                    _ => None,
363                })
364                .collect();
365            let form_record = c
366                .doc_structure
367                .records()
368                .iter()
369                .filter_map(|r| match r {
370                    stet_graphics::document_structure::StructuralRecord::Form(rec) => {
371                        Some(rec.clone())
372                    }
373                    _ => None,
374                })
375                .reduce(|acc, next| next.merge_over(&acc));
376            crate::form_fields::write_form(
377                &mut writer,
378                &widgets,
379                form_record.as_ref(),
380                page_refs.len(),
381            )
382        });
383        if let Some(out) = &acroform_output {
384            for (i, refs) in out.per_page_widget_refs.iter().enumerate() {
385                per_page_annots[i].extend(refs);
386            }
387        }
388
389        // Layer /PAGES (document-wide defaults) under /PAGE (per-page
390        // overrides) into one PageOverride per page. Later /PAGE
391        // records override earlier ones key-by-key, matching the same
392        // "later wins" rule we apply to /DOCINFO.
393        let per_page_overrides = compute_page_overrides(ctx, page_refs.len());
394
395        // Second pass: build page objects referencing shared font objects
396        for (i, (result, page)) in page_results.iter().enumerate() {
397            self.build_page(
398                &mut writer,
399                page,
400                pages_ref,
401                page_refs[i],
402                result,
403                &font_obj_map,
404                &mut font_tracker,
405                &per_page_annots[i],
406                &per_page_overrides[i],
407                &ocg_id_to_ref,
408            )?;
409        }
410
411        // Pages object
412        writer.set_object(
413            pages_ref,
414            &PdfObj::Dict(vec![
415                (b"Type".to_vec(), PdfObj::name("Pages")),
416                (
417                    b"Kids".to_vec(),
418                    PdfObj::Array(page_refs.iter().map(|&r| PdfObj::Ref(r)).collect()),
419                ),
420                (b"Count".to_vec(), PdfObj::Int(page_refs.len() as i64)),
421            ]),
422        );
423
424        // Outlines — emitted from `/OUT pdfmark` records on the
425        // pdfmark buffer. Returns `None` when no /OUT records were
426        // issued, in which case /Catalog stays free of /Outlines.
427        let outlines_ref = ctx.and_then(|c| {
428            let records: Vec<stet_graphics::document_structure::OutlineRecord> = c
429                .doc_structure
430                .records()
431                .iter()
432                .filter_map(|r| match r {
433                    stet_graphics::document_structure::StructuralRecord::Outline(rec) => {
434                        Some(rec.clone())
435                    }
436                    _ => None,
437                })
438                .collect();
439            if records.is_empty() {
440                return None;
441            }
442            let tree = stet_graphics::document_structure::build_outline_tree(&records);
443            crate::outline::write_outline_tree(&mut writer, &tree, &page_refs)
444        });
445
446        // /Names — combined tree of /Dests (from /DEST records) and
447        // /EmbeddedFiles (from /EMBED records). Each leaf is built
448        // separately, then `write_names_root` combines them into one
449        // catalog-level dict.
450        let dests_leaf = ctx.and_then(|c| {
451            let records: Vec<stet_graphics::document_structure::DestRecord> = c
452                .doc_structure
453                .records()
454                .iter()
455                .filter_map(|r| match r {
456                    stet_graphics::document_structure::StructuralRecord::Dest(rec) => {
457                        Some(rec.clone())
458                    }
459                    _ => None,
460                })
461                .collect();
462            crate::names::build_dests_leaf(&mut writer, &records, &page_refs)
463        });
464        let embedded_files_leaf = ctx.and_then(|c| {
465            let records: Vec<stet_graphics::document_structure::EmbedRecord> = c
466                .doc_structure
467                .records()
468                .iter()
469                .filter_map(|r| match r {
470                    stet_graphics::document_structure::StructuralRecord::Embed(rec) => {
471                        Some(rec.clone())
472                    }
473                    _ => None,
474                })
475                .collect();
476            crate::attachments::build_embedded_files_leaf(&mut writer, &records)
477        });
478        let names_ref =
479            crate::names::write_names_root(&mut writer, dests_leaf, embedded_files_leaf);
480
481        // /VIEWERPREFERENCES — merge all records into one effective
482        // viewer-prefs bag, then split into the `/ViewerPreferences`
483        // indirect object plus the catalog-level `/PageLayout` and
484        // `/PageMode` entries which sit on `/Catalog` directly.
485        let merged_prefs = ctx.map(collect_viewer_prefs).unwrap_or_default();
486        let viewer_prefs_ref = crate::metadata::write_viewer_prefs(&mut writer, &merged_prefs);
487
488        // /Metadata — last record wins; emit the stream object.
489        let metadata_ref = ctx.and_then(|c| {
490            c.doc_structure
491                .records()
492                .iter()
493                .rev()
494                .find_map(|r| match r {
495                    stet_graphics::document_structure::StructuralRecord::Metadata(rec) => {
496                        Some(rec.clone())
497                    }
498                    _ => None,
499                })
500                .map(|rec| crate::metadata::write_xmp_metadata(&mut writer, &rec))
501        });
502
503        // Catalog
504        let mut catalog_entries = vec![
505            (b"Type".to_vec(), PdfObj::name("Catalog")),
506            (b"Pages".to_vec(), PdfObj::Ref(pages_ref)),
507        ];
508
509        // /OCProperties — Optional-Content document state. /OCGs lists
510        // every layer used anywhere in the document; /D is the default
511        // configuration the viewer applies on open (which layers start
512        // on, the display order, base state). Per-marker visibility
513        // decisions live in the per-page /Properties dicts the build_page
514        // loop above wrote, so /OCProperties only describes layers, not
515        // their per-page bracket semantics.
516        if !ocg_id_to_ref.is_empty() {
517            let make_array = || -> Vec<PdfObj> {
518                ocg_order
519                    .iter()
520                    .filter_map(|id| ocg_id_to_ref.get(id).map(|&r| PdfObj::Ref(r)))
521                    .collect()
522            };
523            let off_array: Vec<PdfObj> = ocg_order
524                .iter()
525                .filter(|id| ocg_default_off.contains(id))
526                .filter_map(|id| ocg_id_to_ref.get(id).map(|&r| PdfObj::Ref(r)))
527                .collect();
528            let mut d_entries: Vec<(Vec<u8>, PdfObj)> = vec![
529                (b"Name".to_vec(), PdfObj::LitString(b"Default".to_vec())),
530                (b"BaseState".to_vec(), PdfObj::name("ON")),
531                (b"Order".to_vec(), PdfObj::Array(make_array())),
532            ];
533            if !off_array.is_empty() {
534                d_entries.push((b"OFF".to_vec(), PdfObj::Array(off_array)));
535            }
536            let ocprops = writer.add_object(&PdfObj::Dict(vec![
537                (b"OCGs".to_vec(), PdfObj::Array(make_array())),
538                (b"D".to_vec(), PdfObj::Dict(d_entries)),
539            ]));
540            catalog_entries.push((b"OCProperties".to_vec(), PdfObj::Ref(ocprops)));
541        }
542        if let Some(outline_ref) = outlines_ref {
543            catalog_entries.push((b"Outlines".to_vec(), PdfObj::Ref(outline_ref)));
544        }
545        if let Some(names_ref) = names_ref {
546            catalog_entries.push((b"Names".to_vec(), PdfObj::Ref(names_ref)));
547        }
548        if let Some(viewer_prefs_ref) = viewer_prefs_ref {
549            catalog_entries.push((b"ViewerPreferences".to_vec(), PdfObj::Ref(viewer_prefs_ref)));
550        }
551        // /PageLayout — only the producer-supplied value, validated.
552        if let Some(layout_bytes) = merged_prefs
553            .page_layout
554            .as_deref()
555            .and_then(crate::metadata::validated_page_layout)
556        {
557            catalog_entries.push((b"PageLayout".to_vec(), PdfObj::Name(layout_bytes.to_vec())));
558        }
559        // /PageMode — producer's /VIEWERPREFERENCES /PageMode wins;
560        // otherwise fall back to /UseOutlines when an outline tree
561        // exists so viewers open the bookmark pane by default.
562        let effective_page_mode: Option<Vec<u8>> = merged_prefs
563            .page_mode
564            .as_deref()
565            .and_then(crate::metadata::validated_page_mode)
566            .map(|v| v.to_vec())
567            .or_else(|| outlines_ref.map(|_| b"UseOutlines".to_vec()));
568        if let Some(mode) = effective_page_mode {
569            catalog_entries.push((b"PageMode".to_vec(), PdfObj::Name(mode)));
570        }
571        if let Some(metadata_ref) = metadata_ref {
572            catalog_entries.push((b"Metadata".to_vec(), PdfObj::Ref(metadata_ref)));
573        }
574        if let Some(out) = &acroform_output {
575            catalog_entries.push((b"AcroForm".to_vec(), PdfObj::Ref(out.acroform_ref)));
576        }
577
578        // /Catalog /OutputIntents — emitted before set_object so the
579        // intent dicts and their ICC profile streams land in the writer
580        // first, then the catalog references them.
581        if !self.output_intents.is_empty() {
582            let intent_refs = emit_output_intents(&mut writer, &self.output_intents);
583            if !intent_refs.is_empty() {
584                catalog_entries.push((
585                    b"OutputIntents".to_vec(),
586                    PdfObj::Array(intent_refs.into_iter().map(PdfObj::Ref).collect()),
587                ));
588            }
589        }
590
591        writer.set_object(catalog_ref, &PdfObj::Dict(catalog_entries));
592
593        // Info dictionary — start with device defaults, then let any
594        // /DOCINFO pdfmark records override or extend.
595        let info_ref = writer.alloc_obj();
596        let info_entries = self.build_info_dict(ctx);
597        writer.set_object(info_ref, &PdfObj::Dict(info_entries));
598
599        Ok((writer, catalog_ref, info_ref))
600    }
601
602    /// Embed all tracked fonts once at document level.
603    /// Returns a map from PDF font name (e.g. "F0") to the PDF object number.
604    fn embed_all_fonts(
605        &self,
606        writer: &mut PdfWriter,
607        font_tracker: &FontTracker,
608        ctx: Option<&Context>,
609    ) -> HashMap<String, u32> {
610        let mut map = HashMap::new();
611        for usage in font_tracker.fonts() {
612            let font_ref = if let Some(c) = ctx {
613                font_embedder::build_font_resource(writer, usage, c).unwrap_or_else(|| {
614                    let tu = font_embedder::build_tounicode_for_fallback(writer, usage, c);
615                    self.build_font_reference(writer, usage, tu)
616                })
617            } else {
618                self.build_font_reference(writer, usage, None)
619            };
620            map.insert(usage.pdf_name.clone(), font_ref);
621        }
622        map
623    }
624
625    /// Build PDF objects for a single page. The page's indirect object
626    /// number is pre-allocated by the caller (so annotations can target
627    /// the page before its dict is written), and the per-page
628    /// annotation refs are passed in for inclusion in the page's
629    /// `/Annots` array.
630    #[allow(clippy::too_many_arguments)]
631    fn build_page(
632        &self,
633        writer: &mut PdfWriter,
634        page: &PageData,
635        pages_ref: u32,
636        page_ref: u32,
637        result: &ContentStreamResult,
638        font_obj_map: &HashMap<String, u32>,
639        font_tracker: &mut FontTracker,
640        annot_refs: &[u32],
641        overrides: &EffectivePageOverride,
642        ocg_id_to_ref: &HashMap<u32, u32>,
643    ) -> Result<(), String> {
644        let ContentStreamResult {
645            content,
646            images,
647            shading_refs,
648            used_font_names,
649            ext_gstate_dicts,
650            color_spaces,
651            icc_color_spaces,
652            pattern_refs,
653            pattern_cs_entries,
654            transfer_refs,
655            halftone_refs,
656            bg_ucr_refs,
657            soft_mask_refs,
658            ocg_marker_refs,
659            form_xobjects,
660        } = result;
661
662        // Build image XObjects and Form XObjects (Group / SoftMasked
663        // content). Both share the page's /XObject resource dict; the
664        // Forms inherit /Resources from the page per PDF 1.7 § 7.8.3.
665        // Capture each form's indirect ref alongside the resource entries
666        // so the soft-mask /SMask patches below can wire them up.
667        let mut xobject_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
668        for (i, img) in images.iter().enumerate() {
669            let img_ref = self.build_image_xobject(writer, img);
670            xobject_entries.push((format!("Im{}", i).into_bytes(), PdfObj::Ref(img_ref)));
671        }
672        let mut form_obj_refs: Vec<u32> = Vec::with_capacity(form_xobjects.len());
673        for (i, form) in form_xobjects.iter().enumerate() {
674            let form_ref = build_form_xobject(writer, form);
675            form_obj_refs.push(form_ref);
676            xobject_entries.push((format!("X{}", i).into_bytes(), PdfObj::Ref(form_ref)));
677        }
678
679        // Build shading objects
680        let mut shading_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
681        for (i, sh_ref) in shading_refs.iter().enumerate() {
682            let sh_obj = match sh_ref {
683                ShadingRef::Axial(p) => shading_ops::build_axial_shading(writer, p),
684                ShadingRef::Radial(p) => shading_ops::build_radial_shading(writer, p),
685                ShadingRef::Mesh(p) => shading_ops::build_mesh_shading(writer, p),
686                ShadingRef::Patch(p) => shading_ops::build_patch_shading(writer, p),
687            };
688            shading_entries.push((format!("Sh{}", i).into_bytes(), PdfObj::Ref(sh_obj)));
689        }
690
691        // Build per-page font resource references (pointing to shared document-level objects)
692        let mut font_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
693        for name in used_font_names {
694            if let Some(&obj_ref) = font_obj_map.get(name) {
695                font_entries.push((name.clone().into_bytes(), PdfObj::Ref(obj_ref)));
696            }
697        }
698
699        // Resources dict
700        let mut resources: Vec<(Vec<u8>, PdfObj)> = Vec::new();
701        if !font_entries.is_empty() {
702            resources.push((b"Font".to_vec(), PdfObj::Dict(font_entries)));
703        }
704        if !xobject_entries.is_empty() {
705            resources.push((b"XObject".to_vec(), PdfObj::Dict(xobject_entries)));
706        }
707        if !shading_entries.is_empty() {
708            resources.push((b"Shading".to_vec(), PdfObj::Dict(shading_entries)));
709        }
710
711        // Build ExtGState resources
712        if !ext_gstate_dicts.is_empty() {
713            let mut gs_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
714            for (i, gs_dict) in ext_gstate_dicts.iter().enumerate() {
715                // Rebuild entries (PdfObj doesn't derive Clone)
716                let mut entries: Vec<(Vec<u8>, PdfObj)> = gs_dict
717                    .entries
718                    .iter()
719                    .map(|(k, v)| {
720                        let obj = match v {
721                            PdfObj::Bool(b) => PdfObj::Bool(*b),
722                            PdfObj::Int(n) => PdfObj::Int(*n),
723                            PdfObj::Real(r) => PdfObj::Real(*r),
724                            PdfObj::Name(n) => PdfObj::Name(n.clone()),
725                            PdfObj::Ref(r) => PdfObj::Ref(*r),
726                            _ => PdfObj::Null,
727                        };
728                        (k.clone(), obj)
729                    })
730                    .collect();
731
732                // Check if this ExtGState has a transfer function reference
733                if let Some(tr) = transfer_refs.iter().find(|r| r.ext_gstate_idx == i) {
734                    let tr2_value = build_transfer_tr2(writer, &tr.tables, tr.is_color);
735                    entries.push((b"TR2".to_vec(), tr2_value));
736                }
737
738                // Check if this ExtGState has a halftone reference
739                if let Some(hr) = halftone_refs.iter().find(|r| r.ext_gstate_idx == i) {
740                    let ht_value = build_halftone_ht(writer, &hr.state);
741                    entries.push((b"HT".to_vec(), ht_value));
742                }
743
744                // Check if this ExtGState has BG/UCR references
745                if let Some(br) = bg_ucr_refs.iter().find(|r| r.ext_gstate_idx == i) {
746                    if let Some(ref bg) = br.state.bg {
747                        let func_ref = build_type0_function(writer, bg);
748                        entries.push((b"BG2".to_vec(), PdfObj::Ref(func_ref)));
749                    }
750                    if let Some(ref ucr) = br.state.ucr {
751                        let func_ref = build_type0_function_signed(writer, ucr);
752                        entries.push((b"UCR2".to_vec(), PdfObj::Ref(func_ref)));
753                    }
754                }
755
756                // Check if this ExtGState has a soft-mask reference. The
757                // mask form ref was allocated in form_obj_refs above; here
758                // we assemble the /SMask dict and stitch it in.
759                if let Some(sm) = soft_mask_refs.iter().find(|r| r.ext_gstate_idx == i) {
760                    let mask_ref = form_obj_refs[sm.mask_form_idx];
761                    let subtype_name: &[u8] = match sm.subtype {
762                        stet_graphics::display_list::SoftMaskSubtype::Alpha => b"Alpha",
763                        stet_graphics::display_list::SoftMaskSubtype::Luminosity => b"Luminosity",
764                    };
765                    let mut smask_entries: Vec<(Vec<u8>, PdfObj)> = vec![
766                        (b"Type".to_vec(), PdfObj::name("Mask")),
767                        (b"S".to_vec(), PdfObj::Name(subtype_name.to_vec())),
768                        (b"G".to_vec(), PdfObj::Ref(mask_ref)),
769                    ];
770                    if let Some([r, g, b]) = sm.backdrop_color {
771                        smask_entries.push((
772                            b"BC".to_vec(),
773                            PdfObj::Array(vec![PdfObj::Real(r), PdfObj::Real(g), PdfObj::Real(b)]),
774                        ));
775                    }
776                    if sm.transfer_invert {
777                        // Emit /TR { 1 exch sub } as a Type 4 (PostScript)
778                        // function. Inline via an indirect-stream object.
779                        let tr_ref = build_invert_transfer(writer);
780                        smask_entries.push((b"TR".to_vec(), PdfObj::Ref(tr_ref)));
781                    }
782                    entries.push((b"SMask".to_vec(), PdfObj::Dict(smask_entries)));
783                }
784
785                let gs_ref = writer.add_object(&PdfObj::Dict(entries));
786                gs_entries.push((format!("GS{}", i).into_bytes(), PdfObj::Ref(gs_ref)));
787            }
788            resources.push((b"ExtGState".to_vec(), PdfObj::Dict(gs_entries)));
789        }
790
791        // Build ColorSpace resources (for Separation/DeviceN fill/stroke colors)
792        let mut cs_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
793        for (name, spot_cs) in color_spaces {
794            let cs_obj = build_spot_colorspace(spot_cs, writer);
795            cs_entries.push((name.clone().into_bytes(), cs_obj));
796        }
797        // ICCBased fill/stroke color space resources. Each emits one
798        // /ICCBased stream object and a `[/ICCBased <ref>]` array.
799        for (name, icc_cs) in icc_color_spaces {
800            let icc_ref = writer.add_stream(
801                vec![(b"N".to_vec(), PdfObj::Int(icc_cs.n as i64))],
802                &icc_cs.profile_data,
803                true,
804            );
805            cs_entries.push((
806                name.clone().into_bytes(),
807                PdfObj::Array(vec![PdfObj::name("ICCBased"), PdfObj::Ref(icc_ref)]),
808            ));
809        }
810        // Add uncolored pattern color space entries (e.g., [/Pattern /DeviceRGB])
811        for (name, cs_obj) in pattern_cs_entries {
812            // Reconstruct PdfObj since it doesn't derive Clone
813            let obj = match cs_obj {
814                PdfObj::Array(items) => {
815                    let cloned: Vec<PdfObj> = items
816                        .iter()
817                        .map(|item| match item {
818                            PdfObj::Name(n) => PdfObj::Name(n.clone()),
819                            PdfObj::Int(n) => PdfObj::Int(*n),
820                            PdfObj::Real(n) => PdfObj::Real(*n),
821                            PdfObj::Ref(r) => PdfObj::Ref(*r),
822                            _ => PdfObj::Null,
823                        })
824                        .collect();
825                    PdfObj::Array(cloned)
826                }
827                _ => PdfObj::Null,
828            };
829            cs_entries.push((name.clone().into_bytes(), obj));
830        }
831        if !cs_entries.is_empty() {
832            resources.push((b"ColorSpace".to_vec(), PdfObj::Dict(cs_entries)));
833        }
834
835        // Build /Properties dict for Optional-Content markers. Each
836        // BDC marker in the content stream names a resource here, which
837        // resolves to either an /OCG (Single visibility) or an /OCMD
838        // (Membership / Expression).
839        if !ocg_marker_refs.is_empty() {
840            let mut props_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
841            for marker in ocg_marker_refs {
842                let prop_ref = build_ocg_property_ref(writer, &marker.visibility, ocg_id_to_ref);
843                props_entries.push((
844                    marker.resource_name.clone().into_bytes(),
845                    PdfObj::Ref(prop_ref),
846                ));
847            }
848            resources.push((b"Properties".to_vec(), PdfObj::Dict(props_entries)));
849        }
850
851        // Build Pattern XObject resources
852        if !pattern_refs.is_empty() {
853            let mut pattern_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
854            for (i, pat_ref) in pattern_refs.iter().enumerate() {
855                let tile_result =
856                    content_stream::build_tile_content_stream(&pat_ref.tile, font_tracker);
857
858                // Build tile resources
859                let mut tile_resources: Vec<(Vec<u8>, PdfObj)> = Vec::new();
860
861                // Tile images
862                if !tile_result.images.is_empty() {
863                    let mut tile_xobj: Vec<(Vec<u8>, PdfObj)> = Vec::new();
864                    for (j, img) in tile_result.images.iter().enumerate() {
865                        let img_ref = self.build_image_xobject(writer, img);
866                        tile_xobj.push((format!("Im{}", j).into_bytes(), PdfObj::Ref(img_ref)));
867                    }
868                    tile_resources.push((b"XObject".to_vec(), PdfObj::Dict(tile_xobj)));
869                }
870
871                // Tile shadings
872                if !tile_result.shading_refs.is_empty() {
873                    let mut tile_sh: Vec<(Vec<u8>, PdfObj)> = Vec::new();
874                    for (j, sh_ref) in tile_result.shading_refs.iter().enumerate() {
875                        let sh_obj = match sh_ref {
876                            ShadingRef::Axial(p) => shading_ops::build_axial_shading(writer, p),
877                            ShadingRef::Radial(p) => shading_ops::build_radial_shading(writer, p),
878                            ShadingRef::Mesh(p) => shading_ops::build_mesh_shading(writer, p),
879                            ShadingRef::Patch(p) => shading_ops::build_patch_shading(writer, p),
880                        };
881                        tile_sh.push((format!("Sh{}", j).into_bytes(), PdfObj::Ref(sh_obj)));
882                    }
883                    tile_resources.push((b"Shading".to_vec(), PdfObj::Dict(tile_sh)));
884                }
885
886                // Tile fonts
887                if !tile_result.used_font_names.is_empty() {
888                    let mut tile_fonts: Vec<(Vec<u8>, PdfObj)> = Vec::new();
889                    for name in &tile_result.used_font_names {
890                        if let Some(&obj_ref) = font_obj_map.get(name) {
891                            tile_fonts.push((name.clone().into_bytes(), PdfObj::Ref(obj_ref)));
892                        }
893                    }
894                    if !tile_fonts.is_empty() {
895                        tile_resources.push((b"Font".to_vec(), PdfObj::Dict(tile_fonts)));
896                    }
897                }
898
899                // Tile ExtGState
900                if !tile_result.ext_gstate_dicts.is_empty() {
901                    let mut tile_gs: Vec<(Vec<u8>, PdfObj)> = Vec::new();
902                    for (j, gs_dict) in tile_result.ext_gstate_dicts.iter().enumerate() {
903                        let mut entries: Vec<(Vec<u8>, PdfObj)> = gs_dict
904                            .entries
905                            .iter()
906                            .map(|(k, v)| {
907                                let obj = match v {
908                                    PdfObj::Bool(b) => PdfObj::Bool(*b),
909                                    PdfObj::Int(n) => PdfObj::Int(*n),
910                                    PdfObj::Real(r) => PdfObj::Real(*r),
911                                    PdfObj::Name(n) => PdfObj::Name(n.clone()),
912                                    PdfObj::Ref(r) => PdfObj::Ref(*r),
913                                    _ => PdfObj::Null,
914                                };
915                                (k.clone(), obj)
916                            })
917                            .collect();
918                        if let Some(tr) = tile_result
919                            .transfer_refs
920                            .iter()
921                            .find(|r| r.ext_gstate_idx == j)
922                        {
923                            let tr2_value = build_transfer_tr2(writer, &tr.tables, tr.is_color);
924                            entries.push((b"TR2".to_vec(), tr2_value));
925                        }
926                        if let Some(hr) = tile_result
927                            .halftone_refs
928                            .iter()
929                            .find(|r| r.ext_gstate_idx == j)
930                        {
931                            let ht_value = build_halftone_ht(writer, &hr.state);
932                            entries.push((b"HT".to_vec(), ht_value));
933                        }
934                        if let Some(br) = tile_result
935                            .bg_ucr_refs
936                            .iter()
937                            .find(|r| r.ext_gstate_idx == j)
938                        {
939                            if let Some(ref bg) = br.state.bg {
940                                let func_ref = build_type0_function(writer, bg);
941                                entries.push((b"BG2".to_vec(), PdfObj::Ref(func_ref)));
942                            }
943                            if let Some(ref ucr) = br.state.ucr {
944                                let func_ref = build_type0_function_signed(writer, ucr);
945                                entries.push((b"UCR2".to_vec(), PdfObj::Ref(func_ref)));
946                            }
947                        }
948                        let gs_ref = writer.add_object(&PdfObj::Dict(entries));
949                        tile_gs.push((format!("GS{}", j).into_bytes(), PdfObj::Ref(gs_ref)));
950                    }
951                    tile_resources.push((b"ExtGState".to_vec(), PdfObj::Dict(tile_gs)));
952                }
953
954                // Tile color spaces (Separation/DeviceN + ICCBased)
955                if !tile_result.color_spaces.is_empty() || !tile_result.icc_color_spaces.is_empty()
956                {
957                    let mut tile_cs: Vec<(Vec<u8>, PdfObj)> = Vec::new();
958                    for (name, spot_cs) in &tile_result.color_spaces {
959                        let cs_obj = build_spot_colorspace(spot_cs, writer);
960                        tile_cs.push((name.clone().into_bytes(), cs_obj));
961                    }
962                    for (name, icc_cs) in &tile_result.icc_color_spaces {
963                        let icc_ref = writer.add_stream(
964                            vec![(b"N".to_vec(), PdfObj::Int(icc_cs.n as i64))],
965                            &icc_cs.profile_data,
966                            true,
967                        );
968                        tile_cs.push((
969                            name.clone().into_bytes(),
970                            PdfObj::Array(vec![PdfObj::name("ICCBased"), PdfObj::Ref(icc_ref)]),
971                        ));
972                    }
973                    tile_resources.push((b"ColorSpace".to_vec(), PdfObj::Dict(tile_cs)));
974                }
975
976                // Build Pattern stream object
977                let m = &pat_ref.pattern_matrix;
978                let pat_dict = vec![
979                    (b"Type".to_vec(), PdfObj::name("Pattern")),
980                    (b"PatternType".to_vec(), PdfObj::Int(1)),
981                    (
982                        b"PaintType".to_vec(),
983                        PdfObj::Int(pat_ref.paint_type as i64),
984                    ),
985                    (b"TilingType".to_vec(), PdfObj::Int(1)),
986                    (
987                        b"BBox".to_vec(),
988                        // Expand BBox slightly beyond XStep/YStep so adjacent tiles
989                        // overlap, eliminating hairline seam artifacts in PDF viewers.
990                        PdfObj::Array(vec![
991                            PdfObj::Real(pat_ref.bbox[0] - 0.5),
992                            PdfObj::Real(pat_ref.bbox[1] - 0.5),
993                            PdfObj::Real(pat_ref.bbox[2] + 0.5),
994                            PdfObj::Real(pat_ref.bbox[3] + 0.5),
995                        ]),
996                    ),
997                    (b"XStep".to_vec(), PdfObj::Real(pat_ref.xstep)),
998                    (b"YStep".to_vec(), PdfObj::Real(pat_ref.ystep)),
999                    (
1000                        b"Matrix".to_vec(),
1001                        PdfObj::Array(vec![
1002                            PdfObj::Real(m.a),
1003                            PdfObj::Real(m.b),
1004                            PdfObj::Real(m.c),
1005                            PdfObj::Real(m.d),
1006                            PdfObj::Real(m.tx),
1007                            PdfObj::Real(m.ty),
1008                        ]),
1009                    ),
1010                    (b"Resources".to_vec(), PdfObj::Dict(tile_resources)),
1011                ];
1012
1013                let pat_obj = writer.add_stream(pat_dict, &tile_result.content, true);
1014                pattern_entries.push((format!("P{}", i).into_bytes(), PdfObj::Ref(pat_obj)));
1015            }
1016
1017            resources.push((b"Pattern".to_vec(), PdfObj::Dict(pattern_entries)));
1018        }
1019
1020        // Content stream
1021        let content_ref = writer.add_stream(Vec::new(), content, true);
1022
1023        // Page object
1024        let mut page_entries = vec![
1025            (b"Type".to_vec(), PdfObj::name("Page")),
1026            (b"Parent".to_vec(), PdfObj::Ref(pages_ref)),
1027            (
1028                b"MediaBox".to_vec(),
1029                PdfObj::Array(vec![
1030                    PdfObj::Int(0),
1031                    PdfObj::Int(0),
1032                    PdfObj::Real(page.width_pts),
1033                    PdfObj::Real(page.height_pts),
1034                ]),
1035            ),
1036            (b"Contents".to_vec(), PdfObj::Ref(content_ref)),
1037            (b"Resources".to_vec(), PdfObj::Dict(resources)),
1038        ];
1039        // Page-level transparency group. PDF/X-1a sources set a
1040        // /Group << /S /Transparency /CS DeviceCMYK >> on the page so
1041        // overprint compositing happens in CMYK; without it, the
1042        // renderer falls back to RGB compositing and overprint
1043        // semantics break (50% K painted on a green spot/CMYK cell no
1044        // longer knocks out the cell — visible as residual green or
1045        // green-X-shaped strokes leaking past the K paint).
1046        let page_cs: Option<&str> = match page.display_list.page_group_color_space() {
1047            stet_graphics::display_list::GroupColorSpace::DeviceGray => Some("DeviceGray"),
1048            stet_graphics::display_list::GroupColorSpace::DeviceRGB => Some("DeviceRGB"),
1049            stet_graphics::display_list::GroupColorSpace::DeviceCMYK => Some("DeviceCMYK"),
1050            stet_graphics::display_list::GroupColorSpace::Inherited => None,
1051        };
1052        if let Some(cs) = page_cs {
1053            page_entries.push((
1054                b"Group".to_vec(),
1055                PdfObj::Dict(vec![
1056                    (b"Type".to_vec(), PdfObj::name("Group")),
1057                    (b"S".to_vec(), PdfObj::name("Transparency")),
1058                    (b"CS".to_vec(), PdfObj::name(cs)),
1059                ]),
1060            ));
1061        }
1062        // /CropBox / /BleedBox / /TrimBox / /ArtBox: pdfmark /PAGE or
1063        // /PAGES wins; otherwise fall back to the device's pending
1064        // trim_box (set via PdfDevice::set_trim_box).
1065        let effective_trim = overrides.boxes.trim_box.or_else(|| {
1066            page.trim_box
1067                .map(|(llx, lly, urx, ury)| [llx, lly, urx, ury])
1068        });
1069        for (name, b) in [
1070            (b"CropBox".as_slice(), overrides.boxes.crop_box),
1071            (b"BleedBox".as_slice(), overrides.boxes.bleed_box),
1072            (b"TrimBox".as_slice(), effective_trim),
1073            (b"ArtBox".as_slice(), overrides.boxes.art_box),
1074        ] {
1075            if let Some([llx, lly, urx, ury]) = b {
1076                page_entries.push((
1077                    name.to_vec(),
1078                    PdfObj::Array(vec![
1079                        PdfObj::Real(llx),
1080                        PdfObj::Real(lly),
1081                        PdfObj::Real(urx),
1082                        PdfObj::Real(ury),
1083                    ]),
1084                ));
1085            }
1086        }
1087        if let Some(rotate) = overrides.rotate
1088            && matches!(rotate, 0 | 90 | 180 | 270 | -90 | -180 | -270)
1089        {
1090            page_entries.push((b"Rotate".to_vec(), PdfObj::Int(rotate as i64)));
1091        }
1092        if !annot_refs.is_empty() {
1093            page_entries.push((
1094                b"Annots".to_vec(),
1095                PdfObj::Array(annot_refs.iter().map(|r| PdfObj::Ref(*r)).collect()),
1096            ));
1097        }
1098        if let Some(aa) = &overrides.additional_actions
1099            && !aa.is_empty()
1100        {
1101            // Page-level /AA — open / close hooks. Page refs aren't
1102            // available to action targets here (an /O /GoTo can land
1103            // on any page; we use the empty page_refs slice so out-of-
1104            // range refs short-circuit to None and the action drops).
1105            let mut aa_entries: Vec<(Vec<u8>, PdfObj)> = Vec::new();
1106            if let Some(action) = &aa.on_open
1107                && let Some(dict) = crate::outline::encode_action(action, &[])
1108            {
1109                aa_entries.push((b"O".to_vec(), dict));
1110            }
1111            if let Some(action) = &aa.on_close
1112                && let Some(dict) = crate::outline::encode_action(action, &[])
1113            {
1114                aa_entries.push((b"C".to_vec(), dict));
1115            }
1116            if !aa_entries.is_empty() {
1117                page_entries.push((b"AA".to_vec(), PdfObj::Dict(aa_entries)));
1118            }
1119        }
1120        writer.set_object(page_ref, &PdfObj::Dict(page_entries));
1121
1122        Ok(())
1123    }
1124
1125    /// Build a PDF font reference for a tracked font.
1126    ///
1127    /// For Standard 14 fonts, creates a simple Type1 font dict.
1128    /// For other fonts, also creates a simple Type1 dict (no embedding yet).
1129    /// Both include a ToUnicode CMap for searchability.
1130    fn build_font_reference(
1131        &self,
1132        writer: &mut PdfWriter,
1133        usage: &crate::font_tracker::FontUsage,
1134        tounicode_override: Option<u32>,
1135    ) -> u32 {
1136        // Build ToUnicode CMap — use override if provided, otherwise fall back to naive mapping
1137        let tounicode_ref = tounicode_override.or_else(|| self.build_tounicode_cmap(writer, usage));
1138
1139        let mut entries: Vec<(Vec<u8>, PdfObj)> = vec![
1140            (b"Type".to_vec(), PdfObj::name("Font")),
1141            (b"Subtype".to_vec(), PdfObj::name("Type1")),
1142            (b"BaseFont".to_vec(), PdfObj::Name(usage.font_name.clone())),
1143        ];
1144
1145        if !usage.is_standard_14 {
1146            // For non-standard fonts, add Encoding
1147            entries.push((b"Encoding".to_vec(), PdfObj::name("WinAnsiEncoding")));
1148        }
1149
1150        if let Some(tu_ref) = tounicode_ref {
1151            entries.push((b"ToUnicode".to_vec(), PdfObj::Ref(tu_ref)));
1152        }
1153
1154        writer.add_object(&PdfObj::Dict(entries))
1155    }
1156
1157    /// Build a ToUnicode CMap for a font.
1158    ///
1159    /// Maps character codes to Unicode based on common Adobe glyph naming.
1160    /// For printable ASCII codes, maps code→Unicode directly (works for most
1161    /// Latin text fonts). The full glyph-name-based mapping requires encoding
1162    /// array access (deferred to font embedding phase).
1163    fn build_tounicode_cmap(
1164        &self,
1165        writer: &mut PdfWriter,
1166        usage: &crate::font_tracker::FontUsage,
1167    ) -> Option<u32> {
1168        use std::collections::HashMap;
1169
1170        let mut map: HashMap<u16, u16> = HashMap::new();
1171
1172        for &code in &usage.used_codes {
1173            if code <= 255 {
1174                // For ASCII range, assume code = Unicode (works for standard encodings)
1175                if (0x20..=0x7E).contains(&code) {
1176                    map.insert(code, code);
1177                }
1178            }
1179        }
1180
1181        if map.is_empty() {
1182            return None;
1183        }
1184
1185        let font_name = String::from_utf8_lossy(&usage.font_name);
1186        let cmap_data = generate_tounicode_cmap(&map, &font_name);
1187        Some(writer.add_stream(Vec::new(), &cmap_data, true))
1188    }
1189
1190    /// Build a PDF image XObject from prepared image data. Returns the object number.
1191    fn build_image_xobject(&self, writer: &mut PdfWriter, img: &ImageXObject) -> u32 {
1192        // Build SMask if present
1193        let smask_ref = img.smask_data.as_ref().map(|smask_data| {
1194            writer.add_stream(
1195                vec![
1196                    (b"Type".to_vec(), PdfObj::name("XObject")),
1197                    (b"Subtype".to_vec(), PdfObj::name("Image")),
1198                    (b"Width".to_vec(), PdfObj::Int(img.width as i64)),
1199                    (b"Height".to_vec(), PdfObj::Int(img.height as i64)),
1200                    (b"ColorSpace".to_vec(), PdfObj::name("DeviceGray")),
1201                    (b"BitsPerComponent".to_vec(), PdfObj::Int(8)),
1202                    (b"Interpolate".to_vec(), PdfObj::Bool(false)),
1203                ],
1204                smask_data,
1205                true,
1206            )
1207        });
1208
1209        // Build ICC profile stream if needed
1210        let icc_ref = img.icc_profile.as_ref().map(|icc| {
1211            writer.add_stream(
1212                vec![(b"N".to_vec(), PdfObj::Int(icc.n as i64))],
1213                &icc.data,
1214                true,
1215            )
1216        });
1217
1218        // Build PDF ColorSpace value
1219        let cs_obj = build_pdf_colorspace(&img.pdf_color_space, icc_ref, writer);
1220
1221        // Main image XObject
1222        let mut entries = vec![
1223            (b"Type".to_vec(), PdfObj::name("XObject")),
1224            (b"Subtype".to_vec(), PdfObj::name("Image")),
1225            (b"Width".to_vec(), PdfObj::Int(img.width as i64)),
1226            (b"Height".to_vec(), PdfObj::Int(img.height as i64)),
1227        ];
1228
1229        if img.is_imagemask {
1230            entries.push((b"ImageMask".to_vec(), PdfObj::Bool(true)));
1231            // Imagemasks don't have ColorSpace or BitsPerComponent in the XObject
1232            entries.push((
1233                b"Decode".to_vec(),
1234                PdfObj::Array(vec![PdfObj::Int(1), PdfObj::Int(0)]),
1235            ));
1236        } else {
1237            entries.push((b"ColorSpace".to_vec(), cs_obj));
1238            entries.push((
1239                b"BitsPerComponent".to_vec(),
1240                PdfObj::Int(img.bits_per_component as i64),
1241            ));
1242        }
1243
1244        entries.push((b"Interpolate".to_vec(), PdfObj::Bool(false)));
1245
1246        if let Some(smask) = smask_ref {
1247            entries.push((b"SMask".to_vec(), PdfObj::Ref(smask)));
1248        }
1249
1250        // Color key masking (ImageType 4): /Mask array of 2×n integers
1251        if let Some(ref ckm) = img.color_key_mask {
1252            let ncomp = img.pdf_color_space.num_components();
1253            let mask_ints: Vec<PdfObj> = if ckm.len() == ncomp {
1254                // Exact match: expand each value v to [v, v] range pair
1255                ckm.iter()
1256                    .flat_map(|&v| [PdfObj::Int(v as i64), PdfObj::Int(v as i64)])
1257                    .collect()
1258            } else {
1259                // Range match: already in [min0, max0, min1, max1, ...] form
1260                ckm.iter().map(|&v| PdfObj::Int(v as i64)).collect()
1261            };
1262            entries.push((b"Mask".to_vec(), PdfObj::Array(mask_ints)));
1263        }
1264
1265        writer.add_stream(entries, &img.sample_data, true)
1266    }
1267}
1268
1269/// Build a PDF color space object from our enum.
1270fn build_pdf_colorspace(
1271    cs: &crate::image_ops::PdfColorSpace,
1272    icc_ref: Option<u32>,
1273    writer: &mut PdfWriter,
1274) -> PdfObj {
1275    use crate::image_ops::PdfColorSpace;
1276    match cs {
1277        PdfColorSpace::DeviceGray => PdfObj::name("DeviceGray"),
1278        PdfColorSpace::DeviceRGB => PdfObj::name("DeviceRGB"),
1279        PdfColorSpace::DeviceCMYK => PdfObj::name("DeviceCMYK"),
1280        PdfColorSpace::ICCBased { .. } => {
1281            if let Some(ref_num) = icc_ref {
1282                PdfObj::Array(vec![PdfObj::name("ICCBased"), PdfObj::Ref(ref_num)])
1283            } else {
1284                PdfObj::name("DeviceRGB") // fallback
1285            }
1286        }
1287        PdfColorSpace::Indexed {
1288            base,
1289            hival,
1290            lookup,
1291        } => {
1292            // Pass icc_ref into the base recursion — when the Indexed
1293            // base is ICCBased, the parent's icc_ref points at the
1294            // embedded profile stream and the base needs it to emit
1295            // `[/ICCBased <ref>]`. Dropping it demotes the base to
1296            // DeviceRGB and loses the source profile (GWG130 b/d).
1297            let base_obj = build_pdf_colorspace(base, icc_ref, writer);
1298            // Embed lookup table as a hex string stream
1299            let lookup_ref = writer.add_stream(Vec::new(), lookup, true);
1300            PdfObj::Array(vec![
1301                PdfObj::name("Indexed"),
1302                base_obj,
1303                PdfObj::Int(*hival as i64),
1304                PdfObj::Ref(lookup_ref),
1305            ])
1306        }
1307        PdfColorSpace::Separation {
1308            name,
1309            alt,
1310            tint_table,
1311        } => {
1312            let alt_obj = build_pdf_colorspace(alt, None, writer);
1313            let func_ref = build_tint_function(tint_table, writer);
1314            PdfObj::Array(vec![
1315                PdfObj::name("Separation"),
1316                PdfObj::Name(name.clone()),
1317                alt_obj,
1318                PdfObj::Ref(func_ref),
1319            ])
1320        }
1321        PdfColorSpace::DeviceN {
1322            names,
1323            alt,
1324            tint_table,
1325        } => {
1326            let alt_obj = build_pdf_colorspace(alt, None, writer);
1327            let func_ref = build_tint_function(tint_table, writer);
1328            let names_arr = PdfObj::Array(names.iter().map(|n| PdfObj::Name(n.clone())).collect());
1329            PdfObj::Array(vec![
1330                PdfObj::name("DeviceN"),
1331                names_arr,
1332                alt_obj,
1333                PdfObj::Ref(func_ref),
1334            ])
1335        }
1336    }
1337}
1338
1339/// Emit one PDF `/OutputIntent` dict per record, with the
1340/// `/DestOutputProfile` ICC stream embedded as a separate object.
1341/// Returns the indirect-object numbers for the intent dicts so the
1342/// caller can build the `/Catalog /OutputIntents` array.
1343fn emit_output_intents(
1344    writer: &mut PdfWriter,
1345    intents: &[stet_graphics::document_structure::OutputIntentRecord],
1346) -> Vec<u32> {
1347    let mut refs = Vec::with_capacity(intents.len());
1348    for intent in intents {
1349        let profile_ref = intent.dest_output_profile.as_ref().map(|bytes| {
1350            writer.add_stream(
1351                vec![(b"N".to_vec(), PdfObj::Int(intent.n as i64))],
1352                bytes,
1353                true,
1354            )
1355        });
1356        let mut entries: Vec<(Vec<u8>, PdfObj)> = vec![
1357            (b"Type".to_vec(), PdfObj::name("OutputIntent")),
1358            (b"S".to_vec(), PdfObj::Name(intent.subtype.clone())),
1359        ];
1360        if let Some(s) = &intent.output_condition_identifier {
1361            entries.push((
1362                b"OutputConditionIdentifier".to_vec(),
1363                PdfObj::LitString(s.clone()),
1364            ));
1365        }
1366        if let Some(s) = &intent.output_condition {
1367            entries.push((b"OutputCondition".to_vec(), PdfObj::LitString(s.clone())));
1368        }
1369        if let Some(s) = &intent.registry_name {
1370            entries.push((b"RegistryName".to_vec(), PdfObj::LitString(s.clone())));
1371        }
1372        if let Some(s) = &intent.info {
1373            entries.push((b"Info".to_vec(), PdfObj::LitString(s.clone())));
1374        }
1375        if let Some(p_ref) = profile_ref {
1376            entries.push((b"DestOutputProfile".to_vec(), PdfObj::Ref(p_ref)));
1377        }
1378        let intent_ref = writer.add_object(&PdfObj::Dict(entries));
1379        refs.push(intent_ref);
1380    }
1381    refs
1382}
1383
1384/// Build a PDF Type 0 (sampled) function stream from a TintLookupTable.
1385/// Returns the object number of the function stream.
1386fn build_tint_function(
1387    table: &stet_graphics::device::TintLookupTable,
1388    writer: &mut PdfWriter,
1389) -> u32 {
1390    let ni = table.num_inputs as usize;
1391    let no = table.num_outputs as usize;
1392
1393    // Convert f32 data (0.0–1.0) to u8 samples (0–255).
1394    // Our TintLookupTable stores data in row-major order (last dimension varies fastest),
1395    // but PDF Type 0 functions require the first dimension to vary fastest.
1396    // For 1D, the order is the same. For ND, we must transpose.
1397    let spd = table.samples_per_dim as usize;
1398    let total_entries = spd.pow(ni as u32);
1399    let samples: Vec<u8> = if ni <= 1 {
1400        table
1401            .data
1402            .iter()
1403            .map(|&v| (v.clamp(0.0, 1.0) * 255.0) as u8)
1404            .collect()
1405    } else {
1406        // Reorder: iterate in PDF order (dim0 fastest) and look up in our order (dim0 slowest)
1407        let mut out = Vec::with_capacity(total_entries * no);
1408        for pdf_idx in 0..total_entries {
1409            // Decompose pdf_idx with dim0 varying fastest
1410            let mut coords = vec![0usize; ni];
1411            let mut rem = pdf_idx;
1412            for coord in coords.iter_mut() {
1413                *coord = rem % spd;
1414                rem /= spd;
1415            }
1416            // Convert to our row-major index (dim0 slowest, last dim fastest)
1417            let mut our_idx = 0;
1418            for coord in coords.iter() {
1419                our_idx = our_idx * spd + coord;
1420            }
1421            let base = our_idx * no;
1422            for c in 0..no {
1423                out.push((table.data[base + c].clamp(0.0, 1.0) * 255.0) as u8);
1424            }
1425        }
1426        out
1427    };
1428
1429    // Domain: [0 1] repeated for each input
1430    let mut domain = Vec::with_capacity(ni * 2);
1431    for _ in 0..ni {
1432        domain.push(PdfObj::Int(0));
1433        domain.push(PdfObj::Int(1));
1434    }
1435
1436    // Range: [0 1] repeated for each output
1437    let mut range = Vec::with_capacity(no * 2);
1438    for _ in 0..no {
1439        range.push(PdfObj::Int(0));
1440        range.push(PdfObj::Int(1));
1441    }
1442
1443    // Size: samples_per_dim repeated for each input dimension
1444    let size: Vec<PdfObj> = (0..ni)
1445        .map(|_| PdfObj::Int(table.samples_per_dim as i64))
1446        .collect();
1447
1448    let dict_entries = vec![
1449        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1450        (b"Domain".to_vec(), PdfObj::Array(domain)),
1451        (b"Range".to_vec(), PdfObj::Array(range)),
1452        (b"Size".to_vec(), PdfObj::Array(size)),
1453        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1454    ];
1455
1456    writer.add_stream(dict_entries, &samples, true)
1457}
1458
1459/// Build a PDF Separation or DeviceN color space array from a SpotColorSpace.
1460/// Returns a PdfObj (array) suitable for inclusion in the Resources/ColorSpace dict.
1461fn build_spot_colorspace(
1462    spot_cs: &stet_graphics::device::SpotColorSpace,
1463    writer: &mut PdfWriter,
1464) -> PdfObj {
1465    use stet_graphics::device::{SimpleColorSpace, SpotColorSpace};
1466    match spot_cs {
1467        SpotColorSpace::Separation {
1468            name,
1469            alt,
1470            tint_table,
1471        } => {
1472            let alt_obj = match alt {
1473                SimpleColorSpace::DeviceGray => PdfObj::name("DeviceGray"),
1474                SimpleColorSpace::DeviceRGB => PdfObj::name("DeviceRGB"),
1475                SimpleColorSpace::DeviceCMYK => PdfObj::name("DeviceCMYK"),
1476            };
1477            let func_ref = build_tint_function(tint_table, writer);
1478            PdfObj::Array(vec![
1479                PdfObj::name("Separation"),
1480                PdfObj::Name(name.clone()),
1481                alt_obj,
1482                PdfObj::Ref(func_ref),
1483            ])
1484        }
1485        SpotColorSpace::DeviceN {
1486            names,
1487            alt,
1488            tint_table,
1489        } => {
1490            let alt_obj = match alt {
1491                SimpleColorSpace::DeviceGray => PdfObj::name("DeviceGray"),
1492                SimpleColorSpace::DeviceRGB => PdfObj::name("DeviceRGB"),
1493                SimpleColorSpace::DeviceCMYK => PdfObj::name("DeviceCMYK"),
1494            };
1495            let func_ref = build_tint_function(tint_table, writer);
1496            let names_arr = PdfObj::Array(names.iter().map(|n| PdfObj::Name(n.clone())).collect());
1497            PdfObj::Array(vec![
1498                PdfObj::name("DeviceN"),
1499                names_arr,
1500                alt_obj,
1501                PdfObj::Ref(func_ref),
1502            ])
1503        }
1504        _ => PdfObj::name("DeviceRGB"),
1505    }
1506}
1507
1508/// Generate a ToUnicode CMap stream.
1509fn generate_tounicode_cmap(map: &std::collections::HashMap<u16, u16>, font_name: &str) -> Vec<u8> {
1510    use std::io::Write;
1511    let mut buf = Vec::new();
1512
1513    writeln!(buf, "/CIDInit /ProcSet findresource begin").unwrap();
1514    writeln!(buf, "12 dict begin").unwrap();
1515    writeln!(buf, "begincmap").unwrap();
1516    writeln!(buf, "/CIDSystemInfo <<").unwrap();
1517    writeln!(buf, "  /Registry (Adobe)").unwrap();
1518    writeln!(buf, "  /Ordering (UCS)").unwrap();
1519    writeln!(buf, "  /Supplement 0").unwrap();
1520    writeln!(buf, ">> def").unwrap();
1521    writeln!(buf, "/CMapName /{}-UCS def", font_name).unwrap();
1522    writeln!(buf, "/CMapType 2 def").unwrap();
1523    writeln!(buf, "1 begincodespacerange").unwrap();
1524    writeln!(buf, "<00> <FF>").unwrap();
1525    writeln!(buf, "endcodespacerange").unwrap();
1526
1527    let mut sorted: Vec<_> = map.iter().collect();
1528    sorted.sort_by_key(|&(&code, _)| code);
1529
1530    for chunk in sorted.chunks(100) {
1531        writeln!(buf, "{} beginbfchar", chunk.len()).unwrap();
1532        for &(&code, &unicode) in chunk {
1533            writeln!(buf, "<{:02X}> <{:04X}>", code, unicode).unwrap();
1534        }
1535        writeln!(buf, "endbfchar").unwrap();
1536    }
1537
1538    writeln!(buf, "endcmap").unwrap();
1539    writeln!(buf, "CMapName currentdict /CMap defineresource pop").unwrap();
1540    writeln!(buf, "end").unwrap();
1541    writeln!(buf, "end").unwrap();
1542
1543    buf
1544}
1545
1546impl OutputDevice for PdfDevice {
1547    fn fill_path(&mut self, _path: &PsPath, _params: &FillParams) {}
1548    fn stroke_path(&mut self, _path: &PsPath, _params: &StrokeParams) {}
1549    fn clip_path(&mut self, _path: &PsPath, _params: &ClipParams) {}
1550    fn init_clip(&mut self) {}
1551    fn erase_page(&mut self) {}
1552
1553    fn set_trim_box(&mut self, llx: f64, lly: f64, urx: f64, ury: f64) {
1554        self.pending_trim_box = Some((llx, lly, urx, ury));
1555    }
1556
1557    fn show_page(&mut self, _output_path: &str) -> Result<(), String> {
1558        Ok(())
1559    }
1560
1561    fn draw_image(&mut self, _sample_data: &[u8], _params: &ImageParams) {}
1562
1563    fn page_size(&self) -> (u32, u32) {
1564        (self.page_w, self.page_h)
1565    }
1566
1567    fn replay_and_show(&mut self, list: DisplayList, output_path: &str) -> Result<(), String> {
1568        // Capture output path from first page
1569        if self.output_path.is_none() {
1570            // Strip extension (.png or .pdf)
1571            let base = if let Some(pos) = output_path.rfind('.') {
1572                &output_path[..pos]
1573            } else {
1574                output_path
1575            };
1576            // Remove -NNNN page number suffix (e.g., "arc-0001" → "arc")
1577            let base = if base.len() >= 5 && base.as_bytes()[base.len() - 5] == b'-' {
1578                let suffix = &base[base.len() - 4..];
1579                if suffix.bytes().all(|b| b.is_ascii_digit()) {
1580                    &base[..base.len() - 5]
1581                } else {
1582                    base
1583                }
1584            } else {
1585                base
1586            };
1587            self.output_path = Some(format!("{}.pdf", base));
1588        }
1589
1590        let scale = 72.0 / self.dpi;
1591
1592        self.pages.push(PageData {
1593            display_list: list,
1594            width_pts: self.page_w as f64 * scale,
1595            height_pts: self.page_h as f64 * scale,
1596            page_w: self.page_w,
1597            page_h: self.page_h,
1598            dpi: self.dpi,
1599            trim_box: self.pending_trim_box.take(),
1600        });
1601
1602        Ok(())
1603    }
1604
1605    fn finish(&mut self) -> Result<(), String> {
1606        if self.pages.is_empty() {
1607            return Ok(());
1608        }
1609        self.write_pdf(None)
1610    }
1611
1612    fn finish_with_context(&mut self, ctx: &Context) -> Result<(), String> {
1613        if self.pages.is_empty() {
1614            return Ok(());
1615        }
1616        self.write_pdf(Some(ctx))
1617    }
1618
1619    fn as_any(&self) -> &dyn std::any::Any {
1620        self
1621    }
1622}
1623
1624/// Parse an ICC profile header to extract the number of components and description.
1625///
1626/// Returns (N, description) where N is derived from the color space signature
1627/// at bytes 16–19 and description is extracted from the `desc` or `mluc` tag.
1628///
1629/// Currently unused — kept for forward compatibility with the planned
1630/// PDF/X-4 OutputIntent implementation.
1631#[allow(dead_code)]
1632fn parse_icc_header(data: &[u8]) -> (u32, String) {
1633    let n = if data.len() >= 20 {
1634        match &data[16..20] {
1635            b"CMYK" => 4,
1636            b"RGB " => 3,
1637            b"GRAY" => 1,
1638            b"Lab " => 3,
1639            _ => 4, // assume CMYK for unknown
1640        }
1641    } else {
1642        4
1643    };
1644    let desc = extract_icc_description(data).unwrap_or_else(|| "Custom".to_string());
1645    (n, desc)
1646}
1647
1648/// Extract the profile description from an ICC profile's tag table.
1649///
1650/// Looks for the `desc` tag (v2, type 'desc') or `mluc` tag (v4, type 'mluc').
1651#[allow(dead_code)]
1652fn extract_icc_description(data: &[u8]) -> Option<String> {
1653    if data.len() < 132 {
1654        return None;
1655    }
1656    let tag_count = u32::from_be_bytes(data[128..132].try_into().ok()?) as usize;
1657    let tag_table_start = 132;
1658
1659    for i in 0..tag_count {
1660        let offset = tag_table_start + i * 12;
1661        if offset + 12 > data.len() {
1662            break;
1663        }
1664        let tag_sig = &data[offset..offset + 4];
1665        let tag_offset = u32::from_be_bytes(data[offset + 4..offset + 8].try_into().ok()?) as usize;
1666        let tag_size = u32::from_be_bytes(data[offset + 8..offset + 12].try_into().ok()?) as usize;
1667
1668        if tag_sig != b"desc" {
1669            continue;
1670        }
1671        if tag_offset + tag_size > data.len() || tag_size < 12 {
1672            return None;
1673        }
1674
1675        let type_sig = &data[tag_offset..tag_offset + 4];
1676        if type_sig == b"desc" {
1677            // ICC v2 'desc' type: u32 count at offset+8, ASCII string at offset+12
1678            let count =
1679                u32::from_be_bytes(data[tag_offset + 8..tag_offset + 12].try_into().ok()?) as usize;
1680            if count == 0 {
1681                return None;
1682            }
1683            let str_end = (tag_offset + 12 + count).min(tag_offset + tag_size);
1684            let s = &data[tag_offset + 12..str_end];
1685            // Trim trailing null bytes
1686            let s = s.split(|&b| b == 0).next().unwrap_or(s);
1687            return Some(String::from_utf8_lossy(s).to_string());
1688        } else if type_sig == b"mluc" {
1689            // ICC v4 'mluc' type: multi-localized Unicode
1690            if tag_size < 20 {
1691                return None;
1692            }
1693            let record_count =
1694                u32::from_be_bytes(data[tag_offset + 8..tag_offset + 12].try_into().ok()?) as usize;
1695            if record_count == 0 {
1696                return None;
1697            }
1698            // First record: language(2) + country(2) + length(4) + offset(4)
1699            let rec_base = tag_offset + 16;
1700            if rec_base + 12 > data.len() {
1701                return None;
1702            }
1703            let str_len =
1704                u32::from_be_bytes(data[rec_base + 4..rec_base + 8].try_into().ok()?) as usize;
1705            let str_off =
1706                u32::from_be_bytes(data[rec_base + 8..rec_base + 12].try_into().ok()?) as usize;
1707            let abs_off = tag_offset + str_off;
1708            if abs_off + str_len > data.len() || str_len < 2 {
1709                return None;
1710            }
1711            // UTF-16BE → String
1712            let utf16: Vec<u16> = data[abs_off..abs_off + str_len]
1713                .chunks_exact(2)
1714                .map(|c| u16::from_be_bytes([c[0], c[1]]))
1715                .collect();
1716            return Some(
1717                String::from_utf16_lossy(&utf16)
1718                    .trim_end_matches('\0')
1719                    .to_string(),
1720            );
1721        }
1722
1723        break;
1724    }
1725    None
1726}
1727
1728/// Build a PDF Type 4 (PostScript calculator) function that inverts its
1729/// input: `{ 1 exch sub }`. Used as the `/TR` entry on a SoftMask /SMask
1730/// dict when the source transfer was `{ 1 exch sub }` on the PS side.
1731/// Returns the indirect object number.
1732fn build_invert_transfer(writer: &mut PdfWriter) -> u32 {
1733    let dict_entries = vec![
1734        (b"FunctionType".to_vec(), PdfObj::Int(4)),
1735        (
1736            b"Domain".to_vec(),
1737            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1738        ),
1739        (
1740            b"Range".to_vec(),
1741            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1742        ),
1743    ];
1744    writer.add_stream(dict_entries, b"{ 1 exch sub }", false)
1745}
1746
1747/// Build a PDF Type 0 (sampled) function stream from a 256-entry transfer table.
1748/// Returns the object number of the function stream.
1749fn build_type0_function(writer: &mut PdfWriter, table: &[f64]) -> u32 {
1750    let dict_entries = vec![
1751        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1752        (
1753            b"Domain".to_vec(),
1754            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1755        ),
1756        (
1757            b"Range".to_vec(),
1758            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1759        ),
1760        (
1761            b"Size".to_vec(),
1762            PdfObj::Array(vec![PdfObj::Int(table.len() as i64)]),
1763        ),
1764        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1765    ];
1766    let data: Vec<u8> = table
1767        .iter()
1768        .map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
1769        .collect();
1770    writer.add_stream(dict_entries, &data, false)
1771}
1772
1773/// Build the /TR2 value for an ExtGState dict from transfer function tables.
1774/// Returns a PdfObj (Ref for single function, Array for 4-component, or Name for identity).
1775fn build_transfer_tr2(
1776    writer: &mut PdfWriter,
1777    tables: &[Option<std::sync::Arc<Vec<f64>>>],
1778    is_color: bool,
1779) -> PdfObj {
1780    if is_color && tables.len() == 4 {
1781        // 4-component: [R, G, B, Gray], use /Identity for None entries
1782        let refs: Vec<PdfObj> = tables
1783            .iter()
1784            .map(|t| {
1785                if let Some(table) = t {
1786                    let func_ref = build_type0_function(writer, table);
1787                    PdfObj::Ref(func_ref)
1788                } else {
1789                    PdfObj::name("Identity")
1790                }
1791            })
1792            .collect();
1793        PdfObj::Array(refs)
1794    } else if !is_color && tables.len() == 1 {
1795        if let Some(ref table) = tables[0] {
1796            let func_ref = build_type0_function(writer, table);
1797            PdfObj::Ref(func_ref)
1798        } else {
1799            PdfObj::name("Identity")
1800        }
1801    } else {
1802        PdfObj::name("Identity")
1803    }
1804}
1805
1806/// Build a PDF Type 0 (sampled) function stream from a 256-entry table with signed range [-1,1].
1807/// Used for undercolor removal (UCR) functions.
1808fn build_type0_function_signed(writer: &mut PdfWriter, table: &[f64]) -> u32 {
1809    let dict_entries = vec![
1810        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1811        (
1812            b"Domain".to_vec(),
1813            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1814        ),
1815        (
1816            b"Range".to_vec(),
1817            PdfObj::Array(vec![PdfObj::Int(-1), PdfObj::Int(1)]),
1818        ),
1819        (
1820            b"Size".to_vec(),
1821            PdfObj::Array(vec![PdfObj::Int(table.len() as i64)]),
1822        ),
1823        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1824    ];
1825    // Encode [-1,1] → [0,255]: byte = (v + 1) / 2 * 255
1826    let data: Vec<u8> = table
1827        .iter()
1828        .map(|&v| ((v.clamp(-1.0, 1.0) + 1.0) / 2.0 * 255.0).round() as u8)
1829        .collect();
1830    writer.add_stream(dict_entries, &data, false)
1831}
1832
1833/// Build a PDF Type 4 (PostScript calculator) function from token bytes.
1834/// Domain is 2D [-1,1]×[-1,1], Range [0,1].
1835fn build_type4_function(writer: &mut PdfWriter, tokens: &[u8]) -> u32 {
1836    let dict_entries = vec![
1837        (b"FunctionType".to_vec(), PdfObj::Int(4)),
1838        (
1839            b"Domain".to_vec(),
1840            PdfObj::Array(vec![
1841                PdfObj::Int(-1),
1842                PdfObj::Int(1),
1843                PdfObj::Int(-1),
1844                PdfObj::Int(1),
1845            ]),
1846        ),
1847        (
1848            b"Range".to_vec(),
1849            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1850        ),
1851    ];
1852    writer.add_stream(dict_entries, tokens, false)
1853}
1854
1855/// Build a PDF Type 0 (sampled) 2D function from a 64×64 sample table.
1856/// Domain is [-1,1]×[-1,1], Range [0,1].
1857fn build_type0_function_2d(writer: &mut PdfWriter, table: &[f64]) -> u32 {
1858    let dict_entries = vec![
1859        (b"FunctionType".to_vec(), PdfObj::Int(0)),
1860        (
1861            b"Domain".to_vec(),
1862            PdfObj::Array(vec![
1863                PdfObj::Int(-1),
1864                PdfObj::Int(1),
1865                PdfObj::Int(-1),
1866                PdfObj::Int(1),
1867            ]),
1868        ),
1869        (
1870            b"Range".to_vec(),
1871            PdfObj::Array(vec![PdfObj::Int(0), PdfObj::Int(1)]),
1872        ),
1873        (
1874            b"Size".to_vec(),
1875            PdfObj::Array(vec![PdfObj::Int(64), PdfObj::Int(64)]),
1876        ),
1877        (b"BitsPerSample".to_vec(), PdfObj::Int(8)),
1878    ];
1879    let data: Vec<u8> = table
1880        .iter()
1881        .map(|&v| (v.clamp(0.0, 1.0) * 255.0).round() as u8)
1882        .collect();
1883    writer.add_stream(dict_entries, &data, false)
1884}
1885
1886/// Build a PDF halftone screen object (Type 1 halftone dict) from a HalftoneScreen.
1887/// Returns a PdfObj (either inline Dict or Ref to indirect object).
1888fn build_halftone_screen(
1889    writer: &mut PdfWriter,
1890    screen: &stet_graphics::device::HalftoneScreen,
1891) -> PdfObj {
1892    let spot_func = if let Some(ref tokens) = screen.type4_tokens {
1893        let func_ref = build_type4_function(writer, tokens);
1894        PdfObj::Ref(func_ref)
1895    } else if let Some(ref table) = screen.sampled_2d {
1896        let func_ref = build_type0_function_2d(writer, table);
1897        PdfObj::Ref(func_ref)
1898    } else {
1899        PdfObj::name("Default")
1900    };
1901
1902    let entries = vec![
1903        (b"Type".to_vec(), PdfObj::name("Halftone")),
1904        (b"HalftoneType".to_vec(), PdfObj::Int(1)),
1905        (b"Frequency".to_vec(), PdfObj::Real(screen.frequency)),
1906        (b"Angle".to_vec(), PdfObj::Real(screen.angle)),
1907        (b"SpotFunction".to_vec(), spot_func),
1908    ];
1909    let obj_ref = writer.add_object(&PdfObj::Dict(entries));
1910    PdfObj::Ref(obj_ref)
1911}
1912
1913/// Build the /HT value for an ExtGState dict from a HalftoneState.
1914fn build_halftone_ht(
1915    writer: &mut PdfWriter,
1916    state: &stet_graphics::device::HalftoneState,
1917) -> PdfObj {
1918    if let Some(ref color) = state.color {
1919        // Type 5 composite halftone
1920        let mut entries = vec![
1921            (b"Type".to_vec(), PdfObj::name("Halftone")),
1922            (b"HalftoneType".to_vec(), PdfObj::Int(5)),
1923        ];
1924        let component_names: [&[u8]; 4] = [b"Red", b"Green", b"Blue", b"Default"];
1925        for (i, screen_opt) in color.iter().enumerate() {
1926            if let Some(screen) = screen_opt {
1927                let ht_obj = build_halftone_screen(writer, screen);
1928                entries.push((component_names[i].to_vec(), ht_obj));
1929            }
1930        }
1931        let obj_ref = writer.add_object(&PdfObj::Dict(entries));
1932        PdfObj::Ref(obj_ref)
1933    } else if let Some(ref gray) = state.gray {
1934        build_halftone_screen(writer, gray)
1935    } else {
1936        PdfObj::name("Default")
1937    }
1938}
1939
1940/// Effective per-page override after layering /PAGES under /PAGE.
1941#[derive(Default, Clone)]
1942struct EffectivePageOverride {
1943    boxes: stet_graphics::document_structure::PageBoxes,
1944    rotate: Option<i32>,
1945    additional_actions: Option<stet_graphics::document_structure::PageAdditionalActions>,
1946}
1947
1948/// Walk the pdfmark buffer and compute one [`EffectivePageOverride`]
1949/// per page in `0..page_count`. Order of precedence per key:
1950/// 1. Last `/PAGE` for that specific page (later record wins).
1951/// 2. Last `/PAGES` (later document-wide record wins).
1952fn compute_page_overrides(ctx: Option<&Context>, page_count: usize) -> Vec<EffectivePageOverride> {
1953    use stet_graphics::document_structure::{PageOverrideScope, StructuralRecord};
1954    let mut out = vec![EffectivePageOverride::default(); page_count];
1955    let Some(c) = ctx else {
1956        return out;
1957    };
1958    let mut all_boxes = stet_graphics::document_structure::PageBoxes::default();
1959    let mut all_rotate: Option<i32> = None;
1960    let mut all_aa: Option<stet_graphics::document_structure::PageAdditionalActions> = None;
1961    let mut per_page_boxes: Vec<stet_graphics::document_structure::PageBoxes> =
1962        vec![stet_graphics::document_structure::PageBoxes::default(); page_count];
1963    let mut per_page_rotate: Vec<Option<i32>> = vec![None; page_count];
1964    let mut per_page_aa: Vec<Option<stet_graphics::document_structure::PageAdditionalActions>> =
1965        vec![None; page_count];
1966
1967    for record in c.doc_structure.records() {
1968        let StructuralRecord::PageOverride(rec) = record else {
1969            continue;
1970        };
1971        match rec.scope {
1972            PageOverrideScope::All => {
1973                all_boxes = rec.boxes.merge_over(&all_boxes);
1974                if rec.rotate.is_some() {
1975                    all_rotate = rec.rotate;
1976                }
1977                if let Some(new_aa) = &rec.additional_actions {
1978                    all_aa = Some(match all_aa {
1979                        Some(prev) => new_aa.merge_over(&prev),
1980                        None => new_aa.clone(),
1981                    });
1982                }
1983            }
1984            PageOverrideScope::Single(page) => {
1985                let idx = page as usize;
1986                if idx == 0 || idx > page_count {
1987                    continue;
1988                }
1989                let i = idx - 1;
1990                per_page_boxes[i] = rec.boxes.merge_over(&per_page_boxes[i]);
1991                if rec.rotate.is_some() {
1992                    per_page_rotate[i] = rec.rotate;
1993                }
1994                if let Some(new_aa) = &rec.additional_actions {
1995                    per_page_aa[i] = Some(match per_page_aa[i].clone() {
1996                        Some(prev) => new_aa.merge_over(&prev),
1997                        None => new_aa.clone(),
1998                    });
1999                }
2000            }
2001            _ => continue,
2002        }
2003    }
2004
2005    for i in 0..page_count {
2006        out[i].boxes = per_page_boxes[i].merge_over(&all_boxes);
2007        out[i].rotate = per_page_rotate[i].or(all_rotate);
2008        out[i].additional_actions = match (&per_page_aa[i], &all_aa) {
2009            (Some(p), Some(a)) => Some(p.merge_over(a)),
2010            (Some(p), None) => Some(p.clone()),
2011            (None, Some(a)) => Some(a.clone()),
2012            (None, None) => None,
2013        };
2014    }
2015    out
2016}
2017
2018/// Convert days since 1970-01-01 to (year, month, day).
2019fn days_to_ymd(days: u64) -> (u64, u64, u64) {
2020    // Civil calendar algorithm from Howard Hinnant
2021    let z = days + 719468;
2022    let era = z / 146097;
2023    let doe = z - era * 146097;
2024    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
2025    let y = yoe + era * 400;
2026    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
2027    let mp = (5 * doy + 2) / 153;
2028    let d = doy - (153 * mp + 2) / 5 + 1;
2029    let m = if mp < 10 { mp + 3 } else { mp - 9 };
2030    let y = if m <= 2 { y + 1 } else { y };
2031    (y, m, d)
2032}
2033
2034/// Format the current wall-clock time as a PDF date string in UTC.
2035fn default_now_pdf_date() -> String {
2036    use std::time::SystemTime;
2037    let now = SystemTime::now()
2038        .duration_since(SystemTime::UNIX_EPOCH)
2039        .unwrap_or_default()
2040        .as_secs();
2041    let secs_per_day = 86400u64;
2042    let days = now / secs_per_day;
2043    let time_of_day = now % secs_per_day;
2044    let hours = time_of_day / 3600;
2045    let minutes = (time_of_day % 3600) / 60;
2046    let seconds = time_of_day % 60;
2047    let (year, month, day) = days_to_ymd(days);
2048    format!(
2049        "D:{:04}{:02}{:02}{:02}{:02}{:02}Z",
2050        year, month, day, hours, minutes, seconds
2051    )
2052}
2053
2054/// Merge every `/DOCINFO` pdfmark record on the buffer into a single
2055/// effective record. Later records override earlier ones key-by-key,
2056/// matching GhostScript pdfwrite's behaviour where multiple
2057/// `[ /DOCINFO pdfmark` blocks accumulate.
2058/// Merge every `/VIEWERPREFERENCES pdfmark` record into one effective
2059/// record. Later records override earlier ones key-by-key, matching
2060/// the same "later wins" rule we apply to `/DOCINFO`.
2061fn collect_viewer_prefs(ctx: &Context) -> stet_graphics::document_structure::ViewerPrefsRecord {
2062    use stet_graphics::document_structure::{StructuralRecord, ViewerPrefsRecord};
2063    let mut acc = ViewerPrefsRecord::default();
2064    for record in ctx.doc_structure.records() {
2065        if let StructuralRecord::ViewerPrefs(rec) = record {
2066            acc = rec.merge_over(&acc);
2067        }
2068    }
2069    acc
2070}
2071
2072fn collect_docinfo(ctx: &Context) -> stet_graphics::document_structure::DocInfoRecord {
2073    let mut acc = stet_graphics::document_structure::DocInfoRecord::default();
2074    for record in ctx.doc_structure.records() {
2075        let stet_graphics::document_structure::StructuralRecord::DocInfo(rec) = record else {
2076            continue;
2077        };
2078        if let Some(v) = &rec.title {
2079            acc.title = Some(v.clone());
2080        }
2081        if let Some(v) = &rec.author {
2082            acc.author = Some(v.clone());
2083        }
2084        if let Some(v) = &rec.subject {
2085            acc.subject = Some(v.clone());
2086        }
2087        if let Some(v) = &rec.keywords {
2088            acc.keywords = Some(v.clone());
2089        }
2090        if let Some(v) = &rec.creator {
2091            acc.creator = Some(v.clone());
2092        }
2093        if let Some(v) = &rec.producer {
2094            acc.producer = Some(v.clone());
2095        }
2096        if let Some(v) = &rec.creation_date {
2097            acc.creation_date = Some(v.clone());
2098        }
2099        if let Some(v) = &rec.mod_date {
2100            acc.mod_date = Some(v.clone());
2101        }
2102        if let Some(v) = rec.trapped {
2103            acc.trapped = Some(v);
2104        }
2105    }
2106    acc
2107}
2108
2109/// Build a PDF Form XObject indirect from a captured Form content stream.
2110/// The Form has no /Resources entry — it inherits the enclosing page's
2111/// resources (PDF 1.7 § 7.8.3), which is how stet keeps the writer's
2112/// per-page resource lists shared across the page and its nested groups.
2113fn build_form_xobject(writer: &mut PdfWriter, form: &crate::content_stream::FormXObject) -> u32 {
2114    let mut entries: Vec<(Vec<u8>, PdfObj)> = vec![
2115        (b"Type".to_vec(), PdfObj::name("XObject")),
2116        (b"Subtype".to_vec(), PdfObj::name("Form")),
2117        (b"FormType".to_vec(), PdfObj::Int(1)),
2118        (
2119            b"BBox".to_vec(),
2120            PdfObj::Array(vec![
2121                PdfObj::Real(form.bbox[0]),
2122                PdfObj::Real(form.bbox[1]),
2123                PdfObj::Real(form.bbox[2]),
2124                PdfObj::Real(form.bbox[3]),
2125            ]),
2126        ),
2127    ];
2128    if let Some(group_entries) = &form.group_dict_entries {
2129        let entries_clone: Vec<(Vec<u8>, PdfObj)> = group_entries
2130            .iter()
2131            .map(|(k, v)| (k.clone(), clone_pdfobj_shallow(v)))
2132            .collect();
2133        entries.push((b"Group".to_vec(), PdfObj::Dict(entries_clone)));
2134    }
2135    writer.add_stream(entries, &form.content, true)
2136}
2137
2138/// Shallow clone of a PdfObj used for the static keys we emit in /Group
2139/// dicts (Bool / Name / Real). Panics on shapes we don't expect to
2140/// appear there, so a future change that adds an indirect ref or array
2141/// into a group dict gets caught loudly instead of silently dropping
2142/// data.
2143fn clone_pdfobj_shallow(v: &PdfObj) -> PdfObj {
2144    match v {
2145        PdfObj::Bool(b) => PdfObj::Bool(*b),
2146        PdfObj::Int(n) => PdfObj::Int(*n),
2147        PdfObj::Real(r) => PdfObj::Real(*r),
2148        PdfObj::Name(n) => PdfObj::Name(n.clone()),
2149        PdfObj::Ref(r) => PdfObj::Ref(*r),
2150        PdfObj::Null => PdfObj::Null,
2151        _ => panic!("clone_pdfobj_shallow: unsupported PdfObj variant in /Group dict"),
2152    }
2153}
2154
2155/// Walk an `OcgVisibility` predicate and call `visit(ocg_id,
2156/// default_visible)` for each OCG it references. The `default_visible`
2157/// flag is the one attached to the variant; it controls whether the
2158/// document-default config lists this OCG under `/OFF`.
2159fn collect_ocg_ids<F>(visibility: &stet_graphics::display_list::OcgVisibility, mut visit: F)
2160where
2161    F: FnMut(u32, bool),
2162{
2163    use stet_graphics::display_list::OcgVisibility;
2164    fn walk_expr(
2165        e: &stet_graphics::display_list::VisibilityExpr,
2166        v: &mut impl FnMut(u32, bool),
2167        default_visible: bool,
2168    ) {
2169        use stet_graphics::display_list::VisibilityExpr;
2170        match e {
2171            VisibilityExpr::And(xs) | VisibilityExpr::Or(xs) => {
2172                for x in xs {
2173                    walk_expr(x, v, default_visible);
2174                }
2175            }
2176            VisibilityExpr::Not(x) => walk_expr(x, v, default_visible),
2177            VisibilityExpr::Layer(id) => v(*id, default_visible),
2178        }
2179    }
2180    match visibility {
2181        OcgVisibility::Single {
2182            ocg_id,
2183            default_visible,
2184        } => visit(*ocg_id, *default_visible),
2185        OcgVisibility::Membership {
2186            ocg_ids,
2187            default_visible,
2188            ..
2189        } => {
2190            for &id in ocg_ids {
2191                visit(id, *default_visible);
2192            }
2193        }
2194        OcgVisibility::Expression {
2195            expr,
2196            default_visible,
2197        } => walk_expr(expr, &mut visit, *default_visible),
2198    }
2199}
2200
2201/// Resolve a single `OcgVisibility` predicate into the indirect ref
2202/// that goes into a page's `/Properties` entry. The `Single` case
2203/// reuses the existing `/OCG` indirect object. `Membership` and
2204/// `Expression` allocate fresh `/OCMD` indirect objects (one per
2205/// content-stream marker), referencing the underlying `/OCG`s through
2206/// `ocg_id_to_ref`.
2207fn build_ocg_property_ref(
2208    writer: &mut PdfWriter,
2209    visibility: &stet_graphics::display_list::OcgVisibility,
2210    ocg_id_to_ref: &HashMap<u32, u32>,
2211) -> u32 {
2212    use stet_graphics::display_list::OcgVisibility;
2213    match visibility {
2214        OcgVisibility::Single { ocg_id, .. } => *ocg_id_to_ref.get(ocg_id).unwrap_or(&0),
2215        OcgVisibility::Membership {
2216            ocg_ids, policy, ..
2217        } => {
2218            use stet_graphics::display_list::MembershipPolicy;
2219            let policy_name: &[u8] = match policy {
2220                MembershipPolicy::AllOn => b"AllOn",
2221                MembershipPolicy::AnyOn => b"AnyOn",
2222                MembershipPolicy::AllOff => b"AllOff",
2223                MembershipPolicy::AnyOff => b"AnyOff",
2224            };
2225            let ocgs: Vec<PdfObj> = ocg_ids
2226                .iter()
2227                .filter_map(|id| ocg_id_to_ref.get(id).map(|&r| PdfObj::Ref(r)))
2228                .collect();
2229            writer.add_object(&PdfObj::Dict(vec![
2230                (b"Type".to_vec(), PdfObj::name("OCMD")),
2231                (b"OCGs".to_vec(), PdfObj::Array(ocgs)),
2232                (b"P".to_vec(), PdfObj::Name(policy_name.to_vec())),
2233            ]))
2234        }
2235        OcgVisibility::Expression { expr, .. } => {
2236            let ve = build_ve_array(writer, expr, ocg_id_to_ref);
2237            writer.add_object(&PdfObj::Dict(vec![
2238                (b"Type".to_vec(), PdfObj::name("OCMD")),
2239                (b"VE".to_vec(), ve),
2240            ]))
2241        }
2242    }
2243}
2244
2245/// Recursively convert a `VisibilityExpr` into the PDF `/VE` array
2246/// shape: `[/And expr1 expr2 …]`, `[/Or expr1 expr2 …]`, `[/Not expr]`,
2247/// or a bare indirect ref for a leaf `Layer`.
2248fn build_ve_array(
2249    writer: &mut PdfWriter,
2250    expr: &stet_graphics::display_list::VisibilityExpr,
2251    ocg_id_to_ref: &HashMap<u32, u32>,
2252) -> PdfObj {
2253    use stet_graphics::display_list::VisibilityExpr;
2254    match expr {
2255        VisibilityExpr::And(xs) => {
2256            let mut arr = vec![PdfObj::name("And")];
2257            for x in xs {
2258                arr.push(build_ve_array(writer, x, ocg_id_to_ref));
2259            }
2260            PdfObj::Array(arr)
2261        }
2262        VisibilityExpr::Or(xs) => {
2263            let mut arr = vec![PdfObj::name("Or")];
2264            for x in xs {
2265                arr.push(build_ve_array(writer, x, ocg_id_to_ref));
2266            }
2267            PdfObj::Array(arr)
2268        }
2269        VisibilityExpr::Not(x) => PdfObj::Array(vec![
2270            PdfObj::name("Not"),
2271            build_ve_array(writer, x, ocg_id_to_ref),
2272        ]),
2273        VisibilityExpr::Layer(id) => match ocg_id_to_ref.get(id) {
2274            Some(&r) => PdfObj::Ref(r),
2275            None => PdfObj::Null,
2276        },
2277    }
2278}