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