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