Skip to main content

forme/pdf/
mod.rs

1//! # PDF Serializer
2//!
3//! Takes the laid-out pages from the layout engine and writes a valid PDF file.
4//!
5//! This is a from-scratch PDF 1.7 writer. We write the raw bytes ourselves
6//! because it gives us full control over the output and makes the engine
7//! self-contained. The PDF spec is verbose but the subset we need for
8//! document rendering is manageable.
9//!
10//! ## PDF Structure (simplified)
11//!
12//! ```text
13//! %PDF-1.7            <- header
14//! 1 0 obj ... endobj  <- objects (fonts, pages, content streams, etc.)
15//! 2 0 obj ... endobj
16//! ...
17//! xref                <- cross-reference table (byte offsets of each object)
18//! trailer             <- points to the root object
19//! %%EOF
20//! ```
21//!
22//! ## Font Embedding
23//!
24//! Standard PDF fonts (Helvetica, Times, Courier) use simple Type1 references.
25//! Custom TrueType fonts are embedded as CIDFontType2 with Identity-H encoding,
26//! producing 5 PDF objects per font: FontFile2, FontDescriptor, CIDFont,
27//! ToUnicode CMap, and the root Type0 dictionary.
28
29pub mod certify;
30pub mod merge;
31pub mod redaction;
32pub(crate) mod tagged;
33pub(crate) mod xmp;
34
35use std::collections::{HashMap, HashSet};
36use std::fmt::Write as FmtWrite; // for write! on String
37use std::io::Write as IoWrite; // for write! on Vec<u8>
38
39use crate::error::FormeError;
40use crate::font::subset::subset_ttf;
41use crate::font::{FontContext, FontData, FontKey};
42use crate::layout::*;
43use crate::model::*;
44use crate::style::{Color, FontStyle, Overflow, TextDecoration, TransformOp};
45use crate::svg::SvgCommand;
46use miniz_oxide::deflate::compress_to_vec_zlib;
47
48/// Default `/Params /ModDate` for attachments. A fixed constant, never
49/// wall-clock: byte-determinism is a hard guarantee (native/WASM parity is
50/// gated on it in CI). Callers wanting a real date pass `modDate`.
51const DEFAULT_ATTACHMENT_MOD_DATE: &str = "D:20000101000000Z";
52
53/// A link annotation to be added to a page.
54struct LinkAnnotation {
55    x: f64,
56    y: f64,
57    width: f64,
58    height: f64,
59    href: String,
60}
61
62/// A bookmark entry for the PDF outline tree.
63struct PdfBookmark {
64    title: String,
65    page_obj_id: usize,
66    y_pdf: f64,
67}
68
69/// A form field annotation collected during layout traversal.
70struct FormFieldData {
71    field_type: FormFieldType,
72    name: String,
73    x: f64,
74    y: f64,
75    width: f64,
76    height: f64,
77    page_idx: usize,
78}
79
80pub struct PdfWriter;
81
82/// Embedding data for a custom TrueType font.
83#[allow(dead_code)]
84struct CustomFontEmbedData {
85    ttf_data: Vec<u8>,
86    /// Maps original glyph IDs (from shaping) to remapped GIDs in the subset font.
87    gid_remap: HashMap<u16, u16>,
88    /// Maps original glyph IDs to their Unicode character(s) for ToUnicode CMap.
89    glyph_to_char: HashMap<u16, char>,
90    /// Legacy fallback: maps chars to subset GIDs (for page number placeholders).
91    char_to_gid: HashMap<char, u16>,
92    units_per_em: u16,
93    ascender: i16,
94    descender: i16,
95}
96
97/// Font usage data collected from layout elements.
98struct FontUsage {
99    /// Characters used per font (for standard font subsetting fallback).
100    chars: HashSet<char>,
101    /// Glyph IDs used per font (from shaped PositionedGlyphs).
102    glyph_ids: HashSet<u16>,
103    /// Maps glyph ID → first char it represents (for ToUnicode CMap).
104    glyph_to_char: HashMap<u16, char>,
105}
106
107/// Tracks allocated PDF objects during writing.
108struct PdfBuilder {
109    objects: Vec<PdfObject>,
110    /// Maps (family, weight, italic) -> (object_id, index)
111    font_objects: Vec<(FontKey, usize)>,
112    /// Embedding data for custom fonts, keyed by FontKey.
113    custom_font_data: HashMap<FontKey, CustomFontEmbedData>,
114    /// Base-14 fonts that were embedded via the pdfUa metric-compatible
115    /// substitution (Liberation). They aren't in `custom_font_data` — the
116    /// caller registered no custom bytes for them — but they ARE embedded, so
117    /// the PDF/A "all fonts embedded" check must treat them as satisfied.
118    embedded_standard_fonts: std::collections::HashSet<FontKey>,
119    /// XObject obj IDs for images, indexed as /Im0, /Im1, ...
120    /// Each entry is (main_xobject_id, optional_smask_xobject_id).
121    image_objects: Vec<usize>,
122    /// Maps (page_index, element_position_in_page) to image index in image_objects.
123    /// Used during content stream writing to find the right /ImN reference.
124    image_index_map: HashMap<(usize, usize), usize>,
125    /// Maps page_index to (image_index, intrinsic_width_px, intrinsic_height_px)
126    /// for the page's optional `background_image`. Identical URLs across
127    /// pages share a single XObject; the dims are needed for
128    /// `cover` / `contain` sizing math at content-stream time.
129    page_background_image_map: HashMap<usize, (usize, u32, u32)>,
130    /// Caches `backgroundImage URL → (image index, w_px, h_px)` so
131    /// identical background images across different pages collapse to a
132    /// single XObject.
133    page_background_url_cache: HashMap<String, (usize, u32, u32)>,
134    /// ExtGState objects for opacity. Maps opacity value (as ordered bits) to
135    /// (object_id, gs_name) e.g. (42, "GS0").
136    ext_gstate_map: HashMap<u64, (usize, String)>,
137    /// Shading dictionaries for gradients. One entry per (page, element)
138    /// gradient instance. Resolves to (object_id, sh_name e.g. "Sh0").
139    /// Maps `(page_idx, elem_idx) -> (obj_id, name)`.
140    shading_map: HashMap<(usize, usize), (usize, String)>,
141    /// Non-fatal notices collected during the write (e.g. pdfUa without an
142    /// embeddable font). Returned to the caller so every render surface can
143    /// show them, never silently dropped.
144    warnings: Vec<String>,
145    /// Characters replaced by "?" because no available font covers them —
146    /// not WinAnsi, not the bundled Noto, not a registered font. RefCell
147    /// because the substitution sites run under `&self` (write_element is
148    /// recursive; chart labels render through a free fn holding `&PdfBuilder`).
149    /// Drained into one warning per distinct character at the end of the
150    /// write: a silently wrong glyph is a render defect (decision 2026-09-08 —
151    /// keep the bundled font, make the register-a-font path discoverable).
152    missing_glyphs: std::cell::RefCell<std::collections::BTreeSet<char>>,
153    /// Output version — read by serialize() for the header; every other
154    /// 2.0 behavior is decided in write() before objects are built.
155    pdf_version: crate::model::PdfVersion,
156}
157
158pub(crate) struct PdfObject {
159    #[allow(dead_code)]
160    pub(crate) id: usize,
161    pub(crate) data: Vec<u8>,
162}
163
164impl Default for PdfWriter {
165    fn default() -> Self {
166        Self::new()
167    }
168}
169
170impl PdfWriter {
171    pub fn new() -> Self {
172        Self
173    }
174
175    /// Write laid-out pages to a PDF byte vector.
176    ///
177    /// MEMORY NOTE (streaming-serialize investigation, 2026-09 — set aside): the
178    /// large-document peak (~1GB for a 500-page doc) is NOT here. It is the
179    /// `Vec<LayoutPage>` the caller retains (~2MB/page) while this fn borrows it
180    /// as a slice. This writer is already ~90% streaming-ready: Pass 1 (below)
181    /// consumes and zlib-compresses everything heavy per page; Pass 2 touches
182    /// only scalars (width/height) and the lightweight collected lists
183    /// (annotations, bookmarks). So making `write` take pages by value and drop
184    /// each page's `elements` after Pass 1 saves nothing on its own — `layout()`
185    /// has already materialized the whole tree before `write` is called. A real
186    /// peak reduction needs a restartable STREAMING LAYOUT producer (yield page
187    /// N, serialize, drop), which collides with the sentinel count pass (total
188    /// page count is needed before page 1 can emit) and touches pagination.
189    /// Crucially, PDF/A + PDF/UA are NOT a blocker: the structure tree
190    /// (`tagged::TagBuilder`), `link_slots`, and disjoint page/annotation
191    /// StructParent numbering are a few MB of lightweight metadata that stay
192    /// whole-document and assemble unchanged at finalize — so streaming frees
193    /// layout memory earlier without moving a single output byte, and veraPDF
194    /// stays 9/9 by construction. See `scripts/parity/benchmarks.mjs`
195    /// `trackedFixes` for the full write-up.
196    #[allow(clippy::too_many_arguments)]
197    pub fn write(
198        &self,
199        pages: &[LayoutPage],
200        metadata: &Metadata,
201        font_context: &FontContext,
202        tagged: bool,
203        pdfa: Option<&PdfAConformance>,
204        pdf_ua: bool,
205        embedded_data: Option<&str>,
206        attachments: &[Attachment],
207        zugferd: Option<&ZugferdMeta>,
208        flatten_forms: bool,
209        pdf_version: crate::model::PdfVersion,
210        pdf_ua2: bool,
211    ) -> Result<(Vec<u8>, Vec<String>), FormeError> {
212        // ── Attachment / e-invoice validation (before any emission) ──
213        //
214        // PDF/A-1/-2 allow only PDF/A files as attachments (veraPDF rule
215        // 6.8-5) — which the engine cannot verify, so a 2x level with any
216        // attachment refuses rather than emitting a file that lies about
217        // conformance. PDF/A-3 exists precisely to permit arbitrary
218        // embedded files.
219        if let Some(level) = pdfa {
220            if !level.allows_attachments() && (embedded_data.is_some() || !attachments.is_empty()) {
221                use crate::model::PdfAConformance as L;
222                let (family, clause, remedy) = match level {
223                    L::A4 => ("PDF/A-4", "ISO 19005-4", "pdfa: \"4f\""),
224                    _ => (
225                        "PDF/A-2",
226                        "ISO 19005-2, 6.8",
227                        "a PDF/A-3 level — e.g. pdfa: \"3b\"",
228                    ),
229                };
230                return Err(FormeError::RenderError(format!(
231                    "{family} forbids embedded files that are not themselves PDF/A \
232                     ({clause}), which the engine cannot verify. Use {remedy}, which \
233                     permits arbitrary attachments, or remove the attachment / embedData."
234                )));
235            }
236            // The inverse rule, from veraPDF's PDFA-4F profile verbatim
237            // (6.9-t5): "A PDF/A-4f conforming file shall contain an
238            // EmbeddedFiles key" — an A-4f claim with NOTHING embedded is
239            // itself non-conformant.
240            if matches!(level, crate::model::PdfAConformance::A4f)
241                && embedded_data.is_none()
242                && attachments.is_empty()
243            {
244                return Err(FormeError::RenderError(
245                    "pdfa: \"4f\" requires at least one embedded file (ISO 19005-4, \
246                     Annex A; veraPDF 6.9-t5 — the EmbeddedFiles name tree must exist). \
247                     Add an attachment or embedData, or claim pdfa: \"4\" instead."
248                        .to_string(),
249                ));
250            }
251        }
252        // Factur-X/ZUGFeRD identification is container metadata pointing
253        // at an attached XML: it needs PDF/A-3 and a matching attachment,
254        // or the XMP would name a profile/file that isn't there.
255        let zugferd_filename: Option<String> = if let Some(z) = zugferd {
256            const LEVELS: [&str; 6] = [
257                "MINIMUM",
258                "BASIC WL",
259                "BASIC",
260                "EN 16931",
261                "EXTENDED",
262                "XRECHNUNG",
263            ];
264            if !LEVELS.contains(&z.conformance_level.as_str()) {
265                return Err(FormeError::RenderError(format!(
266                    "zugferd.conformanceLevel {:?} is not a Factur-X profile — expected one of \
267                     MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG (exact spelling, \
268                     spaces included).",
269                    z.conformance_level
270                )));
271            }
272            if !pdfa.is_some_and(|l| l.allows_attachments()) {
273                return Err(FormeError::RenderError(
274                    "Factur-X/ZUGFeRD (zugferd) requires a PDF/A-3 conformance level — set \
275                     pdfa: \"3b\" (or \"3a\"/\"3u\"). The e-invoice XML is an embedded file, \
276                     which only PDF/A-3 permits."
277                        .to_string(),
278                ));
279            }
280            let filename = z.document_file_name.clone().unwrap_or_else(|| {
281                if z.conformance_level == "XRECHNUNG" {
282                    "xrechnung.xml".to_string()
283                } else {
284                    "factur-x.xml".to_string()
285                }
286            });
287            if !attachments.iter().any(|a| a.name == filename) {
288                return Err(FormeError::RenderError(format!(
289                    "zugferd is set but no attachment is named {filename:?} — the XMP would \
290                     point at a file that isn't embedded. Attach the invoice XML with name: \
291                     {filename:?}, or set zugferd.documentFileName to the attachment's name."
292                )));
293            }
294            Some(filename)
295        } else {
296            None
297        };
298        let mut builder = PdfBuilder {
299            objects: Vec::new(),
300            font_objects: Vec::new(),
301            custom_font_data: HashMap::new(),
302            embedded_standard_fonts: std::collections::HashSet::new(),
303            image_objects: Vec::new(),
304            image_index_map: HashMap::new(),
305            page_background_image_map: HashMap::new(),
306            page_background_url_cache: HashMap::new(),
307            ext_gstate_map: HashMap::new(),
308            shading_map: HashMap::new(),
309            warnings: Vec::new(),
310            missing_glyphs: Default::default(),
311            pdf_version: Default::default(),
312        };
313
314        // Reserve object IDs:
315        // 0 = placeholder (PDF objects are 1-indexed)
316        // 1 = Catalog
317        // 2 = Pages (page tree root)
318        // 3+ = fonts, then page objects, then content streams
319        builder.objects.push(PdfObject {
320            id: 0,
321            data: vec![],
322        });
323        builder.objects.push(PdfObject {
324            id: 1,
325            data: vec![],
326        });
327        builder.objects.push(PdfObject {
328            id: 2,
329            data: vec![],
330        });
331
332        // Register the fonts actually used across all pages
333        builder.pdf_version = pdf_version;
334        self.register_fonts(&mut builder, pages, font_context, pdf_ua, pdf_version)?;
335
336        // PDF/A: validate that all fonts are embedded. A font counts as
337        // embedded if the caller registered custom bytes for it OR it's a
338        // base-14 family embedded via the pdfUa Liberation substitution
339        // (`embedded_standard_fonts`) — so PDF/A composes with PDF/UA when
340        // @formepdf/fonts-standard is registered.
341        if pdfa.is_some() {
342            for (key, _) in &builder.font_objects {
343                if !builder.custom_font_data.contains_key(key)
344                    && !builder.embedded_standard_fonts.contains(key)
345                {
346                    return Err(FormeError::RenderError(format!(
347                        "PDF/A requires all fonts to be embedded, but '{}' is not. Register a \
348                         metric-compatible font — install @formepdf/fonts-standard and register \
349                         its fonts (`for (const f of standardFonts()) Font.register(f)`), or supply \
350                         your own via Font.register().",
351                        key.family
352                    )));
353                }
354            }
355        }
356
357        // Register images as XObject PDF objects
358        self.register_images(&mut builder, pages);
359
360        // Register page background images (if any) — distinct from
361        // element-level Image XObjects since they're addressed per-page
362        // and can be shared across pages with the same source URL.
363        self.register_page_background_images(&mut builder, pages);
364
365        // Register ExtGState objects for opacity
366        self.register_ext_gstates(&mut builder, pages);
367
368        // Register Shading dictionaries for gradient backgrounds.
369        self.register_shadings(&mut builder, pages);
370
371        // Create tag builder for accessibility if requested. PDF/UA-2 mode
372        // selects the ISO 32005 structure shape (see TagBuilder::new).
373        let mut tag_builder = if tagged {
374            Some(tagged::TagBuilder::new(pages.len(), pdf_ua2))
375        } else {
376            None
377        };
378
379        // Two-pass page processing:
380        // Pass 1: Build content streams, page objects, collect bookmarks + annotations
381        // Pass 2: Create annotation objects (needs full bookmark list for internal links)
382        let mut page_obj_ids: Vec<usize> = Vec::new();
383        let mut all_bookmarks: Vec<PdfBookmark> = Vec::new();
384        let mut per_page_content_obj_ids: Vec<usize> = Vec::new();
385        let mut per_page_annotations: Vec<Vec<LinkAnnotation>> = Vec::new();
386        let mut per_page_resources: Vec<String> = Vec::new();
387        let mut all_form_fields: Vec<FormFieldData> = Vec::new();
388
389        // Pass 1: content streams, page objects (without /Annots), bookmarks
390        for (page_idx, page) in pages.iter().enumerate() {
391            let content = self.build_content_stream_for_page(
392                page,
393                page_idx,
394                &builder,
395                page_idx + 1,
396                pages.len(),
397                tag_builder.as_mut(),
398                flatten_forms,
399            );
400            let compressed = compress_to_vec_zlib(content.as_bytes(), 6);
401
402            let content_obj_id = builder.objects.len();
403            let mut content_data: Vec<u8> = Vec::new();
404            let _ = write!(
405                content_data,
406                "<< /Length {} /Filter /FlateDecode >>\nstream\n",
407                compressed.len()
408            );
409            content_data.extend_from_slice(&compressed);
410            content_data.extend_from_slice(b"\nendstream");
411            builder.objects.push(PdfObject {
412                id: content_obj_id,
413                data: content_data,
414            });
415            per_page_content_obj_ids.push(content_obj_id);
416
417            // Collect link annotations (deferred creation until pass 2)
418            let mut annotations: Vec<LinkAnnotation> = Vec::new();
419            Self::collect_link_annotations(&page.elements, page.height, &mut annotations);
420            per_page_annotations.push(annotations);
421
422            // Collect form field annotations
423            Self::collect_form_fields(&page.elements, page.height, page_idx, &mut all_form_fields);
424
425            // Reserve page object (placeholder — filled in pass 2)
426            let page_obj_id = builder.objects.len();
427            builder.objects.push(PdfObject {
428                id: page_obj_id,
429                data: vec![],
430            });
431
432            // Build resource dict for this page
433            let font_resources = self.build_font_resource_dict(&builder.font_objects);
434            let xobject_resources = self.build_xobject_resource_dict(page_idx, &builder);
435            let ext_gstate_resources = self.build_ext_gstate_resource_dict(&builder);
436            let shading_resources = self.build_shading_resource_dict(page_idx, &builder);
437            let mut resources = format!("/Font << {} >>", font_resources);
438            if !xobject_resources.is_empty() {
439                let _ = write!(resources, " /XObject << {} >>", xobject_resources);
440            }
441            if !ext_gstate_resources.is_empty() {
442                let _ = write!(resources, " /ExtGState << {} >>", ext_gstate_resources);
443            }
444            if !shading_resources.is_empty() {
445                let _ = write!(resources, " /Shading << {} >>", shading_resources);
446            }
447            per_page_resources.push(resources);
448
449            // Collect bookmarks (needs page_obj_id)
450            Self::collect_bookmarks(&page.elements, page.height, page_obj_id, &mut all_bookmarks);
451
452            page_obj_ids.push(page_obj_id);
453        }
454
455        // Pass 2: create annotation objects and fill in page dicts
456        for (page_idx, annotations) in per_page_annotations.iter().enumerate() {
457            let mut annot_obj_ids: Vec<usize> = Vec::new();
458            for annot in annotations {
459                let rect = format!(
460                    "[{:.2} {:.2} {:.2} {:.2}]",
461                    annot.x,
462                    annot.y,
463                    annot.x + annot.width,
464                    annot.y + annot.height
465                );
466
467                if let Some(anchor) = annot.href.strip_prefix('#') {
468                    // Internal link: find matching bookmark by title
469                    if let Some(bm) = all_bookmarks.iter().find(|b| b.title == anchor) {
470                        let annot_obj_id = builder.objects.len();
471                        // Tagged: attach this annotation to its /Link structure
472                        // element (OBJR + /StructParent) so links are tagged
473                        // (PDF/UA 7.18.5-1).
474                        let sp_str = tag_builder
475                            .as_mut()
476                            .and_then(|tb| {
477                                tb.connect_link_annotation(page_idx, &annot.href, annot_obj_id)
478                            })
479                            .map(|sp| format!(" /StructParent {}", sp))
480                            .unwrap_or_default();
481                        // PDF/UA 7.18.1-2 / 7.18.5-2: a link annotation must
482                        // carry an alternate description in its /Contents key.
483                        let contents = Self::escape_pdf_string(&format!("Link to {anchor}"));
484                        // ISO 14289-2 8.8: "All destinations whose target
485                        // lies within the current document shall be
486                        // structure destinations." Under UA-2 the GoTo also
487                        // carries /SD targeting the bookmark's structure
488                        // element; the placeholder object number is patched
489                        // with the real id after write_objects assigns it.
490                        let wants_sd = tag_builder
491                            .as_mut()
492                            .map(|tb| tb.request_struct_destination(anchor, annot_obj_id))
493                            .unwrap_or(false);
494                        let sd_str = if wants_sd {
495                            format!(" /SD [999999999 0 R /XYZ 0 {:.2} null]", bm.y_pdf)
496                        } else {
497                            String::new()
498                        };
499                        let annot_dict = format!(
500                            "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] \
501                             /F 4 /Contents ({}){} \
502                             /A << /S /GoTo /D [{} 0 R /XYZ 0 {:.2} null]{} >> >>",
503                            rect, contents, sp_str, bm.page_obj_id, bm.y_pdf, sd_str
504                        );
505                        builder.objects.push(PdfObject {
506                            id: annot_obj_id,
507                            data: annot_dict.into_bytes(),
508                        });
509                        annot_obj_ids.push(annot_obj_id);
510                    }
511                    // No matching bookmark: skip silently
512                } else {
513                    // External link
514                    let annot_obj_id = builder.objects.len();
515                    let sp_str = tag_builder
516                        .as_mut()
517                        .and_then(|tb| {
518                            tb.connect_link_annotation(page_idx, &annot.href, annot_obj_id)
519                        })
520                        .map(|sp| format!(" /StructParent {}", sp))
521                        .unwrap_or_default();
522                    let href_esc = Self::escape_pdf_string(&annot.href);
523                    let annot_dict = format!(
524                        "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] \
525                         /F 4 /Contents ({}){} \
526                         /A << /Type /Action /S /URI /URI ({}) >> >>",
527                        rect, href_esc, sp_str, href_esc
528                    );
529                    builder.objects.push(PdfObject {
530                        id: annot_obj_id,
531                        data: annot_dict.into_bytes(),
532                    });
533                    annot_obj_ids.push(annot_obj_id);
534                }
535            }
536
537            let annots_str = if annot_obj_ids.is_empty() {
538                String::new()
539            } else {
540                let refs: String = annot_obj_ids
541                    .iter()
542                    .map(|id| format!("{} 0 R", id))
543                    .collect::<Vec<_>>()
544                    .join(" ");
545                format!(" /Annots [{}]", refs)
546            };
547
548            let page_obj_id = page_obj_ids[page_idx];
549            let content_obj_id = per_page_content_obj_ids[page_idx];
550            let struct_parents_str = if tagged {
551                format!(" /StructParents {} /Tabs /S", page_idx)
552            } else {
553                String::new()
554            };
555            let page_dict = format!(
556                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] \
557                 /Contents {} 0 R /Resources << {} >>{}{} >>",
558                pages[page_idx].width,
559                pages[page_idx].height,
560                content_obj_id,
561                per_page_resources[page_idx],
562                annots_str,
563                struct_parents_str
564            );
565            builder.objects[page_obj_id].data = page_dict.into_bytes();
566        }
567
568        // Build outline tree if bookmarks exist
569        let outlines_obj_id = if !all_bookmarks.is_empty() {
570            Some(self.write_outline_tree(&mut builder, &all_bookmarks, tag_builder.as_mut()))
571        } else {
572            None
573        };
574
575        // Build structure tree for tagged PDF
576        let struct_tree_root_id = if let Some(ref tb) = tag_builder {
577            let (root_id, _parent_tree_id, sd_patches) = tb.write_objects(
578                &mut builder.objects,
579                &page_obj_ids,
580                metadata.lang.as_deref(),
581                pdf_version == crate::model::PdfVersion::V2_0,
582            );
583            // Resolve pending structure destinations: the annotation dicts
584            // carry a placeholder object number for their /SD target, since
585            // structure-element ids aren't assigned until write_objects.
586            for (annot_obj_id, elem_obj_id) in sd_patches {
587                let data = std::mem::take(&mut builder.objects[annot_obj_id].data);
588                let patched = String::from_utf8(data)
589                    .expect("annotation dicts are ASCII")
590                    .replacen("999999999 0 R", &format!("{} 0 R", elem_obj_id), 1);
591                builder.objects[annot_obj_id].data = patched.into_bytes();
592            }
593            Some(root_id)
594        } else {
595            None
596        };
597
598        // PDF/A and/or PDF/UA — and ALWAYS under PDF 2.0, where document
599        // metadata lives in XMP (the trailer /Info entries are deprecated
600        // in ISO 32000-2 and the key itself is forbidden by veraPDF's
601        // PDF/A-4 profile): write the XMP metadata stream.
602        let xmp_metadata_id =
603            if pdfa.is_some() || pdf_ua || pdf_version == crate::model::PdfVersion::V2_0 {
604                let xmp_xml = xmp::generate_xmp(metadata, pdfa, pdf_ua, pdf_ua2, zugferd);
605                let xmp_bytes = xmp_xml.as_bytes();
606                let xmp_obj_id = builder.objects.len();
607                // XMP metadata stream must NOT be compressed (PDF/A requirement)
608                let xmp_data = format!(
609                    "<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n",
610                    xmp_bytes.len()
611                );
612                let mut xmp_obj_data: Vec<u8> = xmp_data.into_bytes();
613                xmp_obj_data.extend_from_slice(xmp_bytes);
614                xmp_obj_data.extend_from_slice(b"\nendstream");
615                builder.objects.push(PdfObject {
616                    id: xmp_obj_id,
617                    data: xmp_obj_data,
618                });
619                Some(xmp_obj_id)
620            } else {
621                None
622            };
623
624        let output_intent_id = if pdfa.is_some() {
625            // Embed sRGB ICC profile
626            static SRGB_ICC: &[u8] = include_bytes!("sRGB.icc");
627            let compressed_icc = compress_to_vec_zlib(SRGB_ICC, 6);
628
629            let icc_obj_id = builder.objects.len();
630            let mut icc_data: Vec<u8> = Vec::new();
631            let _ = write!(
632                icc_data,
633                "<< /N 3 /Length {} /Filter /FlateDecode >>\nstream\n",
634                compressed_icc.len()
635            );
636            icc_data.extend_from_slice(&compressed_icc);
637            icc_data.extend_from_slice(b"\nendstream");
638            builder.objects.push(PdfObject {
639                id: icc_obj_id,
640                data: icc_data,
641            });
642
643            // OutputIntent dictionary
644            let oi_obj_id = builder.objects.len();
645            let oi_data = format!(
646                "<< /Type /OutputIntent /S /GTS_PDFA1 \
647                 /OutputConditionIdentifier (sRGB IEC61966-2.1) \
648                 /RegistryName (http://www.color.org) \
649                 /DestOutputProfile {} 0 R >>",
650                icc_obj_id
651            );
652            builder.objects.push(PdfObject {
653                id: oi_obj_id,
654                data: oi_data.into_bytes(),
655            });
656            Some(oi_obj_id)
657        } else {
658            None
659        };
660
661        // Embedded files: the legacy embeddedData JSON plus caller
662        // attachments (associated files). The legacy-only path must stay
663        // byte-identical to what it always emitted; attachments add the
664        // PDF/A-3 requirements — MIME /Subtype (6.8-1), /F + /UF (6.8-2),
665        // /AFRelationship (6.8-3) — and everything joins the catalog /AF
666        // array (6.8-4) as needed.
667        let mut name_tree_entries: Vec<(String, usize)> = Vec::new();
668        let mut af_filespec_ids: Vec<usize> = Vec::new();
669        if let Some(data) = embedded_data {
670            let compressed = compress_to_vec_zlib(data.as_bytes(), 6);
671
672            // EmbeddedFile stream
673            let ef_obj_id = builder.objects.len();
674            let ef_data = format!(
675                "<< /Type /EmbeddedFile /Subtype /application#2Fjson /Length {} /Filter /FlateDecode >>\nstream\n",
676                compressed.len()
677            );
678            let mut ef_bytes = ef_data.into_bytes();
679            ef_bytes.extend_from_slice(&compressed);
680            ef_bytes.extend_from_slice(b"\nendstream");
681            builder.objects.push(PdfObject {
682                id: ef_obj_id,
683                data: ef_bytes,
684            });
685
686            // FileSpec dictionary
687            let fs_obj_id = builder.objects.len();
688            let desc = if builder.pdf_version == crate::model::PdfVersion::V2_0 {
689                // ISO 14289-2 8.14.1 (see the attachments path below).
690                " /Desc (forme-data.json)"
691            } else {
692                ""
693            };
694            let fs_data = format!(
695                "<< /Type /Filespec /F (forme-data.json) /UF (forme-data.json) /EF << /F {} 0 R >> /AFRelationship /Data{} >>",
696                ef_obj_id, desc
697            );
698            builder.objects.push(PdfObject {
699                id: fs_obj_id,
700                data: fs_data.into_bytes(),
701            });
702            name_tree_entries.push(("forme-data.json".to_string(), fs_obj_id));
703            // Association is a PDF/A-3 requirement; the plain path keeps
704            // its historical byte-identical shape (no /AF).
705            if pdfa.is_some_and(|l| l.allows_attachments()) {
706                af_filespec_ids.push(fs_obj_id);
707            }
708        }
709        for att in attachments {
710            let bytes = Self::decode_attachment_src(&att.src)?;
711            let compressed = compress_to_vec_zlib(&bytes, 6);
712            let mime = att
713                .mime_type
714                .as_deref()
715                .unwrap_or("application/octet-stream");
716            let mod_date = att
717                .mod_date
718                .as_deref()
719                .unwrap_or(DEFAULT_ATTACHMENT_MOD_DATE);
720
721            let ef_obj_id = builder.objects.len();
722            let ef_head = format!(
723                "<< /Type /EmbeddedFile /Subtype /{} /Length {} /Filter /FlateDecode \
724                 /Params << /Size {} /ModDate ({}) >> >>\nstream\n",
725                Self::mime_to_pdf_name(mime),
726                compressed.len(),
727                bytes.len(),
728                Self::escape_pdf_string(mod_date),
729            );
730            let mut ef_bytes = ef_head.into_bytes();
731            ef_bytes.extend_from_slice(&compressed);
732            ef_bytes.extend_from_slice(b"\nendstream");
733            builder.objects.push(PdfObject {
734                id: ef_obj_id,
735                data: ef_bytes,
736            });
737
738            // The invoice XML named by zugferd gets its relationship from
739            // the profile when the caller didn't set one: MINIMUM and
740            // BASIC WL are not full invoices (spec mandates /Data); the
741            // conformant profiles use /Alternative (mandatory in DE).
742            let relationship = att.relationship.unwrap_or_else(|| {
743                if zugferd_filename.as_deref() == Some(att.name.as_str()) {
744                    match zugferd.map(|z| z.conformance_level.as_str()) {
745                        Some("MINIMUM") | Some("BASIC WL") => AfRelationship::Data,
746                        _ => AfRelationship::Alternative,
747                    }
748                } else {
749                    AfRelationship::Unspecified
750                }
751            });
752
753            let fs_obj_id = builder.objects.len();
754            let mut fs_data = format!(
755                "<< /Type /Filespec /F ({name}) /UF ({name}) /EF << /F {ef} 0 R >> /AFRelationship /{rel}",
756                name = Self::escape_pdf_string(&att.name),
757                ef = ef_obj_id,
758                rel = relationship.pdf_name(),
759            );
760            if let Some(desc) = &att.description {
761                let _ = write!(fs_data, " /Desc ({})", Self::escape_pdf_string(desc));
762            } else if builder.pdf_version == crate::model::PdfVersion::V2_0 {
763                // ISO 14289-2 8.14.1: "The Desc entry shall be present on
764                // all file specification dictionaries present in the
765                // EmbeddedFiles name tree." The file name is the honest
766                // default when the author gave no description.
767                let _ = write!(fs_data, " /Desc ({})", Self::escape_pdf_string(&att.name));
768            }
769            fs_data.push_str(" >>");
770            builder.objects.push(PdfObject {
771                id: fs_obj_id,
772                data: fs_data.into_bytes(),
773            });
774            name_tree_entries.push((att.name.clone(), fs_obj_id));
775            af_filespec_ids.push(fs_obj_id);
776        }
777        let embedded_names_id = if name_tree_entries.is_empty() {
778            None
779        } else {
780            // Name-tree keys must be lexically sorted (PDF 32000 §7.9.6).
781            name_tree_entries.sort_by(|a, b| a.0.cmp(&b.0));
782            let names_obj_id = builder.objects.len();
783            let pairs = name_tree_entries
784                .iter()
785                .map(|(name, id)| format!("({}) {} 0 R", Self::escape_pdf_string(name), id))
786                .collect::<Vec<_>>()
787                .join(" ");
788            let names_data = format!("<< /Names [{}] >>", pairs);
789            builder.objects.push(PdfObject {
790                id: names_obj_id,
791                data: names_data.into_bytes(),
792            });
793            Some(names_obj_id)
794        };
795
796        // Build AcroForm for interactive form fields
797        let acroform_obj_id = if !all_form_fields.is_empty() && !flatten_forms {
798            // Find the Helvetica font object ID for AcroForm /DR
799            let helv_obj_id = builder
800                .font_objects
801                .iter()
802                .find(|(key, _)| key.family == "Helvetica" && key.weight == 400 && !key.italic)
803                .map(|(_, id)| *id);
804
805            // Separate radio buttons from other fields
806            let mut radio_groups: HashMap<String, Vec<usize>> = HashMap::new(); // name -> indices
807            let mut non_radio_indices: Vec<usize> = Vec::new();
808            for (i, field) in all_form_fields.iter().enumerate() {
809                if matches!(field.field_type, FormFieldType::RadioButton { .. }) {
810                    radio_groups.entry(field.name.clone()).or_default().push(i);
811                } else {
812                    non_radio_indices.push(i);
813                }
814            }
815
816            // Pre-allocate parent field objects for radio groups
817            let mut radio_parent_ids: HashMap<String, usize> = HashMap::new();
818            for group_name in radio_groups.keys() {
819                let parent_id = builder.objects.len();
820                builder.objects.push(PdfObject {
821                    id: parent_id,
822                    data: vec![], // placeholder — filled after kids are created
823                });
824                radio_parent_ids.insert(group_name.clone(), parent_id);
825            }
826
827            // Create appearance streams for checkboxes and radio buttons
828            // Checkbox checked: checkmark
829            let checkbox_yes_stream_id = builder.objects.len();
830            {
831                let stream_content =
832                    b"0.2 0.2 0.2 rg\n2 6 m 5.5 2 l 12 11 l 11 12 l 5.5 4.5 l 3 7 l 2 6 l f\n";
833                let mut data: Vec<u8> = Vec::new();
834                let _ = write!(
835                    data,
836                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
837                    stream_content.len()
838                );
839                data.extend_from_slice(stream_content);
840                data.extend_from_slice(b"\nendstream");
841                builder.objects.push(PdfObject {
842                    id: checkbox_yes_stream_id,
843                    data,
844                });
845            }
846            // Checkbox unchecked: empty
847            let checkbox_off_stream_id = builder.objects.len();
848            {
849                let stream_content = b"";
850                let mut data: Vec<u8> = Vec::new();
851                let _ = write!(
852                    data,
853                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
854                    stream_content.len()
855                );
856                data.extend_from_slice(stream_content);
857                data.extend_from_slice(b"\nendstream");
858                builder.objects.push(PdfObject {
859                    id: checkbox_off_stream_id,
860                    data,
861                });
862            }
863            // Radio selected: filled circle (bezier approximation)
864            let radio_on_stream_id = builder.objects.len();
865            {
866                // Circle centered at (7,7) radius 5 using 4-segment bezier
867                let k = 2.761; // 5 * 0.5523 (magic number for circle approximation)
868                let stream_content = format!(
869                    "0.2 0.2 0.2 rg\n\
870                     7 12 m {:.2} 12 12 {:.2} 12 7 c\n\
871                     12 {:.2} {:.2} 2 7 2 c\n\
872                     {:.2} 2 2 {:.2} 2 7 c\n\
873                     2 {:.2} {:.2} 12 7 12 c f\n",
874                    7.0 + k,
875                    7.0 + k, // top-right
876                    7.0 - k,
877                    7.0 - k, // bottom-right
878                    7.0 - k,
879                    7.0 - k, // bottom-left
880                    7.0 + k,
881                    7.0 + k, // top-left
882                );
883                let stream_bytes = stream_content.as_bytes();
884                let mut data: Vec<u8> = Vec::new();
885                let _ = write!(
886                    data,
887                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
888                    stream_bytes.len()
889                );
890                data.extend_from_slice(stream_bytes);
891                data.extend_from_slice(b"\nendstream");
892                builder.objects.push(PdfObject {
893                    id: radio_on_stream_id,
894                    data,
895                });
896            }
897            // Radio unselected: empty
898            let radio_off_stream_id = builder.objects.len();
899            {
900                let stream_content = b"";
901                let mut data: Vec<u8> = Vec::new();
902                let _ = write!(
903                    data,
904                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
905                    stream_content.len()
906                );
907                data.extend_from_slice(stream_content);
908                data.extend_from_slice(b"\nendstream");
909                builder.objects.push(PdfObject {
910                    id: radio_off_stream_id,
911                    data,
912                });
913            }
914
915            // Create widget annotation objects per page
916            let mut acroform_field_ids: Vec<usize> = Vec::new();
917            let mut per_page_widget_ids: Vec<Vec<usize>> = vec![Vec::new(); pages.len()];
918            let mut radio_kid_ids: HashMap<String, Vec<usize>> = HashMap::new();
919
920            for field in all_form_fields.iter() {
921                let rect = format!(
922                    "[{:.2} {:.2} {:.2} {:.2}]",
923                    field.x,
924                    field.y,
925                    field.x + field.width,
926                    field.y + field.height
927                );
928                let page_ref = format!("{} 0 R", page_obj_ids[field.page_idx]);
929
930                match &field.field_type {
931                    FormFieldType::TextField {
932                        value,
933                        multiline,
934                        password,
935                        read_only,
936                        max_length,
937                        font_size,
938                        ..
939                    } => {
940                        let mut flags: u32 = 0;
941                        if *multiline {
942                            flags |= 1 << 12; // bit 13 (0-indexed bit 12)
943                        }
944                        if *password {
945                            flags |= 1 << 13; // bit 14
946                        }
947                        if *read_only {
948                            flags |= 1; // bit 1
949                        }
950                        let da = if let Some(helv_id) = helv_obj_id {
951                            let _ = helv_id; // used in /DR, not /DA
952                            format!("/Helv {} Tf 0 g", font_size)
953                        } else {
954                            format!("/Helv {} Tf 0 g", font_size)
955                        };
956                        let v_str = if let Some(ref v) = value {
957                            format!(
958                                " /V ({}) /DV ({})",
959                                Self::escape_pdf_string(v),
960                                Self::escape_pdf_string(v)
961                            )
962                        } else {
963                            String::new()
964                        };
965                        let max_len_str = if let Some(ml) = max_length {
966                            format!(" /MaxLen {}", ml)
967                        } else {
968                            String::new()
969                        };
970                        // Build appearance stream for the text field
971                        let ap_w = field.width;
972                        let ap_h = field.height;
973                        let text_y = if *multiline {
974                            ap_h - *font_size - 2.0
975                        } else {
976                            (ap_h - *font_size) / 2.0
977                        };
978                        let ap_content = if let Some(ref v) = value {
979                            format!(
980                                "1 1 1 rg 0 0 {} {} re f \
981                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S \
982                                 BT /Helv {} Tf 0 g 2 {} Td ({}) Tj ET",
983                                ap_w,
984                                ap_h,
985                                ap_w,
986                                ap_h,
987                                font_size,
988                                text_y,
989                                Self::escape_pdf_string(v)
990                            )
991                        } else {
992                            format!(
993                                "1 1 1 rg 0 0 {} {} re f \
994                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S",
995                                ap_w, ap_h, ap_w, ap_h
996                            )
997                        };
998                        let ap_stream_id = builder.objects.len();
999                        let ap_stream = format!(
1000                            "<< /Type /XObject /Subtype /Form /BBox [0 0 {} {}] \
1001                             /Resources << /Font << /Helv {} 0 R >> >> /Length {} >>\nstream\n{}\nendstream",
1002                            ap_w, ap_h,
1003                            helv_obj_id.unwrap_or(0),
1004                            ap_content.len(),
1005                            ap_content
1006                        );
1007                        builder.objects.push(PdfObject {
1008                            id: ap_stream_id,
1009                            data: ap_stream.into_bytes(),
1010                        });
1011
1012                        let widget_obj_id = builder.objects.len();
1013                        let widget_dict = format!(
1014                            "<< /Type /Annot /Subtype /Widget /FT /Tx \
1015                             /T ({}) /Rect {} /P {}\
1016                             {} /DA ({}) /Ff {}{} \
1017                             /MK << /BC [0.6 0.6 0.6] /BG [1 1 1] >> \
1018                             /AP << /N {} 0 R >> >>",
1019                            Self::escape_pdf_string(&field.name),
1020                            rect,
1021                            page_ref,
1022                            v_str,
1023                            da,
1024                            flags,
1025                            max_len_str,
1026                            ap_stream_id
1027                        );
1028                        builder.objects.push(PdfObject {
1029                            id: widget_obj_id,
1030                            data: widget_dict.into_bytes(),
1031                        });
1032                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1033                        acroform_field_ids.push(widget_obj_id);
1034                    }
1035
1036                    FormFieldType::Checkbox {
1037                        checked, read_only, ..
1038                    } => {
1039                        let state = if *checked { "Yes" } else { "Off" };
1040                        let mut flags: u32 = 0;
1041                        if *read_only {
1042                            flags |= 1;
1043                        }
1044                        let ff_str = if flags > 0 {
1045                            format!(" /Ff {}", flags)
1046                        } else {
1047                            String::new()
1048                        };
1049                        let widget_obj_id = builder.objects.len();
1050                        let widget_dict = format!(
1051                            "<< /Type /Annot /Subtype /Widget /FT /Btn \
1052                             /T ({}) /Rect {} /P {} \
1053                             /V /{} /AS /{}{} \
1054                             /MK << /BC [0.6 0.6 0.6] /CA (4) >> \
1055                             /AP << /N << /Yes {} 0 R /Off {} 0 R >> >> >>",
1056                            Self::escape_pdf_string(&field.name),
1057                            rect,
1058                            page_ref,
1059                            state,
1060                            state,
1061                            ff_str,
1062                            checkbox_yes_stream_id,
1063                            checkbox_off_stream_id,
1064                        );
1065                        builder.objects.push(PdfObject {
1066                            id: widget_obj_id,
1067                            data: widget_dict.into_bytes(),
1068                        });
1069                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1070                        acroform_field_ids.push(widget_obj_id);
1071                    }
1072
1073                    FormFieldType::Dropdown {
1074                        options,
1075                        value,
1076                        read_only,
1077                        font_size,
1078                        ..
1079                    } => {
1080                        let mut flags: u32 = 1 << 17; // bit 18 = combo box
1081                        if *read_only {
1082                            flags |= 1;
1083                        }
1084                        let opts_str: String = options
1085                            .iter()
1086                            .map(|o| format!("({})", Self::escape_pdf_string(o)))
1087                            .collect::<Vec<_>>()
1088                            .join(" ");
1089                        let v_str = if let Some(ref v) = value {
1090                            format!(" /V ({})", Self::escape_pdf_string(v))
1091                        } else {
1092                            String::new()
1093                        };
1094                        // Build appearance stream for the dropdown
1095                        let ap_w = field.width;
1096                        let ap_h = field.height;
1097                        let text_y = (ap_h - *font_size) / 2.0;
1098                        let ap_content = if let Some(ref v) = value {
1099                            format!(
1100                                "1 1 1 rg 0 0 {} {} re f \
1101                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S \
1102                                 BT /Helv {} Tf 0 g 2 {} Td ({}) Tj ET",
1103                                ap_w,
1104                                ap_h,
1105                                ap_w,
1106                                ap_h,
1107                                font_size,
1108                                text_y,
1109                                Self::escape_pdf_string(v)
1110                            )
1111                        } else {
1112                            format!(
1113                                "1 1 1 rg 0 0 {} {} re f \
1114                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S",
1115                                ap_w, ap_h, ap_w, ap_h
1116                            )
1117                        };
1118                        let ap_stream_id = builder.objects.len();
1119                        let ap_stream = format!(
1120                            "<< /Type /XObject /Subtype /Form /BBox [0 0 {} {}] \
1121                             /Resources << /Font << /Helv {} 0 R >> >> /Length {} >>\nstream\n{}\nendstream",
1122                            ap_w, ap_h,
1123                            helv_obj_id.unwrap_or(0),
1124                            ap_content.len(),
1125                            ap_content
1126                        );
1127                        builder.objects.push(PdfObject {
1128                            id: ap_stream_id,
1129                            data: ap_stream.into_bytes(),
1130                        });
1131
1132                        let widget_obj_id = builder.objects.len();
1133                        let widget_dict = format!(
1134                            "<< /Type /Annot /Subtype /Widget /FT /Ch \
1135                             /T ({}) /Rect {} /P {} \
1136                             /Opt [{}]{} \
1137                             /DA (/Helv {} Tf 0 g) /Ff {} \
1138                             /MK << /BC [0.6 0.6 0.6] /BG [1 1 1] >> \
1139                             /AP << /N {} 0 R >> >>",
1140                            Self::escape_pdf_string(&field.name),
1141                            rect,
1142                            page_ref,
1143                            opts_str,
1144                            v_str,
1145                            font_size,
1146                            flags,
1147                            ap_stream_id
1148                        );
1149                        builder.objects.push(PdfObject {
1150                            id: widget_obj_id,
1151                            data: widget_dict.into_bytes(),
1152                        });
1153                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1154                        acroform_field_ids.push(widget_obj_id);
1155                    }
1156
1157                    FormFieldType::RadioButton {
1158                        value,
1159                        checked,
1160                        read_only: _,
1161                    } => {
1162                        // Radio kid widget — parent reference is critical
1163                        let parent_id = radio_parent_ids[&field.name];
1164                        let as_value = if *checked { value.as_str() } else { "Off" };
1165                        let widget_obj_id = builder.objects.len();
1166                        let widget_dict = format!(
1167                            "<< /Type /Annot /Subtype /Widget \
1168                             /Parent {} 0 R \
1169                             /Rect {} /P {} \
1170                             /AS /{} \
1171                             /AP << /N << /{} {} 0 R /Off {} 0 R >> >> \
1172                             /MK << /BC [0.6 0.6 0.6] >> >>",
1173                            parent_id,
1174                            rect,
1175                            page_ref,
1176                            Self::escape_pdf_string(as_value),
1177                            Self::escape_pdf_string(value),
1178                            radio_on_stream_id,
1179                            radio_off_stream_id,
1180                        );
1181                        builder.objects.push(PdfObject {
1182                            id: widget_obj_id,
1183                            data: widget_dict.into_bytes(),
1184                        });
1185                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1186                        // Kids go in page /Annots, NOT in /AcroForm /Fields
1187                        radio_kid_ids
1188                            .entry(field.name.clone())
1189                            .or_default()
1190                            .push(widget_obj_id);
1191                    }
1192                }
1193            }
1194
1195            // Fill in radio parent field objects
1196            for (group_name, kid_indices) in &radio_kid_ids {
1197                let parent_id = radio_parent_ids[group_name];
1198                // Find the checked value in this group
1199                let checked_value = all_form_fields
1200                    .iter()
1201                    .filter(|f| f.name == *group_name)
1202                    .find_map(|f| {
1203                        if let FormFieldType::RadioButton {
1204                            ref value, checked, ..
1205                        } = f.field_type
1206                        {
1207                            if checked {
1208                                Some(value.clone())
1209                            } else {
1210                                None
1211                            }
1212                        } else {
1213                            None
1214                        }
1215                    })
1216                    .unwrap_or_else(|| "Off".to_string());
1217
1218                let kids_refs: String = kid_indices
1219                    .iter()
1220                    .map(|id| format!("{} 0 R", id))
1221                    .collect::<Vec<_>>()
1222                    .join(" ");
1223
1224                let mut flags: u32 = (1 << 14) | (1 << 15); // radio + noToggleToOff
1225                                                            // Check if read_only on any button in group
1226                let is_read_only = all_form_fields
1227                    .iter()
1228                    .filter(|f| f.name == *group_name)
1229                    .any(|f| {
1230                        matches!(
1231                            f.field_type,
1232                            FormFieldType::RadioButton {
1233                                read_only: true,
1234                                ..
1235                            }
1236                        )
1237                    });
1238                if is_read_only {
1239                    flags |= 1;
1240                }
1241
1242                let parent_dict = format!(
1243                    "<< /FT /Btn /T ({}) /Ff {} /Kids [{}] /V /{} >>",
1244                    Self::escape_pdf_string(group_name),
1245                    flags,
1246                    kids_refs,
1247                    Self::escape_pdf_string(&checked_value),
1248                );
1249                builder.objects[parent_id].data = parent_dict.into_bytes();
1250                acroform_field_ids.push(parent_id);
1251            }
1252
1253            // Now add form widget IDs to the existing page annotation arrays
1254            // We need to update the already-written page dicts to include form widgets
1255            // Rebuild page dicts with form widget annotations included
1256            for (page_idx, widget_ids) in per_page_widget_ids.iter().enumerate() {
1257                if widget_ids.is_empty() {
1258                    continue;
1259                }
1260                let page_obj_id = page_obj_ids[page_idx];
1261                let existing_page_data =
1262                    String::from_utf8_lossy(&builder.objects[page_obj_id].data).to_string();
1263
1264                // If the page already has /Annots, append to it; otherwise add it
1265                let new_refs: String = widget_ids
1266                    .iter()
1267                    .map(|id| format!("{} 0 R", id))
1268                    .collect::<Vec<_>>()
1269                    .join(" ");
1270
1271                let updated = if let Some(pos) = existing_page_data.find("/Annots [") {
1272                    // Insert before the closing ]
1273                    let bracket_end = existing_page_data[pos..].find(']').unwrap() + pos;
1274                    format!(
1275                        "{} {}{}",
1276                        &existing_page_data[..bracket_end],
1277                        new_refs,
1278                        &existing_page_data[bracket_end..]
1279                    )
1280                } else {
1281                    // Add /Annots before the final >>
1282                    let end = existing_page_data.rfind(">>").unwrap();
1283                    format!(
1284                        "{} /Annots [{}]{}",
1285                        &existing_page_data[..end],
1286                        new_refs,
1287                        &existing_page_data[end..]
1288                    )
1289                };
1290                builder.objects[page_obj_id].data = updated.into_bytes();
1291            }
1292
1293            // Create AcroForm dictionary
1294            let acroform_id = builder.objects.len();
1295            let fields_refs: String = acroform_field_ids
1296                .iter()
1297                .map(|id| format!("{} 0 R", id))
1298                .collect::<Vec<_>>()
1299                .join(" ");
1300            let dr_str = if let Some(helv_id) = helv_obj_id {
1301                format!(" /DR << /Font << /Helv {} 0 R >> >>", helv_id)
1302            } else {
1303                String::new()
1304            };
1305            // No /NeedAppearances: we build a full appearance stream for
1306            // every widget (/AP /N on each), and the flag — deprecated in
1307            // PDF 2.0 — told viewers to DISCARD them and regenerate. With
1308            // it gone, viewers render the appearances we authored, which
1309            // is what every headless renderer (poppler, pdfium, pdfjs)
1310            // did anyway.
1311            let acroform_dict = format!(
1312                "<< /Fields [{}]{} /DA (/Helv 0 Tf 0 g) >>",
1313                fields_refs, dr_str
1314            );
1315            builder.objects.push(PdfObject {
1316                id: acroform_id,
1317                data: acroform_dict.into_bytes(),
1318            });
1319            Some(acroform_id)
1320        } else {
1321            None
1322        };
1323
1324        // Write Catalog (object 1)
1325        let mut catalog = String::from("<< /Type /Catalog /Pages 2 0 R");
1326        if let Some(acroform_id) = acroform_obj_id {
1327            write!(catalog, " /AcroForm {} 0 R", acroform_id).unwrap();
1328        }
1329        if let Some(outlines_id) = outlines_obj_id {
1330            write!(
1331                catalog,
1332                " /Outlines {} 0 R /PageMode /UseOutlines",
1333                outlines_id
1334            )
1335            .unwrap();
1336        }
1337        if let Some(ref lang) = metadata.lang {
1338            write!(catalog, " /Lang ({})", Self::escape_pdf_string(lang)).unwrap();
1339        }
1340        if let Some(struct_root_id) = struct_tree_root_id {
1341            write!(
1342                catalog,
1343                " /MarkInfo << /Marked true >> /StructTreeRoot {} 0 R",
1344                struct_root_id
1345            )
1346            .unwrap();
1347        }
1348        if let Some(xmp_id) = xmp_metadata_id {
1349            write!(catalog, " /Metadata {} 0 R", xmp_id).unwrap();
1350        }
1351        if let Some(oi_id) = output_intent_id {
1352            write!(catalog, " /OutputIntents [{} 0 R]", oi_id).unwrap();
1353        }
1354        if let Some(names_id) = embedded_names_id {
1355            write!(catalog, " /Names << /EmbeddedFiles {} 0 R >>", names_id).unwrap();
1356        }
1357        if !af_filespec_ids.is_empty() {
1358            // Document-level association (PDF/A-3 6.8-4; Factur-X requires
1359            // the invoice XML to be associated at the catalog).
1360            let refs = af_filespec_ids
1361                .iter()
1362                .map(|id| format!("{} 0 R", id))
1363                .collect::<Vec<_>>()
1364                .join(" ");
1365            write!(catalog, " /AF [{}]", refs).unwrap();
1366        }
1367        if pdf_ua || pdf_ua2 {
1368            catalog.push_str(" /ViewerPreferences << /DisplayDocTitle true >>");
1369        }
1370        catalog.push_str(" >>");
1371        builder.objects[1].data = catalog.into_bytes();
1372
1373        // Write Pages tree (object 2)
1374        let kids: String = page_obj_ids
1375            .iter()
1376            .map(|id| format!("{} 0 R", id))
1377            .collect::<Vec<_>>()
1378            .join(" ");
1379        builder.objects[2].data = format!(
1380            "<< /Type /Pages /Kids [{}] /Count {} >>",
1381            kids,
1382            page_obj_ids.len()
1383        )
1384        .into_bytes();
1385
1386        // Info dictionary (metadata)
1387        // No trailer /Info under PDF 2.0: its entries are deprecated in
1388        // ISO 32000-2 and veraPDF's PDF/A-4 profile forbids the key
1389        // ("The Info key shall not be present in the trailer dictionary …
1390        // unless there exists a PieceInfo entry", which we never emit).
1391        // Document metadata lives in the XMP stream, emitted above
1392        // unconditionally for 2.0.
1393        let info_obj_id = if pdf_version == crate::model::PdfVersion::V1_7
1394            && (metadata.title.is_some() || metadata.author.is_some())
1395        {
1396            let id = builder.objects.len();
1397            let mut info = String::from("<< ");
1398            if let Some(ref title) = metadata.title {
1399                let _ = write!(info, "/Title ({}) ", Self::escape_pdf_string(title));
1400            }
1401            if let Some(ref author) = metadata.author {
1402                let _ = write!(info, "/Author ({}) ", Self::escape_pdf_string(author));
1403            }
1404            if let Some(ref subject) = metadata.subject {
1405                let _ = write!(info, "/Subject ({}) ", Self::escape_pdf_string(subject));
1406            }
1407            let _ = write!(info, "/Producer (Forme 0.6) /Creator (Forme) >>");
1408            builder.objects.push(PdfObject {
1409                id,
1410                data: info.into_bytes(),
1411            });
1412            Some(id)
1413        } else {
1414            None
1415        };
1416
1417        let pdf = self.serialize(&builder, info_obj_id);
1418        // One warning per distinct substituted character (BTreeSet order is
1419        // deterministic). Page sentinels never reach the encoders, and a
1420        // literal "?" maps through WinAnsi — only genuinely uncovered
1421        // characters land here.
1422        let mut warnings = builder.warnings;
1423        for ch in builder.missing_glyphs.into_inner() {
1424            warnings.push(format!(
1425                "render defect: \"{ch}\" (U+{:04X}) is not covered by any available font and was rendered as \"?\" — register a font containing it (Font.register, the Document fonts prop, or @font-face on the HTML path)",
1426                ch as u32
1427            ));
1428        }
1429        Ok((pdf, warnings))
1430    }
1431
1432    /// Build the PDF content stream for a single page.
1433    #[allow(clippy::too_many_arguments)]
1434    fn build_content_stream_for_page(
1435        &self,
1436        page: &LayoutPage,
1437        page_idx: usize,
1438        builder: &PdfBuilder,
1439        page_number: usize,
1440        total_pages: usize,
1441        mut tag_builder: Option<&mut tagged::TagBuilder>,
1442        flatten_forms: bool,
1443    ) -> String {
1444        let mut stream = String::new();
1445        let page_height = page.height;
1446        let mut element_counter = 0usize;
1447        let mut gradient_counter = 0usize;
1448
1449        // Page background image: paint it before any element content so
1450        // it sits behind everything. Wrapped in q/Q + ExtGState for
1451        // backgroundOpacity, with the cm matrix sized & positioned via
1452        // backgroundSize / backgroundPosition. Same XObject can be reused
1453        // across multiple pages with the same source URL.
1454        if let Some(&img_idx) = builder.page_background_image_map.get(&page_idx) {
1455            self.write_page_background(&mut stream, page, img_idx, builder);
1456        }
1457
1458        // Horizontal content clip (`PageConfig.clip_content_x`): the paged
1459        // equivalent of `body { overflow-x: hidden }`. X is clipped to the
1460        // content box; Y spans the full page so nothing vertical is lost.
1461        let clip_x = page.config.clip_content_x;
1462        if clip_x {
1463            let x = page.config.margin.left;
1464            let w = page.width - page.config.margin.left - page.config.margin.right;
1465            stream.push_str(&format!(
1466                "q\n{:.2} 0 {:.2} {:.2} re W n\n",
1467                x, w, page.height
1468            ));
1469        }
1470
1471        for element in &page.elements {
1472            self.write_element(
1473                &mut stream,
1474                element,
1475                page_height,
1476                builder,
1477                page_idx,
1478                &mut element_counter,
1479                &mut gradient_counter,
1480                page_number,
1481                total_pages,
1482                tag_builder.as_deref_mut(),
1483                flatten_forms,
1484            );
1485        }
1486
1487        if clip_x {
1488            stream.push_str("Q\n");
1489        }
1490
1491        stream
1492    }
1493
1494    /// Write a single layout element as PDF operators.
1495    #[allow(clippy::too_many_arguments)]
1496    #[allow(clippy::too_many_arguments)]
1497    fn write_element(
1498        &self,
1499        stream: &mut String,
1500        element: &LayoutElement,
1501        page_height: f64,
1502        builder: &PdfBuilder,
1503        page_idx: usize,
1504        element_counter: &mut usize,
1505        gradient_counter: &mut usize,
1506        page_number: usize,
1507        total_pages: usize,
1508        mut tag_builder: Option<&mut tagged::TagBuilder>,
1509        flatten_forms: bool,
1510    ) {
1511        // Tagged PDF: emit BDC (begin marked content) for elements with a node_type,
1512        // or /Artifact BMC for decorative elements (watermarks, untagged drawing).
1513        let mut is_artifact = false;
1514        // PDF/UA-2: a structure element was opened but got no MCID — its
1515        // role forbids content items (ISO 32005 containment matrix), so its
1516        // own ink (borders, row backgrounds) must be marked /Artifact and
1517        // only its children carry tagged content.
1518        let mut artifact_own_draw = false;
1519        let tagged_mcid = if let Some(ref mut tb) = tag_builder {
1520            if let Some(ref nt) = element.node_type {
1521                if nt == "Watermark" {
1522                    // Watermarks are decorative — mark as artifact, not structure
1523                    let _ = writeln!(stream, "/Artifact BMC");
1524                    is_artifact = true;
1525                    None
1526                } else {
1527                    let is_header = element.is_header_row;
1528                    let href = element.href.as_deref();
1529                    let mcid = tb.begin_element(
1530                        nt,
1531                        is_header,
1532                        element.alt.as_deref(),
1533                        page_idx,
1534                        href,
1535                        element.col_span,
1536                        element.list_numbering,
1537                        element.actual_text.as_deref(),
1538                    );
1539                    // Register bookmark anchors against the element just
1540                    // opened, so internal links can target it with a
1541                    // structure destination under UA-2 (ISO 14289-2 8.8).
1542                    if let Some(ref bm) = element.bookmark {
1543                        tb.note_bookmark(bm);
1544                    }
1545                    match mcid {
1546                        Some(mcid) => {
1547                            // An href'd element tags as /Link (see begin_element); the
1548                            // BDC role must match the structure role, so key on href too.
1549                            let role = if href.is_some() {
1550                                "Link"
1551                            } else {
1552                                tb.map_role_public(nt, is_header)
1553                            };
1554                            let _ = writeln!(stream, "/{} <</MCID {}>> BDC", role, mcid);
1555                            Some(mcid)
1556                        }
1557                        None => {
1558                            artifact_own_draw = true;
1559                            None
1560                        }
1561                    }
1562                }
1563            } else if !matches!(element.draw, DrawCommand::None) {
1564                // No node_type but has drawing — wrap as artifact
1565                let _ = writeln!(stream, "/Artifact BMC");
1566                is_artifact = true;
1567                None
1568            } else {
1569                None
1570            }
1571        } else {
1572            None
1573        };
1574
1575        // Element-level opacity wrap. Open `q\n/GS{n} gs` AFTER the BMC/BDC
1576        // marker block (so opacity affects content, not the marker), and
1577        // close the matching `Q` BEFORE the EMC. The wrap encompasses both
1578        // the element's own DrawCommand emission AND the recursion into
1579        // `element.children`, so descendants render at the cumulative
1580        // alpha (PDF graphics state stack multiplies naturally — a 0.5
1581        // child of a 0.5 parent renders at effective 0.25).
1582        let needs_element_opacity = element.opacity < 1.0;
1583        if needs_element_opacity {
1584            if let Some((_, gs_name)) = builder.ext_gstate_map.get(&element.opacity.to_bits()) {
1585                let _ = writeln!(stream, "q\n/{} gs", gs_name);
1586            }
1587        }
1588
1589        // CSS-style `transform` wrap. Sits INSIDE the opacity wrap so the
1590        // opacity applies to the transformed output. Layout flow is NOT
1591        // affected by the transform (matches CSS) — element.x/y/width/height
1592        // are still the axis-aligned box; the transform is paint-only and
1593        // also propagates to children via the graphics state stack.
1594        let transform_ops: &[TransformOp] = element
1595            .resolved_style
1596            .as_ref()
1597            .map(|s| s.transform.as_slice())
1598            .unwrap_or(&[]);
1599        let has_transform = !transform_ops.is_empty();
1600        if has_transform {
1601            let rs = element.resolved_style.as_ref().unwrap();
1602            let pdf_x = element.x;
1603            let pdf_y_bottom = page_height - element.y - element.height;
1604            let (ox_frac, oy_frac) = rs.transform_origin;
1605            let origin_x = pdf_x + element.width * ox_frac;
1606            // transform_origin's y is 0=top / 1=bottom in layout (CSS) space.
1607            // Flip for PDF (1=top / 0=bottom).
1608            let origin_y = pdf_y_bottom + (1.0 - oy_frac) * element.height;
1609
1610            let _ = writeln!(stream, "q");
1611            // Shift origin point to PDF (0,0) so subsequent transforms pivot there.
1612            let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", -origin_x, -origin_y);
1613            // User transforms: emit in REVERSE of the CSS list order. CSS lists
1614            // transforms left-to-right with the LAST one applied first
1615            // (closest to the point being drawn). PDF `cm` left-multiplies the
1616            // CTM, so the FIRST emitted cm becomes the innermost. Reversing
1617            // makes the leftmost CSS transform the last cm emitted = outermost
1618            // multiplication = applied last to a point — which matches "first
1619            // listed wraps everything inside it" semantics.
1620            for op in transform_ops.iter().rev() {
1621                match op {
1622                    TransformOp::Rotate { deg } => {
1623                        // CSS rotates clockwise in screen space. With PDF's
1624                        // flipped y-axis, the same matrix would rotate
1625                        // counter-clockwise visually. Negate the angle so a
1626                        // CSS `rotate(45deg)` looks identical in the PDF.
1627                        let theta = (-deg).to_radians();
1628                        let c = theta.cos();
1629                        let s = theta.sin();
1630                        let _ = writeln!(stream, "{:.6} {:.6} {:.6} {:.6} 0 0 cm", c, s, -s, c);
1631                    }
1632                    TransformOp::Scale { x, y } => {
1633                        let _ = writeln!(stream, "{:.6} 0 0 {:.6} 0 0 cm", x, y);
1634                    }
1635                    TransformOp::Translate { x, y } => {
1636                        // CSS y is down, PDF y is up — negate the y component.
1637                        let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", x, -y);
1638                    }
1639                }
1640            }
1641            // Shift origin back to its real position.
1642            let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", origin_x, origin_y);
1643        }
1644
1645        // PDF/UA-2: the element's own ink (a grouping element's borders or
1646        // background) is decoration under ISO 32005 — mark it /Artifact.
1647        // Children recurse OUTSIDE this bracket (below), so their tagged
1648        // content is never nested inside the artifact. Only the Rect and
1649        // None arms are reachable with the flag set: every graphics arm
1650        // maps to /Figure under UA-2 and takes the MCID path instead.
1651        let wrap_own_draw_as_artifact =
1652            artifact_own_draw && !matches!(element.draw, DrawCommand::None);
1653        if wrap_own_draw_as_artifact {
1654            let _ = writeln!(stream, "/Artifact BMC");
1655        }
1656
1657        match &element.draw {
1658            DrawCommand::None => {}
1659
1660            DrawCommand::Rect {
1661                background,
1662                border_width,
1663                border_color,
1664                border_style,
1665                border_radius,
1666                opacity,
1667                box_shadow,
1668                background_gradient,
1669            } => {
1670                let x = element.x;
1671                let y = page_height - element.y - element.height;
1672                let w = element.width;
1673                let h = element.height;
1674
1675                // Apply opacity via ExtGState
1676                let needs_opacity = *opacity < 1.0;
1677                if needs_opacity {
1678                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1679                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
1680                    }
1681                }
1682
1683                // Box shadow: paint a filled rect offset by (offsetX, offsetY)
1684                // BEFORE the background so the shadow sits behind. Shadow
1685                // color alpha goes through the per-shadow ExtGState. Shadow
1686                // path uses the same border_radius as the element so rounded
1687                // boxes get rounded shadows.
1688                if let Some(shadow) = box_shadow {
1689                    if shadow.color.a > 0.0 {
1690                        // PDF y-axis is flipped vs CSS, so a positive
1691                        // offsetY (CSS: shadow goes down) → subtract from
1692                        // pdf_y to move the shadow rect downward in
1693                        // visual terms.
1694                        let sx = x + shadow.offset_x;
1695                        let sy = y - shadow.offset_y;
1696                        let needs_shadow_alpha = shadow.color.a < 1.0;
1697                        if needs_shadow_alpha {
1698                            if let Some((_, gs_name)) =
1699                                builder.ext_gstate_map.get(&shadow.color.a.to_bits())
1700                            {
1701                                let _ = writeln!(stream, "q\n/{} gs", gs_name);
1702                            } else {
1703                                let _ = writeln!(stream, "q");
1704                            }
1705                        } else {
1706                            let _ = writeln!(stream, "q");
1707                        }
1708                        let _ = writeln!(
1709                            stream,
1710                            "{:.3} {:.3} {:.3} rg",
1711                            shadow.color.r, shadow.color.g, shadow.color.b
1712                        );
1713                        if border_radius.top_left > 0.0
1714                            || border_radius.top_right > 0.0
1715                            || border_radius.bottom_right > 0.0
1716                            || border_radius.bottom_left > 0.0
1717                        {
1718                            self.write_rounded_rect(stream, sx, sy, w, h, border_radius);
1719                        } else {
1720                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", sx, sy, w, h);
1721                        }
1722                        let _ = writeln!(stream, "f\nQ");
1723                    }
1724                }
1725
1726                // Background paint: gradient takes precedence over the
1727                // solid color when both are set. Gradient emission uses
1728                // `q + clip path + cm + sh + Q`; the cm translate moves
1729                // the shading's local 0,0 to the rect's bottom-left so
1730                // the Coords (computed during register_shadings) line up.
1731                if background_gradient.is_some() {
1732                    let key = (page_idx, *gradient_counter);
1733                    *gradient_counter += 1;
1734                    if let Some((_, sh_name)) = builder.shading_map.get(&key) {
1735                        let _ = writeln!(stream, "q");
1736                        // Clip to the rect (rounded if borderRadius set).
1737                        if border_radius.top_left > 0.0
1738                            || border_radius.top_right > 0.0
1739                            || border_radius.bottom_right > 0.0
1740                            || border_radius.bottom_left > 0.0
1741                        {
1742                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1743                            let _ = writeln!(stream, "W n");
1744                        } else {
1745                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re W n", x, y, w, h);
1746                        }
1747                        // Translate so the shading's local 0,0 sits at
1748                        // the rect's bottom-left.
1749                        let _ =
1750                            writeln!(stream, "1 0 0 1 {:.3} {:.3} cm\n/{} sh\nQ", x, y, sh_name);
1751                    }
1752                } else if let Some(bg) = background {
1753                    if bg.a > 0.0 {
1754                        let _ = writeln!(stream, "q\n{:.3} {:.3} {:.3} rg", bg.r, bg.g, bg.b);
1755
1756                        if border_radius.top_left > 0.0 {
1757                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1758                        } else {
1759                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
1760                        }
1761
1762                        let _ = writeln!(stream, "f\nQ");
1763                    }
1764                }
1765
1766                let bw = border_width;
1767                if bw.top > 0.0 || bw.right > 0.0 || bw.bottom > 0.0 || bw.left > 0.0 {
1768                    use crate::style::BorderStyle::Solid;
1769                    let all_solid = border_style.top == Solid
1770                        && border_style.right == Solid
1771                        && border_style.bottom == Solid
1772                        && border_style.left == Solid;
1773                    // The uniform fast path draws one rounded/plain rect stroke;
1774                    // it only applies to a solid, equal-width border. Any
1775                    // dashed/dotted or mixed-style border goes per-side (which
1776                    // also emits the dash pattern; radius is dropped there, per
1777                    // Chrome's own dashed-with-radius handling).
1778                    if all_solid
1779                        && (bw.top - bw.right).abs() < 0.001
1780                        && (bw.right - bw.bottom).abs() < 0.001
1781                        && (bw.bottom - bw.left).abs() < 0.001
1782                    {
1783                        let bc = &border_color.top;
1784                        let _ = writeln!(
1785                            stream,
1786                            "q\n{:.3} {:.3} {:.3} RG\n{:.2} w",
1787                            bc.r, bc.g, bc.b, bw.top
1788                        );
1789
1790                        if border_radius.top_left > 0.0 {
1791                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1792                        } else {
1793                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
1794                        }
1795
1796                        let _ = writeln!(stream, "S\nQ");
1797                    } else {
1798                        self.write_border_sides(stream, x, y, w, h, bw, border_color, border_style);
1799                    }
1800                }
1801
1802                if needs_opacity {
1803                    let _ = writeln!(stream, "Q");
1804                }
1805            }
1806
1807            DrawCommand::Text {
1808                lines,
1809                color,
1810                text_decoration,
1811                opacity,
1812            } => {
1813                // Apply opacity via ExtGState
1814                let needs_opacity = *opacity < 1.0;
1815                if needs_opacity {
1816                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1817                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
1818                    }
1819                }
1820
1821                for line in lines {
1822                    if line.glyphs.is_empty() {
1823                        continue;
1824                    }
1825
1826                    // Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
1827                    // to support multi-font text runs
1828                    let groups = Self::group_glyphs_by_style(&line.glyphs);
1829                    let pdf_y = page_height - line.y;
1830
1831                    let _ = writeln!(stream, "BT");
1832
1833                    // Set word spacing for justification (PDF Tw operator)
1834                    if line.word_spacing.abs() > 0.001 {
1835                        let _ = writeln!(stream, "{:.4} Tw", line.word_spacing);
1836                    }
1837
1838                    // Track current text matrix position for relative Td moves
1839                    let mut tm_x = 0.0_f64;
1840                    let mut tm_y = 0.0_f64;
1841                    let mut x_cursor = line.x;
1842
1843                    // Track group spans for per-group text decoration
1844                    let mut group_spans: Vec<(f64, f64, TextDecoration, Color)> = Vec::new();
1845
1846                    for group in &groups {
1847                        let first = &group[0];
1848                        let glyph_color = first.color.unwrap_or(*color);
1849
1850                        let idx = self.font_index(
1851                            &first.font_family,
1852                            first.font_weight,
1853                            first.font_style,
1854                            &builder.font_objects,
1855                        );
1856                        let italic =
1857                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
1858                        let font_key = FontKey {
1859                            family: first.font_family.to_string(),
1860                            weight: first.font_weight,
1861                            italic,
1862                        };
1863                        let font_name = format!("F{}", idx);
1864
1865                        // Td is relative to current text matrix position
1866                        let dx = x_cursor - tm_x;
1867                        let dy = pdf_y - tm_y;
1868                        let _ = writeln!(
1869                            stream,
1870                            "{:.3} {:.3} {:.3} rg\n/{} {:.1} Tf\n{:.2} Tc\n{:.2} {:.2} Td",
1871                            glyph_color.r,
1872                            glyph_color.g,
1873                            glyph_color.b,
1874                            font_name,
1875                            first.font_size,
1876                            first.letter_spacing,
1877                            dx,
1878                            dy
1879                        );
1880                        tm_x = x_cursor;
1881                        tm_y = pdf_y;
1882
1883                        // Check for page number sentinel characters
1884                        let raw_text: String = group.iter().map(|g| g.char_value).collect();
1885                        let has_placeholder = raw_text.contains(PAGE_NUMBER_SENTINEL)
1886                            || raw_text.contains(TOTAL_PAGES_SENTINEL);
1887
1888                        let is_custom = builder.custom_font_data.contains_key(&font_key);
1889
1890                        if is_custom {
1891                            if let Some(embed_data) = builder.custom_font_data.get(&font_key) {
1892                                let mut hex = String::new();
1893                                if has_placeholder {
1894                                    // Sentinel text: replace with actual values and use char→gid fallback
1895                                    let pn = PAGE_NUMBER_SENTINEL.to_string();
1896                                    let tp = TOTAL_PAGES_SENTINEL.to_string();
1897                                    let text_after = raw_text
1898                                        .replace(&pn, &page_number.to_string())
1899                                        .replace(&tp, &total_pages.to_string());
1900                                    for ch in text_after.chars() {
1901                                        let gid =
1902                                            embed_data.char_to_gid.get(&ch).copied().unwrap_or(0);
1903                                        let _ = write!(hex, "{:04X}", gid);
1904                                    }
1905                                } else {
1906                                    // Shaped text: use glyph IDs directly (remapped through subset)
1907                                    for g in group.iter() {
1908                                        let new_gid = embed_data
1909                                            .gid_remap
1910                                            .get(&g.glyph_id)
1911                                            .copied()
1912                                            .unwrap_or_else(|| {
1913                                                // Fallback: try char→gid
1914                                                embed_data
1915                                                    .char_to_gid
1916                                                    .get(&g.char_value)
1917                                                    .copied()
1918                                                    .unwrap_or(0)
1919                                            });
1920                                        let _ = write!(hex, "{:04X}", new_gid);
1921                                    }
1922                                }
1923                                let _ = writeln!(stream, "<{}> Tj", hex);
1924                            } else {
1925                                let _ = writeln!(stream, "<> Tj");
1926                            }
1927                        } else {
1928                            let pn = PAGE_NUMBER_SENTINEL.to_string();
1929                            let tp = TOTAL_PAGES_SENTINEL.to_string();
1930                            let text_after = raw_text
1931                                .replace(&pn, &page_number.to_string())
1932                                .replace(&tp, &total_pages.to_string());
1933                            let mut text_str = String::new();
1934                            for ch in text_after.chars() {
1935                                let b = Self::unicode_to_winansi(ch).unwrap_or_else(|| {
1936                                    builder.missing_glyphs.borrow_mut().insert(ch);
1937                                    b'?'
1938                                });
1939                                match b {
1940                                    b'\\' => text_str.push_str("\\\\"),
1941                                    b'(' => text_str.push_str("\\("),
1942                                    b')' => text_str.push_str("\\)"),
1943                                    0x20..=0x7E => text_str.push(b as char),
1944                                    _ => {
1945                                        let _ = write!(text_str, "\\{:03o}", b);
1946                                    }
1947                                }
1948                            }
1949                            let _ = writeln!(stream, "({}) Tj", text_str);
1950                        }
1951
1952                        // Record span for per-group text decoration
1953                        let group_start_x = x_cursor;
1954
1955                        // Advance x_cursor past this group using shaped advances
1956                        // Account for word_spacing on spaces (Tw adds to each space char)
1957                        if let Some(last) = group.last() {
1958                            let space_count_in_group =
1959                                group.iter().filter(|g| g.char_value == ' ').count();
1960                            x_cursor = line.x
1961                                + last.x_offset
1962                                + last.x_advance
1963                                + space_count_in_group as f64 * line.word_spacing;
1964                        }
1965
1966                        // Check if this group has text decoration
1967                        let group_dec = first.text_decoration;
1968                        if !matches!(group_dec, TextDecoration::None) {
1969                            group_spans.push((group_start_x, x_cursor, group_dec, glyph_color));
1970                        }
1971                    }
1972
1973                    let _ = writeln!(stream, "ET");
1974
1975                    // Draw per-group text decorations
1976                    for (span_x, span_end_x, dec, dec_color) in &group_spans {
1977                        match dec {
1978                            TextDecoration::Underline => {
1979                                let underline_y = pdf_y - 1.5;
1980                                let _ = write!(
1981                                    stream,
1982                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1983                                    dec_color.r, dec_color.g, dec_color.b,
1984                                    span_x, underline_y,
1985                                    span_end_x, underline_y
1986                                );
1987                            }
1988                            TextDecoration::LineThrough => {
1989                                let first_size =
1990                                    line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
1991                                let strikethrough_y = pdf_y + first_size * 0.3;
1992                                let _ = write!(
1993                                    stream,
1994                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1995                                    dec_color.r, dec_color.g, dec_color.b,
1996                                    span_x, strikethrough_y,
1997                                    span_end_x, strikethrough_y
1998                                );
1999                            }
2000                            TextDecoration::None => {}
2001                        }
2002                    }
2003
2004                    // Also handle whole-line decoration from parent style
2005                    if group_spans.is_empty() {
2006                        if matches!(text_decoration, TextDecoration::Underline) {
2007                            let underline_y = pdf_y - 1.5;
2008                            let _ = write!(
2009                                stream,
2010                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2011                                color.r, color.g, color.b,
2012                                line.x, underline_y,
2013                                line.x + line.width, underline_y
2014                            );
2015                        }
2016                        if matches!(text_decoration, TextDecoration::LineThrough) {
2017                            let first_size =
2018                                line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
2019                            let strikethrough_y = pdf_y + first_size * 0.3;
2020                            let _ = write!(
2021                                stream,
2022                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2023                                color.r, color.g, color.b,
2024                                line.x, strikethrough_y,
2025                                line.x + line.width, strikethrough_y
2026                            );
2027                        }
2028                    }
2029                }
2030
2031                if needs_opacity {
2032                    let _ = writeln!(stream, "Q");
2033                }
2034            }
2035
2036            DrawCommand::Image { .. } => {
2037                let elem_idx = *element_counter;
2038                *element_counter += 1;
2039                if let Some(&img_idx) = builder.image_index_map.get(&(page_idx, elem_idx)) {
2040                    let x = element.x;
2041                    let y = page_height - element.y - element.height;
2042                    let _ = write!(
2043                        stream,
2044                        "q\n{:.4} 0 0 {:.4} {:.2} {:.2} cm\n/Im{} Do\nQ\n",
2045                        element.width, element.height, x, y, img_idx
2046                    );
2047                } else {
2048                    // Fallback: grey placeholder if image index not found
2049                    let x = element.x;
2050                    let y = page_height - element.y - element.height;
2051                    let _ = write!(
2052                        stream,
2053                        "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
2054                        x, y, element.width, element.height
2055                    );
2056                }
2057                if tagged_mcid.is_some() {
2058                    let _ = writeln!(stream, "EMC");
2059                    if let Some(ref mut tb) = tag_builder {
2060                        tb.end_element();
2061                    }
2062                } else if is_artifact {
2063                    let _ = writeln!(stream, "EMC");
2064                } else if wrap_own_draw_as_artifact {
2065                    // Unreachable today (graphics arms map to /Figure under
2066                    // UA-2), but if a forbidden-content role ever gained a
2067                    // graphics draw, close its /Artifact bracket and element.
2068                    let _ = writeln!(stream, "EMC");
2069                    if let Some(ref mut tb) = tag_builder {
2070                        tb.end_element();
2071                    }
2072                }
2073                return; // Don't increment counter again for children
2074            }
2075
2076            DrawCommand::ImagePlaceholder => {
2077                *element_counter += 1;
2078                let x = element.x;
2079                let y = page_height - element.y - element.height;
2080                let _ = write!(
2081                    stream,
2082                    "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
2083                    x, y, element.width, element.height
2084                );
2085                if tagged_mcid.is_some() {
2086                    let _ = writeln!(stream, "EMC");
2087                    if let Some(ref mut tb) = tag_builder {
2088                        tb.end_element();
2089                    }
2090                } else if is_artifact {
2091                    let _ = writeln!(stream, "EMC");
2092                } else if wrap_own_draw_as_artifact {
2093                    // Unreachable today (graphics arms map to /Figure under
2094                    // UA-2), but if a forbidden-content role ever gained a
2095                    // graphics draw, close its /Artifact bracket and element.
2096                    let _ = writeln!(stream, "EMC");
2097                    if let Some(ref mut tb) = tag_builder {
2098                        tb.end_element();
2099                    }
2100                }
2101                return;
2102            }
2103
2104            DrawCommand::Svg {
2105                commands,
2106                width: _svg_w,
2107                height: _svg_h,
2108                viewbox_min_x,
2109                viewbox_min_y,
2110                viewbox_width,
2111                viewbox_height,
2112                clip,
2113            } => {
2114                let x = element.x;
2115                let y = page_height - element.y - element.height;
2116
2117                // Save state, translate to position
2118                let _ = writeln!(stream, "q");
2119                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", x, y);
2120
2121                // SVG viewport algorithm with `xMidYMid meet` as the default
2122                // preserveAspectRatio: uniform scale to fit, center the
2123                // remainder. When viewBox matches the display box (the
2124                // no-viewBox case, populated as 0/0/w/h in layout) the scale
2125                // is 1 and the translate is 0 — behavior unchanged.
2126                if *viewbox_width > 0.0 && *viewbox_height > 0.0 {
2127                    let raw_sx = element.width / *viewbox_width;
2128                    let raw_sy = element.height / *viewbox_height;
2129                    let s = raw_sx.min(raw_sy);
2130                    let tx = (element.width - s * *viewbox_width) / 2.0;
2131                    let ty = (element.height - s * *viewbox_height) / 2.0;
2132                    let _ = writeln!(stream, "{:.4} 0 0 {:.4} {:.2} {:.2} cm", s, s, tx, ty);
2133                }
2134
2135                // Flip Y so SVG-coord Y-down becomes PDF Y-up. The flip
2136                // height is the viewBox height (we're now in viewBox space).
2137                let _ = writeln!(stream, "1 0 0 -1 0 {:.2} cm", *viewbox_height);
2138
2139                // Shift origin so the viewBox's (min_x, min_y) lands at (0, 0).
2140                if *viewbox_min_x != 0.0 || *viewbox_min_y != 0.0 {
2141                    let _ = writeln!(
2142                        stream,
2143                        "1 0 0 1 {:.2} {:.2} cm",
2144                        -*viewbox_min_x, -*viewbox_min_y
2145                    );
2146                }
2147
2148                // Clip to viewBox bounds (Canvas always clips, SVG does not).
2149                if *clip {
2150                    let _ = writeln!(
2151                        stream,
2152                        "{:.2} {:.2} {:.2} {:.2} re W n",
2153                        *viewbox_min_x, *viewbox_min_y, *viewbox_width, *viewbox_height
2154                    );
2155                }
2156
2157                Self::write_svg_commands(stream, commands, &builder.ext_gstate_map);
2158
2159                let _ = writeln!(stream, "Q");
2160                if tagged_mcid.is_some() {
2161                    let _ = writeln!(stream, "EMC");
2162                    if let Some(ref mut tb) = tag_builder {
2163                        tb.end_element();
2164                    }
2165                } else if is_artifact {
2166                    let _ = writeln!(stream, "EMC");
2167                } else if wrap_own_draw_as_artifact {
2168                    // Unreachable today (graphics arms map to /Figure under
2169                    // UA-2), but if a forbidden-content role ever gained a
2170                    // graphics draw, close its /Artifact bracket and element.
2171                    let _ = writeln!(stream, "EMC");
2172                    if let Some(ref mut tb) = tag_builder {
2173                        tb.end_element();
2174                    }
2175                }
2176                return;
2177            }
2178
2179            DrawCommand::Barcode {
2180                bars,
2181                bar_width,
2182                height,
2183                color,
2184            } => {
2185                *element_counter += 1;
2186                let _ = writeln!(stream, "q");
2187                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2188                for (i, &bar) in bars.iter().enumerate() {
2189                    if bar == 1 {
2190                        let bx = element.x + i as f64 * bar_width;
2191                        let by = page_height - element.y - height;
2192                        let _ = writeln!(
2193                            stream,
2194                            "{:.2} {:.2} {:.2} {:.2} re",
2195                            bx, by, bar_width, height
2196                        );
2197                    }
2198                }
2199                let _ = writeln!(stream, "f\nQ");
2200                if tagged_mcid.is_some() {
2201                    let _ = writeln!(stream, "EMC");
2202                    if let Some(ref mut tb) = tag_builder {
2203                        tb.end_element();
2204                    }
2205                } else if is_artifact {
2206                    let _ = writeln!(stream, "EMC");
2207                } else if wrap_own_draw_as_artifact {
2208                    // Unreachable today (graphics arms map to /Figure under
2209                    // UA-2), but if a forbidden-content role ever gained a
2210                    // graphics draw, close its /Artifact bracket and element.
2211                    let _ = writeln!(stream, "EMC");
2212                    if let Some(ref mut tb) = tag_builder {
2213                        tb.end_element();
2214                    }
2215                }
2216                return;
2217            }
2218
2219            DrawCommand::QrCode {
2220                modules,
2221                module_size,
2222                color,
2223            } => {
2224                *element_counter += 1;
2225                let _ = writeln!(stream, "q");
2226                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2227                for (row_idx, row) in modules.iter().enumerate() {
2228                    for (col_idx, &dark) in row.iter().enumerate() {
2229                        if dark {
2230                            let mx = element.x + col_idx as f64 * module_size;
2231                            let my = page_height - element.y - (row_idx as f64 + 1.0) * module_size;
2232                            let _ = writeln!(
2233                                stream,
2234                                "{:.2} {:.2} {:.2} {:.2} re",
2235                                mx, my, module_size, module_size
2236                            );
2237                        }
2238                    }
2239                }
2240                let _ = writeln!(stream, "f\nQ");
2241                if tagged_mcid.is_some() {
2242                    let _ = writeln!(stream, "EMC");
2243                    if let Some(ref mut tb) = tag_builder {
2244                        tb.end_element();
2245                    }
2246                } else if is_artifact {
2247                    let _ = writeln!(stream, "EMC");
2248                } else if wrap_own_draw_as_artifact {
2249                    // Unreachable today (graphics arms map to /Figure under
2250                    // UA-2), but if a forbidden-content role ever gained a
2251                    // graphics draw, close its /Artifact bracket and element.
2252                    let _ = writeln!(stream, "EMC");
2253                    if let Some(ref mut tb) = tag_builder {
2254                        tb.end_element();
2255                    }
2256                }
2257                return;
2258            }
2259
2260            DrawCommand::Chart { primitives } => {
2261                *element_counter += 1;
2262                let _ = writeln!(stream, "q");
2263                // Set up coordinate transform: Y-flip so chart primitives use top-left origin
2264                let _ = writeln!(
2265                    stream,
2266                    "1 0 0 -1 {:.4} {:.4} cm",
2267                    element.x,
2268                    page_height - element.y
2269                );
2270
2271                for prim in primitives {
2272                    write_chart_primitive(stream, prim, element.height, builder);
2273                }
2274
2275                let _ = writeln!(stream, "Q");
2276                if tagged_mcid.is_some() {
2277                    let _ = writeln!(stream, "EMC");
2278                    if let Some(ref mut tb) = tag_builder {
2279                        tb.end_element();
2280                    }
2281                } else if is_artifact {
2282                    let _ = writeln!(stream, "EMC");
2283                } else if wrap_own_draw_as_artifact {
2284                    // Unreachable today (graphics arms map to /Figure under
2285                    // UA-2), but if a forbidden-content role ever gained a
2286                    // graphics draw, close its /Artifact bracket and element.
2287                    let _ = writeln!(stream, "EMC");
2288                    if let Some(ref mut tb) = tag_builder {
2289                        tb.end_element();
2290                    }
2291                }
2292                return;
2293            }
2294
2295            DrawCommand::Watermark {
2296                lines,
2297                color,
2298                opacity,
2299                angle_rad,
2300                font_family: _,
2301            } => {
2302                let _ = writeln!(stream, "q");
2303                // Set opacity via ExtGState if not fully opaque
2304                if *opacity < 1.0 {
2305                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
2306                        let _ = writeln!(stream, "/{} gs", gs_name);
2307                    }
2308                }
2309                // Translate to center position (element.x, element.y = page center)
2310                let pdf_cx = element.x;
2311                let pdf_cy = page_height - element.y;
2312                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", pdf_cx, pdf_cy);
2313                // Rotate by angle
2314                let cos_a = angle_rad.cos();
2315                let sin_a = angle_rad.sin();
2316                let _ = writeln!(
2317                    stream,
2318                    "{:.6} {:.6} {:.6} {:.6} 0 0 cm",
2319                    cos_a, sin_a, -sin_a, cos_a
2320                );
2321                // Render text centered on origin
2322                let _ = writeln!(stream, "BT");
2323                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2324                if let Some(line) = lines.first() {
2325                    let groups = Self::group_glyphs_by_style(&line.glyphs);
2326                    let text_width = line.width;
2327                    let cap_height = line.height * 0.7;
2328                    let _ = writeln!(
2329                        stream,
2330                        "{:.2} {:.2} Td",
2331                        -text_width / 2.0,
2332                        -cap_height / 2.0
2333                    );
2334                    for group in &groups {
2335                        let first = &group[0];
2336                        let italic =
2337                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
2338                        let fk = FontKey {
2339                            family: first.font_family.to_string(),
2340                            weight: first.font_weight,
2341                            italic,
2342                        };
2343                        let idx = self.font_index(
2344                            &first.font_family,
2345                            first.font_weight,
2346                            first.font_style,
2347                            &builder.font_objects,
2348                        );
2349                        let font_name = format!("F{}", idx);
2350                        let _ = writeln!(stream, "/{} {:.1} Tf", font_name, first.font_size);
2351                        let is_custom = builder.custom_font_data.contains_key(&fk);
2352                        if is_custom {
2353                            if let Some(embed_data) = builder.custom_font_data.get(&fk) {
2354                                let mut hex = String::new();
2355                                for g in group.iter() {
2356                                    let gid =
2357                                        embed_data.gid_remap.get(&g.glyph_id).copied().unwrap_or(0);
2358                                    let _ = write!(hex, "{:04X}", gid);
2359                                }
2360                                let _ = writeln!(stream, "<{}> Tj", hex);
2361                            }
2362                        } else {
2363                            let hex_str: String = group
2364                                .iter()
2365                                .map(|g| format!("{:02X}", g.glyph_id as u8))
2366                                .collect();
2367                            let _ = writeln!(stream, "<{}> Tj", hex_str);
2368                        }
2369                    }
2370                }
2371                let _ = writeln!(stream, "ET");
2372                let _ = writeln!(stream, "Q");
2373                if tagged_mcid.is_some() {
2374                    let _ = writeln!(stream, "EMC");
2375                    if let Some(ref mut tb) = tag_builder {
2376                        tb.end_element();
2377                    }
2378                } else if is_artifact {
2379                    let _ = writeln!(stream, "EMC");
2380                } else if wrap_own_draw_as_artifact {
2381                    // Unreachable today (graphics arms map to /Figure under
2382                    // UA-2), but if a forbidden-content role ever gained a
2383                    // graphics draw, close its /Artifact bracket and element.
2384                    let _ = writeln!(stream, "EMC");
2385                    if let Some(ref mut tb) = tag_builder {
2386                        tb.end_element();
2387                    }
2388                }
2389                return;
2390            }
2391
2392            DrawCommand::FormField { field_type, .. } => {
2393                // Draw a visual placeholder so form fields are visible in previews
2394                // and non-form-aware viewers. When flatten_forms is true, also render
2395                // the field value as static text and skip interactive widgets.
2396                let pdf_x = element.x;
2397                let pdf_y = page_height - element.y - element.height;
2398                let w = element.width;
2399                let h = element.height;
2400                let _ = writeln!(stream, "q");
2401                match field_type {
2402                    FormFieldType::Checkbox { checked, .. } => {
2403                        // Draw a border square
2404                        let _ = writeln!(stream, "0.6 0.6 0.6 RG"); // grey stroke
2405                        let _ = writeln!(stream, "0.5 w");
2406                        let _ =
2407                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re S", pdf_x, pdf_y, w, h);
2408                        if *checked {
2409                            // Draw a checkmark scaled to field dimensions
2410                            let _ = writeln!(stream, "0.2 0.2 0.2 rg");
2411                            let sx = w / 14.0;
2412                            let sy = h / 14.0;
2413                            let _ = writeln!(
2414                                stream,
2415                                "{:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l f",
2416                                pdf_x + 2.0 * sx, pdf_y + 6.0 * sy,
2417                                pdf_x + 5.5 * sx, pdf_y + 2.0 * sy,
2418                                pdf_x + 12.0 * sx, pdf_y + 11.0 * sy,
2419                                pdf_x + 11.0 * sx, pdf_y + 12.0 * sy,
2420                                pdf_x + 5.5 * sx, pdf_y + 4.5 * sy,
2421                                pdf_x + 3.0 * sx, pdf_y + 7.0 * sy,
2422                                pdf_x + 2.0 * sx, pdf_y + 6.0 * sy,
2423                            );
2424                        }
2425                    }
2426                    FormFieldType::RadioButton { checked, .. } => {
2427                        // Draw a border square
2428                        let _ = writeln!(stream, "0.6 0.6 0.6 RG"); // grey stroke
2429                        let _ = writeln!(stream, "0.5 w");
2430                        let _ =
2431                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re S", pdf_x, pdf_y, w, h);
2432                        if *checked {
2433                            // Draw a filled circle
2434                            let cx = pdf_x + w / 2.0;
2435                            let cy = pdf_y + h / 2.0;
2436                            let r = (w.min(h) / 2.0) * 0.6;
2437                            let k = r * 0.5523;
2438                            let _ = writeln!(stream, "0.2 0.2 0.2 rg");
2439                            let _ = writeln!(
2440                                stream,
2441                                "{:.2} {:.2} m {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c {:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c f",
2442                                cx, cy + r,
2443                                cx + k, cy + r, cx + r, cy + k, cx + r, cy,
2444                                cx + r, cy - k, cx + k, cy - r, cx, cy - r,
2445                                cx - k, cy - r, cx - r, cy - k, cx - r, cy,
2446                                cx - r, cy + k, cx - k, cy + r, cx, cy + r,
2447                            );
2448                        }
2449                    }
2450                    FormFieldType::TextField {
2451                        value,
2452                        placeholder,
2453                        font_size,
2454                        multiline,
2455                        password,
2456                        ..
2457                    } => {
2458                        // White fill + grey border
2459                        let _ = writeln!(stream, "1 1 1 rg");
2460                        let _ = writeln!(stream, "0.6 0.6 0.6 RG");
2461                        let _ = writeln!(stream, "0.5 w");
2462                        let _ =
2463                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re B", pdf_x, pdf_y, w, h);
2464                        // Render value text when flattening
2465                        if flatten_forms {
2466                            let has_value = value.as_ref().is_some_and(|v| !v.is_empty());
2467                            if has_value {
2468                                let val = value.as_ref().unwrap();
2469                                let display_text = if *password {
2470                                    "\u{2022}".repeat(val.len())
2471                                } else {
2472                                    val.clone()
2473                                };
2474                                let font_idx = builder
2475                                    .font_objects
2476                                    .iter()
2477                                    .enumerate()
2478                                    .find(|(_, (key, _))| {
2479                                        key.family == "Helvetica"
2480                                            && key.weight == 400
2481                                            && !key.italic
2482                                    })
2483                                    .map(|(i, _)| i)
2484                                    .unwrap_or(0);
2485                                if *multiline {
2486                                    // Simple word-wrap for multiline
2487                                    let metrics = crate::font::StandardFont::Helvetica.metrics();
2488                                    let max_w = w - 4.0;
2489                                    let mut lines: Vec<String> = Vec::new();
2490                                    for paragraph in display_text.split('\n') {
2491                                        let mut line = String::new();
2492                                        let mut line_w = 0.0;
2493                                        for word in paragraph.split_whitespace() {
2494                                            let word_w =
2495                                                metrics.measure_string(word, *font_size, 0.0);
2496                                            let space_w = if line.is_empty() {
2497                                                0.0
2498                                            } else {
2499                                                metrics.measure_string(" ", *font_size, 0.0)
2500                                            };
2501                                            // Word wider than field — break at character boundary
2502                                            if word_w > max_w {
2503                                                let mut char_line = String::new();
2504                                                let mut char_w = 0.0;
2505                                                for ch in word.chars() {
2506                                                    let cw = metrics.char_width(ch, *font_size);
2507                                                    if !char_line.is_empty() && char_w + cw > max_w
2508                                                    {
2509                                                        if !line.is_empty() {
2510                                                            lines.push(line.clone());
2511                                                            line.clear();
2512                                                            line_w = 0.0;
2513                                                        }
2514                                                        lines.push(char_line.clone());
2515                                                        char_line.clear();
2516                                                        char_w = 0.0;
2517                                                    }
2518                                                    char_line.push(ch);
2519                                                    char_w += cw;
2520                                                }
2521                                                // Remaining chars join the current line
2522                                                if !char_line.is_empty() {
2523                                                    if !line.is_empty() {
2524                                                        line.push(' ');
2525                                                        line_w += metrics
2526                                                            .measure_string(" ", *font_size, 0.0);
2527                                                    }
2528                                                    line.push_str(&char_line);
2529                                                    line_w += char_w;
2530                                                }
2531                                                continue;
2532                                            }
2533                                            if !line.is_empty() && line_w + space_w + word_w > max_w
2534                                            {
2535                                                lines.push(line.clone());
2536                                                line.clear();
2537                                                line_w = 0.0;
2538                                            }
2539                                            if !line.is_empty() {
2540                                                line.push(' ');
2541                                                line_w += space_w;
2542                                            }
2543                                            line.push_str(word);
2544                                            line_w += word_w;
2545                                        }
2546                                        if !line.is_empty() {
2547                                            lines.push(line);
2548                                        }
2549                                    }
2550                                    let text_y = pdf_y + h - font_size - 2.0;
2551                                    for (i, line_text) in lines.iter().enumerate() {
2552                                        let ly = text_y - (i as f64) * (font_size * 1.2);
2553                                        if ly < pdf_y {
2554                                            break;
2555                                        }
2556                                        let esc = Self::encode_winansi_text(builder, line_text);
2557                                        let _ = writeln!(
2558                                            stream,
2559                                            "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2560                                            font_idx,
2561                                            font_size,
2562                                            pdf_x + 2.0,
2563                                            ly,
2564                                            esc
2565                                        );
2566                                    }
2567                                } else {
2568                                    let escaped = Self::encode_winansi_text(builder, &display_text);
2569                                    let text_y = pdf_y + (h - font_size) / 2.0;
2570                                    let _ = writeln!(
2571                                        stream,
2572                                        "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2573                                        font_idx,
2574                                        font_size,
2575                                        pdf_x + 2.0,
2576                                        text_y,
2577                                        escaped
2578                                    );
2579                                }
2580                            } else if let Some(ref ph) = placeholder {
2581                                if !ph.is_empty() {
2582                                    // Render placeholder in grey
2583                                    let font_idx = builder
2584                                        .font_objects
2585                                        .iter()
2586                                        .enumerate()
2587                                        .find(|(_, (key, _))| {
2588                                            key.family == "Helvetica"
2589                                                && key.weight == 400
2590                                                && !key.italic
2591                                        })
2592                                        .map(|(i, _)| i)
2593                                        .unwrap_or(0);
2594                                    let escaped = Self::encode_winansi_text(builder, ph);
2595                                    let text_y = pdf_y + (h - font_size) / 2.0;
2596                                    let _ = writeln!(
2597                                        stream,
2598                                        "BT /F{} {:.1} Tf 0.6 g {:.2} {:.2} Td ({}) Tj ET",
2599                                        font_idx,
2600                                        font_size,
2601                                        pdf_x + 2.0,
2602                                        text_y,
2603                                        escaped
2604                                    );
2605                                }
2606                            }
2607                        }
2608                    }
2609                    FormFieldType::Dropdown {
2610                        value, font_size, ..
2611                    } => {
2612                        // White fill + grey border
2613                        let _ = writeln!(stream, "1 1 1 rg");
2614                        let _ = writeln!(stream, "0.6 0.6 0.6 RG");
2615                        let _ = writeln!(stream, "0.5 w");
2616                        let _ =
2617                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re B", pdf_x, pdf_y, w, h);
2618                        // Render selected value text when flattening
2619                        if flatten_forms {
2620                            if let Some(ref val) = value {
2621                                if !val.is_empty() {
2622                                    let font_idx = builder
2623                                        .font_objects
2624                                        .iter()
2625                                        .enumerate()
2626                                        .find(|(_, (key, _))| {
2627                                            key.family == "Helvetica"
2628                                                && key.weight == 400
2629                                                && !key.italic
2630                                        })
2631                                        .map(|(i, _)| i)
2632                                        .unwrap_or(0);
2633                                    let escaped = Self::encode_winansi_text(builder, val);
2634                                    let text_y = pdf_y + (h - font_size) / 2.0;
2635                                    let _ = writeln!(
2636                                        stream,
2637                                        "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2638                                        font_idx,
2639                                        font_size,
2640                                        pdf_x + 2.0,
2641                                        text_y,
2642                                        escaped
2643                                    );
2644                                }
2645                            }
2646                        }
2647                    }
2648                }
2649                let _ = writeln!(stream, "Q");
2650            }
2651        }
2652
2653        // Close the /Artifact bracket around the element's own ink (opened
2654        // before the draw match) — children below stay outside it.
2655        if wrap_own_draw_as_artifact {
2656            let _ = writeln!(stream, "EMC");
2657        }
2658
2659        // Overflow clipping: wrap children in q/clip/Q when overflow is Hidden.
2660        // When the element's Rect has a non-zero border_radius, clip to the
2661        // rounded path so descendants don't visually overflow the rounded
2662        // corners. Plain rectangular clip otherwise.
2663        let clip_overflow = matches!(element.overflow, Overflow::Hidden);
2664        if clip_overflow {
2665            let clip_x = element.x;
2666            let clip_y = page_height - element.y - element.height;
2667            let clip_w = element.width;
2668            let clip_h = element.height;
2669            // Pull border_radius from the Rect DrawCommand if present.
2670            // Other element kinds (Text, Image, Svg, ...) don't carry a
2671            // border_radius — they fall back to a rectangular clip.
2672            let radius = if let DrawCommand::Rect { border_radius, .. } = &element.draw {
2673                Some(border_radius)
2674            } else {
2675                None
2676            };
2677            let has_rounded_corners = radius.is_some_and(|r| {
2678                r.top_left > 0.0 || r.top_right > 0.0 || r.bottom_right > 0.0 || r.bottom_left > 0.0
2679            });
2680            let _ = writeln!(stream, "q");
2681            if has_rounded_corners {
2682                self.write_rounded_rect(stream, clip_x, clip_y, clip_w, clip_h, radius.unwrap());
2683                let _ = writeln!(stream, "W n");
2684            } else {
2685                let _ = writeln!(
2686                    stream,
2687                    "{:.2} {:.2} {:.2} {:.2} re W n",
2688                    clip_x, clip_y, clip_w, clip_h
2689                );
2690            }
2691        }
2692
2693        for child in &element.children {
2694            self.write_element(
2695                stream,
2696                child,
2697                page_height,
2698                builder,
2699                page_idx,
2700                element_counter,
2701                gradient_counter,
2702                page_number,
2703                total_pages,
2704                tag_builder.as_deref_mut(),
2705                flatten_forms,
2706            );
2707        }
2708
2709        if clip_overflow {
2710            let _ = writeln!(stream, "Q");
2711        }
2712
2713        // Close the transform wrap (paired with the inner q above).
2714        if has_transform {
2715            let _ = writeln!(stream, "Q");
2716        }
2717
2718        // Close the element-level opacity wrap (paired with the q above).
2719        // Goes before EMC so the marker boundary is preserved.
2720        if needs_element_opacity {
2721            let _ = writeln!(stream, "Q");
2722        }
2723
2724        // Tagged PDF: emit EMC (end marked content)
2725        if tagged_mcid.is_some() {
2726            let _ = writeln!(stream, "EMC");
2727            if let Some(ref mut tb) = tag_builder {
2728                tb.end_element();
2729            }
2730        } else if is_artifact {
2731            let _ = writeln!(stream, "EMC");
2732        } else if artifact_own_draw {
2733            // The element opened a structure entry but no marked content
2734            // (PDF/UA-2 forbidden-content role) — close just the element.
2735            if let Some(ref mut tb) = tag_builder {
2736                tb.end_element();
2737            }
2738        }
2739    }
2740
2741    fn write_rounded_rect(
2742        &self,
2743        stream: &mut String,
2744        x: f64,
2745        y: f64,
2746        w: f64,
2747        h: f64,
2748        r: &crate::style::CornerValues,
2749    ) {
2750        let k = 0.5522847498;
2751
2752        let tl = r.top_left.min(w / 2.0).min(h / 2.0);
2753        let tr = r.top_right.min(w / 2.0).min(h / 2.0);
2754        let br = r.bottom_right.min(w / 2.0).min(h / 2.0);
2755        let bl = r.bottom_left.min(w / 2.0).min(h / 2.0);
2756
2757        let _ = writeln!(stream, "{:.2} {:.2} m", x + bl, y);
2758
2759        let _ = writeln!(stream, "{:.2} {:.2} l", x + w - br, y);
2760        if br > 0.0 {
2761            let _ = writeln!(
2762                stream,
2763                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2764                x + w - br + br * k,
2765                y,
2766                x + w,
2767                y + br - br * k,
2768                x + w,
2769                y + br
2770            );
2771        }
2772
2773        let _ = writeln!(stream, "{:.2} {:.2} l", x + w, y + h - tr);
2774        if tr > 0.0 {
2775            let _ = writeln!(
2776                stream,
2777                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2778                x + w,
2779                y + h - tr + tr * k,
2780                x + w - tr + tr * k,
2781                y + h,
2782                x + w - tr,
2783                y + h
2784            );
2785        }
2786
2787        let _ = writeln!(stream, "{:.2} {:.2} l", x + tl, y + h);
2788        if tl > 0.0 {
2789            let _ = writeln!(
2790                stream,
2791                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2792                x + tl - tl * k,
2793                y + h,
2794                x,
2795                y + h - tl + tl * k,
2796                x,
2797                y + h - tl
2798            );
2799        }
2800
2801        let _ = writeln!(stream, "{:.2} {:.2} l", x, y + bl);
2802        if bl > 0.0 {
2803            let _ = writeln!(
2804                stream,
2805                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2806                x,
2807                y + bl - bl * k,
2808                x + bl - bl * k,
2809                y,
2810                x + bl,
2811                y
2812            );
2813        }
2814
2815        let _ = writeln!(stream, "h");
2816    }
2817
2818    #[allow(clippy::too_many_arguments)]
2819    fn write_border_sides(
2820        &self,
2821        stream: &mut String,
2822        x: f64,
2823        y: f64,
2824        w: f64,
2825        h: f64,
2826        bw: &Edges,
2827        bc: &crate::style::EdgeValues<Color>,
2828        bs: &crate::style::EdgeValues<crate::style::BorderStyle>,
2829    ) {
2830        // PDF dash + line-cap ops for a side, calibrated against Chrome:
2831        //   dashed → dash 2×width, gap 1×width (butt cap)
2832        //   dotted → round-capped dots, diameter 1×width, 2×width centre spacing
2833        // Each side is wrapped in q/Q so the graphics state (cap, dash) resets.
2834        fn dash_ops(style: crate::style::BorderStyle, width: f64) -> String {
2835            use crate::style::BorderStyle::*;
2836            match style {
2837                Solid => String::new(),
2838                Dashed => format!("[{:.2} {:.2}] 0 d\n", width * 2.0, width),
2839                Dotted => format!("1 J\n[0 {:.2}] 0 d\n", width * 2.0),
2840            }
2841        }
2842        // side: (color, width, style, x0,y0, x1,y1)
2843        let sides = [
2844            (bc.top, bw.top, bs.top, x, y + h, x + w, y + h),
2845            (bc.bottom, bw.bottom, bs.bottom, x, y, x + w, y),
2846            (bc.left, bw.left, bs.left, x, y, x, y + h),
2847            (bc.right, bw.right, bs.right, x + w, y, x + w, y + h),
2848        ];
2849        for (color, width, style, x0, y0, x1, y1) in sides {
2850            if width <= 0.0 {
2851                continue;
2852            }
2853            let _ = write!(
2854                stream,
2855                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{}{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2856                color.r,
2857                color.g,
2858                color.b,
2859                width,
2860                dash_ops(style, width),
2861                x0,
2862                y0,
2863                x1,
2864                y1
2865            );
2866        }
2867    }
2868
2869    /// Register fonts used across all pages — each unique (family, weight, italic)
2870    /// combination gets its own PDF font object.
2871    /// pdfUa: embed a metric-compatible substitute (Liberation, via
2872    /// `@formepdf/fonts-standard`) for a base-14 font, as a SIMPLE TrueType
2873    /// font carrying the base-14 AFM `/Widths` and WinAnsiEncoding. Because the
2874    /// widths, encoding, and font key are unchanged, the content stream is
2875    /// byte-identical to the non-embedded base-14 path — only the font
2876    /// dictionary gains an embedded program, so text positions are exact by
2877    /// construction. Returns `false` (caller emits the non-embedded base-14)
2878    /// when there is no metric-compatible substitute (Symbol/ZapfDingbats) or
2879    /// `@formepdf/fonts-standard` is not registered.
2880    fn emit_pdfua_embedded_standard(
2881        builder: &mut PdfBuilder,
2882        key: &FontKey,
2883        std_font: &crate::font::StandardFont,
2884        metrics: &crate::font::StandardFontMetrics,
2885        font_context: &FontContext,
2886    ) -> bool {
2887        let lib_family = match std_font.liberation_family() {
2888            Some(f) => f,
2889            None => return false, // Symbol / ZapfDingbats — no substitute
2890        };
2891        // The substitute must have been registered (fonts-standard) — otherwise
2892        // it resolves back to a Standard font and there is nothing to embed.
2893        let lib_bytes: &[u8] = match font_context.resolve(lib_family, key.weight, key.italic) {
2894            FontData::Custom { data, .. } => data,
2895            FontData::Standard(_) => return false,
2896        };
2897        let face = match ttf_parser::Face::parse(lib_bytes, 0) {
2898            Ok(f) => f,
2899            Err(_) => return false,
2900        };
2901        let scale = 1000.0 / face.units_per_em() as f64;
2902        let bbox = face.global_bounding_box();
2903        let pdf_name = Self::sanitize_font_name(lib_family, key.weight, key.italic);
2904
2905        // 1. FontFile2 — the full Liberation program, zlib-compressed.
2906        let compressed = compress_to_vec_zlib(lib_bytes, 6);
2907        let fontfile2_id = builder.objects.len();
2908        let mut ff2: Vec<u8> = Vec::new();
2909        let _ = write!(
2910            ff2,
2911            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
2912            compressed.len(),
2913            lib_bytes.len()
2914        );
2915        ff2.extend_from_slice(&compressed);
2916        ff2.extend_from_slice(b"\nendstream");
2917        builder.objects.push(PdfObject {
2918            id: fontfile2_id,
2919            data: ff2,
2920        });
2921
2922        // 2. FontDescriptor.
2923        let fd_id = builder.objects.len();
2924        let cap_height =
2925            (face.capital_height().unwrap_or_else(|| face.ascender()) as f64 * scale) as i32;
2926        let fd = format!(
2927            "<< /Type /FontDescriptor /FontName /{name} /Flags {flags} \
2928             /FontBBox [{x0} {y0} {x1} {y1}] /ItalicAngle {ia} \
2929             /Ascent {asc} /Descent {desc} /CapHeight {cap} /StemV {stem} \
2930             /FontFile2 {ff2} 0 R >>",
2931            name = pdf_name,
2932            flags = std_font.descriptor_flags(),
2933            x0 = (bbox.x_min as f64 * scale) as i32,
2934            y0 = (bbox.y_min as f64 * scale) as i32,
2935            x1 = (bbox.x_max as f64 * scale) as i32,
2936            y1 = (bbox.y_max as f64 * scale) as i32,
2937            ia = if key.italic { -12 } else { 0 },
2938            asc = (face.ascender() as f64 * scale) as i32,
2939            desc = (face.descender() as f64 * scale) as i32,
2940            cap = cap_height,
2941            stem = if key.weight >= 700 { 120 } else { 80 },
2942            ff2 = fontfile2_id,
2943        );
2944        builder.objects.push(PdfObject {
2945            id: fd_id,
2946            data: fd.into_bytes(),
2947        });
2948
2949        // 3. Simple TrueType font dict — base-14 AFM widths + WinAnsiEncoding,
2950        //    with the PDF/A width carve-out.
2951        //
2952        // For most glyphs the substitute's advance equals the base-14 AFM
2953        // width (Liberation is metric-compatible), so we declare the AFM value
2954        // and positioning stays exact. For the handful of rare accent/symbol
2955        // glyphs per proportional family where they diverge (e.g. macron,
2956        // grave, middot, ÷, ±, quotesingle, µ), we declare the substitute's
2957        // OWN advance instead — so /Widths agrees with the embedded program,
2958        // which ISO 19005 (PDF/A) requires and veraPDF's PDF/A profile checks.
2959        // The trade is a sub-glyph advance drift on those rare glyphs, which
2960        // real documents almost never contain. (Liberation Mono has zero
2961        // divergent glyphs; the carve-out is a no-op there.)
2962        let declared_widths: Vec<u16> = metrics
2963            .widths
2964            .iter()
2965            .enumerate()
2966            .map(|(i, &afm)| {
2967                let code = 32u8.wrapping_add(i as u8); // index 0 = WinAnsi code 32
2968                if let Some(ch) = crate::font::winansi_to_char(code) {
2969                    if let Some(gid) = face.glyph_index(ch) {
2970                        if let Some(adv) = face.glyph_hor_advance(gid) {
2971                            let hmtx = (adv as f64 * scale).round() as u16;
2972                            if (hmtx as i32 - afm as i32).abs() > 1 {
2973                                return hmtx;
2974                            }
2975                        }
2976                    }
2977                }
2978                afm
2979            })
2980            .collect();
2981        let widths_str: String = declared_widths
2982            .iter()
2983            .map(|w| w.to_string())
2984            .collect::<Vec<_>>()
2985            .join(" ");
2986        let obj_id = builder.objects.len();
2987        let font_dict = format!(
2988            "<< /Type /Font /Subtype /TrueType /BaseFont /{name} \
2989             /Encoding /WinAnsiEncoding \
2990             /FirstChar 32 /LastChar 255 /Widths [{w}] \
2991             /FontDescriptor {fd} 0 R >>",
2992            name = pdf_name,
2993            w = widths_str,
2994            fd = fd_id,
2995        );
2996        builder.objects.push(PdfObject {
2997            id: obj_id,
2998            data: font_dict.into_bytes(),
2999        });
3000        builder.font_objects.push((key.clone(), obj_id));
3001        // Record that this base-14 family is embedded (via substitution) so the
3002        // PDF/A all-fonts-embedded check accepts it — this is what lets PDF/A
3003        // and PDF/UA compose.
3004        builder.embedded_standard_fonts.insert(key.clone());
3005        true
3006    }
3007
3008    fn register_fonts(
3009        &self,
3010        builder: &mut PdfBuilder,
3011        pages: &[LayoutPage],
3012        font_context: &FontContext,
3013        pdf_ua: bool,
3014        pdf_version: crate::model::PdfVersion,
3015    ) -> Result<(), FormeError> {
3016        // Collect font usage: glyph IDs, chars, and glyph→char mapping per font
3017        let mut font_usage_map: HashMap<FontKey, FontUsage> = HashMap::new();
3018
3019        for page in pages {
3020            Self::collect_font_usage(&page.elements, &mut font_usage_map);
3021        }
3022
3023        let mut keys: Vec<FontKey> = font_usage_map.keys().cloned().collect();
3024
3025        // Sort for deterministic ordering, then dedup
3026        keys.sort_by(|a, b| {
3027            a.family
3028                .cmp(&b.family)
3029                .then(a.weight.cmp(&b.weight))
3030                .then(a.italic.cmp(&b.italic))
3031        });
3032        keys.dedup();
3033
3034        // Always have at least Helvetica
3035        if keys.is_empty() {
3036            keys.push(FontKey {
3037                family: "Helvetica".to_string(),
3038                weight: 400,
3039                italic: false,
3040            });
3041        }
3042
3043        for key in &keys {
3044            let font_data = font_context.resolve(&key.family, key.weight, key.italic);
3045
3046            match font_data {
3047                FontData::Standard(std_font) => {
3048                    let metrics = std_font.metrics();
3049
3050                    // PDF/UA + PDF/A require every font embedded, which the
3051                    // base-14 fonts are not. In pdfUa mode, if a
3052                    // metric-compatible substitute (Liberation, via
3053                    // @formepdf/fonts-standard) is registered, embed it as a
3054                    // SIMPLE TrueType carrying the base-14 AFM /Widths and
3055                    // WinAnsiEncoding — the content stream is untouched (same
3056                    // `(text) Tj` WinAnsi path, same positions), only the font
3057                    // dictionary gains an embedded program.
3058                    if pdf_ua || pdf_version == crate::model::PdfVersion::V2_0 {
3059                        if Self::emit_pdfua_embedded_standard(
3060                            builder,
3061                            key,
3062                            std_font,
3063                            &metrics,
3064                            font_context,
3065                        ) {
3066                            continue;
3067                        }
3068                        // PDF 2.0 removes the standard-14 provision —
3069                        // conforming readers need not ship these fonts, so
3070                        // non-embedded base-14 output is a bet on reader
3071                        // goodwill. Hard error by name, with the remedy,
3072                        // exactly like the pdfA contract.
3073                        if pdf_version == crate::model::PdfVersion::V2_0 {
3074                            let remedy = match std_font.liberation_family() {
3075                                Some(lib) => format!(
3076                                    "install @formepdf/fonts-standard and register its fonts (`for (const f of standardFonts()) Font.register(f)`) — Forme will embed the metric-compatible {lib} in its place"
3077                                ),
3078                                None => "register an embeddable TrueType font for this text                                          (Symbol/ZapfDingbats have no metric-compatible substitute)"
3079                                    .to_string(),
3080                            };
3081                            return Err(FormeError::FontError(format!(
3082                                "pdfVersion \"2.0\": font '{}' is not embedded. ISO 32000-2 removes the standard-14 provision, so every font must be embedded — {}.",
3083                                std_font.pdf_name(),
3084                                remedy,
3085                            )));
3086                        }
3087                        // Substitution didn't happen. If a metric-compatible
3088                        // substitute exists but wasn't registered, say so by
3089                        // name with the remedy — never silently emit a
3090                        // non-conforming file. (Symbol/ZapfDingbats have no
3091                        // substitute, so there is nothing to suggest.)
3092                        if let Some(lib) = std_font.liberation_family() {
3093                            builder.warnings.push(format!(
3094                                "pdfUa: font '{}' is not embedded, so the PDF will not conform to \
3095                                 PDF/UA (all fonts must be embedded). Install \
3096                                 @formepdf/fonts-standard and register its fonts \
3097                                 (`for (const f of standardFonts()) Font.register(f)`) — Forme \
3098                                 will then embed the metric-compatible {} in its place.",
3099                                std_font.pdf_name(),
3100                                lib,
3101                            ));
3102                        }
3103                    }
3104
3105                    let obj_id = builder.objects.len();
3106                    // Include /Widths so PDF viewers use our exact metrics
3107                    // instead of substituting a system font with different widths
3108                    let widths_str: String = metrics
3109                        .widths
3110                        .iter()
3111                        .map(|w| w.to_string())
3112                        .collect::<Vec<_>>()
3113                        .join(" ");
3114                    let font_dict = format!(
3115                        "<< /Type /Font /Subtype /Type1 /BaseFont /{} \
3116                         /Encoding /WinAnsiEncoding \
3117                         /FirstChar 32 /LastChar 255 /Widths [{}] >>",
3118                        std_font.pdf_name(),
3119                        widths_str,
3120                    );
3121                    builder.objects.push(PdfObject {
3122                        id: obj_id,
3123                        data: font_dict.into_bytes(),
3124                    });
3125                    builder.font_objects.push((key.clone(), obj_id));
3126                }
3127                FontData::Custom { data, .. } => {
3128                    let usage = font_usage_map.get(key);
3129                    let used_glyph_ids = usage.map(|u| &u.glyph_ids);
3130                    let used_chars = usage.map(|u| &u.chars);
3131                    let glyph_to_char = usage.map(|u| &u.glyph_to_char);
3132                    let type0_obj_id = Self::write_custom_font_objects(
3133                        builder,
3134                        key,
3135                        data,
3136                        used_glyph_ids.cloned().unwrap_or_default(),
3137                        used_chars.cloned().unwrap_or_default(),
3138                        glyph_to_char.cloned().unwrap_or_default(),
3139                    )?;
3140                    builder.font_objects.push((key.clone(), type0_obj_id));
3141                }
3142            }
3143        }
3144
3145        Ok(())
3146    }
3147
3148    /// Collect font usage data from layout elements: used chars, glyph IDs, and glyph→char mapping.
3149    fn collect_font_usage(
3150        elements: &[LayoutElement],
3151        font_usage: &mut HashMap<FontKey, FontUsage>,
3152    ) {
3153        for element in elements {
3154            let lines_opt = match &element.draw {
3155                DrawCommand::Text { lines, .. } => Some(lines),
3156                DrawCommand::Watermark { lines, .. } => Some(lines),
3157                _ => None,
3158            };
3159            if let Some(lines) = lines_opt {
3160                for line in lines {
3161                    for glyph in &line.glyphs {
3162                        let italic =
3163                            matches!(glyph.font_style, FontStyle::Italic | FontStyle::Oblique);
3164                        let key = FontKey {
3165                            family: glyph.font_family.to_string(),
3166                            weight: glyph.font_weight,
3167                            italic,
3168                        };
3169                        let usage = font_usage.entry(key).or_insert_with(|| FontUsage {
3170                            chars: HashSet::new(),
3171                            glyph_ids: HashSet::new(),
3172                            glyph_to_char: HashMap::new(),
3173                        });
3174                        usage.chars.insert(glyph.char_value);
3175                        // A page-number sentinel becomes digits at write
3176                        // time — subset all ten for this font, or the
3177                        // substituted numbers would render as .notdef
3178                        // (char_to_gid would have no digit entries).
3179                        if glyph.char_value == PAGE_NUMBER_SENTINEL
3180                            || glyph.char_value == TOTAL_PAGES_SENTINEL
3181                        {
3182                            usage.chars.extend('0'..='9');
3183                        }
3184                        usage.glyph_ids.insert(glyph.glyph_id);
3185                        // For ligatures, use the first char of the cluster
3186                        usage
3187                            .glyph_to_char
3188                            .entry(glyph.glyph_id)
3189                            .or_insert(glyph.char_value);
3190                        // If there's cluster_text, record all chars for this glyph
3191                        if let Some(ref ct) = glyph.cluster_text {
3192                            // First char already recorded above; cluster_text is for ToUnicode
3193                            if let Some(first_char) = ct.chars().next() {
3194                                usage
3195                                    .glyph_to_char
3196                                    .entry(glyph.glyph_id)
3197                                    .or_insert(first_char);
3198                            }
3199                        }
3200                    }
3201                }
3202            }
3203            Self::collect_font_usage(&element.children, font_usage);
3204        }
3205    }
3206
3207    /// Walk all pages, create XObject PDF objects for each image,
3208    /// Register PDF Shading dictionaries for every Rect with a
3209    /// `background_gradient`. Walks the element tree once per page in
3210    /// pre-order (same order `write_element` recurses) so the counter-
3211    /// indexed `shading_map` lookups during emission match.
3212    fn register_shadings(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3213        for (page_idx, page) in pages.iter().enumerate() {
3214            let mut counter = 0usize;
3215            Self::collect_shadings_recursive(&page.elements, page_idx, &mut counter, builder);
3216        }
3217    }
3218
3219    fn collect_shadings_recursive(
3220        elements: &[LayoutElement],
3221        page_idx: usize,
3222        counter: &mut usize,
3223        builder: &mut PdfBuilder,
3224    ) {
3225        for element in elements {
3226            if let DrawCommand::Rect {
3227                background_gradient: Some(gradient),
3228                ..
3229            } = &element.draw
3230            {
3231                let ordinal = *counter;
3232                *counter += 1;
3233                let (obj_id, name) =
3234                    Self::write_shading_objects(builder, gradient, element, ordinal);
3235                builder
3236                    .shading_map
3237                    .insert((page_idx, ordinal), (obj_id, name));
3238            }
3239            Self::collect_shadings_recursive(&element.children, page_idx, counter, builder);
3240        }
3241    }
3242
3243    /// Build the Function + Shading PDF objects for one gradient. Returns
3244    /// (shading_obj_id, "Sh{n}"). 2-stop gradients use a single Type 2
3245    /// (exponential) function. 3+ stop gradients use a Type 3 (stitching)
3246    /// function combining N-1 Type 2 sub-functions, with /Bounds at each
3247    /// interior stop position.
3248    fn write_shading_objects(
3249        builder: &mut PdfBuilder,
3250        gradient: &crate::style::Background,
3251        element: &LayoutElement,
3252        ordinal: usize,
3253    ) -> (usize, String) {
3254        use crate::style::Background;
3255        use crate::style::GradientStop;
3256
3257        // Materialize the gradient as a normalized stop list (positions
3258        // sorted ascending, clamped to [0,1]). Solid-color backgrounds
3259        // collapse to two identical stops at 0 and 1.
3260        let black = Color {
3261            r: 0.0,
3262            g: 0.0,
3263            b: 0.0,
3264            a: 1.0,
3265        };
3266        let stops: Vec<GradientStop> = match gradient {
3267            Background::Color(c) => vec![
3268                GradientStop {
3269                    position: 0.0,
3270                    color: *c,
3271                },
3272                GradientStop {
3273                    position: 1.0,
3274                    color: *c,
3275                },
3276            ],
3277            Background::Linear(g) => normalize_gradient_stops(&g.stops, black),
3278            Background::Radial(g) => normalize_gradient_stops(&g.stops, black),
3279        };
3280
3281        // Build the color-interpolation function. With <=2 stops we emit
3282        // a single Type 2 (exponential) function; with 3+ stops we emit a
3283        // Type 3 (stitching) function combining N-1 Type 2 sub-functions.
3284        let function_id = if stops.len() <= 2 {
3285            let c0 = stops.first().map(|s| s.color).unwrap_or(black);
3286            let c1 = stops.last().map(|s| s.color).unwrap_or(c0);
3287            let id = builder.objects.len();
3288            let data = format!(
3289                "<< /FunctionType 2 /Domain [0 1] /C0 [{:.4} {:.4} {:.4}] /C1 [{:.4} {:.4} {:.4}] /N 1 >>",
3290                c0.r, c0.g, c0.b, c1.r, c1.g, c1.b,
3291            );
3292            builder.objects.push(PdfObject {
3293                id,
3294                data: data.into_bytes(),
3295            });
3296            id
3297        } else {
3298            // Reserve N-1 Type 2 sub-function objects.
3299            let mut sub_ids: Vec<usize> = Vec::with_capacity(stops.len() - 1);
3300            for window in stops.windows(2) {
3301                let c0 = window[0].color;
3302                let c1 = window[1].color;
3303                let id = builder.objects.len();
3304                let data = format!(
3305                    "<< /FunctionType 2 /Domain [0 1] /C0 [{:.4} {:.4} {:.4}] /C1 [{:.4} {:.4} {:.4}] /N 1 >>",
3306                    c0.r, c0.g, c0.b, c1.r, c1.g, c1.b,
3307                );
3308                builder.objects.push(PdfObject {
3309                    id,
3310                    data: data.into_bytes(),
3311                });
3312                sub_ids.push(id);
3313            }
3314            // Bounds = interior stop positions (exclude first and last).
3315            // Encode = [0 1] per sub-function — each sub-function uses its
3316            // full domain regardless of the bound interval width.
3317            let bounds: Vec<String> = stops[1..stops.len() - 1]
3318                .iter()
3319                .map(|s| format!("{:.4}", s.position))
3320                .collect();
3321            let encode: Vec<&str> = (0..sub_ids.len()).map(|_| "0 1").collect();
3322            let functions: Vec<String> = sub_ids.iter().map(|i| format!("{} 0 R", i)).collect();
3323            let id = builder.objects.len();
3324            let data = format!(
3325                "<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>",
3326                functions.join(" "),
3327                bounds.join(" "),
3328                encode.join(" "),
3329            );
3330            builder.objects.push(PdfObject {
3331                id,
3332                data: data.into_bytes(),
3333            });
3334            id
3335        };
3336
3337        // Element dimensions. The shading's coord space is local to the
3338        // rect (we cm-translate to the rect's bottom-left at draw time),
3339        // so x/y aren't needed here — only w/h.
3340        let _ = element.x;
3341        let _ = element.y;
3342        let w = element.width;
3343        let h = element.height;
3344
3345        let shading_id = builder.objects.len();
3346        let shading_data = match gradient {
3347            Background::Linear(g) => {
3348                // CSS angle convention: 0deg = bottom→top, 90deg = left→right,
3349                // 180deg = top→bottom (clockwise from up).
3350                // Our layout uses Y-down; PDF uses Y-up. Compute the axis
3351                // in PDF coords directly: dx = sin(θ), dy = cos(θ) where
3352                // CSS 0deg points "up" (positive PDF y).
3353                // CSS angle convention: 0deg = bottom→top, 180deg =
3354                // top→bottom. PDF y-axis is flipped vs CSS-on-screen, so
3355                // dy comes from cos(θ) directly (CSS 0deg points "up"
3356                // which is +y in PDF coords).
3357                let theta = g.angle_deg.to_radians();
3358                let dx = theta.sin();
3359                let dy = theta.cos();
3360                // Axis length spans the rect along the gradient direction
3361                // (CSS spec covering box).
3362                let axis_len = w * dx.abs() + h * dy.abs();
3363                // Coords are RELATIVE to the rect's bottom-left corner
3364                // (the cm-translate at draw time positions absolutely).
3365                let cx_rel = w / 2.0;
3366                let cy_rel = h / 2.0;
3367                let half = axis_len / 2.0;
3368                let x0 = cx_rel - dx * half;
3369                let y0 = cy_rel - dy * half;
3370                let x1 = cx_rel + dx * half;
3371                let y1 = cy_rel + dy * half;
3372                format!(
3373                    "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{:.3} {:.3} {:.3} {:.3}] /Function {} 0 R /Extend [true true] >>",
3374                    x0, y0, x1, y1, function_id,
3375                )
3376            }
3377            Background::Radial(_) => {
3378                // Circle from center, inner r=0, outer r=max(w/2, h/2),
3379                // expressed relative to rect bottom-left.
3380                let cx_rel = w / 2.0;
3381                let cy_rel = h / 2.0;
3382                let r_outer = (w / 2.0).max(h / 2.0);
3383                format!(
3384                    "<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [{:.3} {:.3} 0 {:.3} {:.3} {:.3}] /Function {} 0 R /Extend [true true] >>",
3385                    cx_rel, cy_rel, cx_rel, cy_rel, r_outer, function_id,
3386                )
3387            }
3388            Background::Color(_) => {
3389                // Solid: emit a constant 1.0-stop function via the Coords
3390                // collapsed to a point. (Shouldn't normally hit this path —
3391                // background_gradient should only be set for true gradients.)
3392                format!(
3393                    "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 0] /Function {} 0 R /Extend [true true] >>",
3394                    function_id,
3395                )
3396            }
3397        };
3398        builder.objects.push(PdfObject {
3399            id: shading_id,
3400            data: shading_data.into_bytes(),
3401        });
3402        (shading_id, format!("Sh{}", ordinal))
3403    }
3404
3405    /// Decode and embed each page's optional `background_image` as a PDF
3406    /// XObject. Identical URLs across pages share a single XObject (the
3407    /// `page_background_url_cache` does the deduplication).
3408    fn register_page_background_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3409        for (page_idx, page) in pages.iter().enumerate() {
3410            let Some(src) = &page.config.background_image else {
3411                continue;
3412            };
3413            // Reuse the XObject if a previous page used the same source.
3414            if let Some(&entry) = builder.page_background_url_cache.get(src) {
3415                builder.page_background_image_map.insert(page_idx, entry);
3416                continue;
3417            }
3418            // Decode + embed; on failure, log a warning and skip the
3419            // background for that page (don't fail the whole render).
3420            match crate::image_loader::load_image(src) {
3421                Ok(image_data) => {
3422                    let img_idx = builder.image_objects.len();
3423                    let dims = (img_idx, image_data.width_px, image_data.height_px);
3424                    let xobj_id = Self::write_image_xobject(builder, &image_data);
3425                    builder.image_objects.push(xobj_id);
3426                    builder.page_background_image_map.insert(page_idx, dims);
3427                    builder.page_background_url_cache.insert(src.clone(), dims);
3428                }
3429                Err(e) => {
3430                    eprintln!("[forme] page background image failed to load: {}", e);
3431                }
3432            }
3433        }
3434    }
3435
3436    /// Emit the page background paint (q + optional ExtGState + cm + Do + Q)
3437    /// at the start of a page's content stream. Sizing follows CSS
3438    /// `background-size` semantics (fill/cover/contain) with positioning
3439    /// per `background-position`.
3440    fn write_page_background(
3441        &self,
3442        stream: &mut String,
3443        page: &LayoutPage,
3444        page_bg: (usize, u32, u32),
3445        builder: &PdfBuilder,
3446    ) {
3447        use crate::model::{BackgroundPosition, BackgroundSize};
3448        let (img_idx, iw_px, ih_px) = page_bg;
3449        let page_w = page.width;
3450        let page_h = page.height;
3451        let iw = iw_px as f64;
3452        let ih = ih_px as f64;
3453
3454        let size = page.config.background_size.unwrap_or_default();
3455        let (dest_w, dest_h) = match size {
3456            BackgroundSize::Fill => (page_w, page_h),
3457            BackgroundSize::Cover => {
3458                let s = (page_w / iw).max(page_h / ih);
3459                (iw * s, ih * s)
3460            }
3461            BackgroundSize::Contain => {
3462                let s = (page_w / iw).min(page_h / ih);
3463                (iw * s, ih * s)
3464            }
3465        };
3466
3467        // Position: for `fill`, dest matches page exactly so position is
3468        // moot; otherwise place per `background-position` against the
3469        // page's bounding box.
3470        let position = page.config.background_position.unwrap_or_default();
3471        // PDF Y origin is bottom-left, so "top" means pdf_y = page_h - dest_h
3472        // and "bottom" means pdf_y = 0.
3473        let (dest_x, dest_y) = match position {
3474            BackgroundPosition::TopLeft => (0.0, page_h - dest_h),
3475            BackgroundPosition::TopRight => (page_w - dest_w, page_h - dest_h),
3476            BackgroundPosition::BottomLeft => (0.0, 0.0),
3477            BackgroundPosition::BottomRight => (page_w - dest_w, 0.0),
3478            BackgroundPosition::Center => ((page_w - dest_w) / 2.0, (page_h - dest_h) / 2.0),
3479        };
3480
3481        // Optional ExtGState wrap for backgroundOpacity < 1.0.
3482        let opacity = page.config.background_opacity.unwrap_or(1.0);
3483        let needs_opacity = opacity < 1.0;
3484        if needs_opacity {
3485            if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
3486                let _ = writeln!(stream, "q\n/{} gs", gs_name);
3487            } else {
3488                let _ = writeln!(stream, "q");
3489            }
3490        } else {
3491            let _ = writeln!(stream, "q");
3492        }
3493        // PDF cm: a b c d e f → matrix [[a c e][b d f][0 0 1]]; for a
3494        // simple scale + translate, that's: w 0 0 h x y cm.
3495        let _ = writeln!(
3496            stream,
3497            "{:.2} 0 0 {:.2} {:.2} {:.2} cm\n/Im{} Do\nQ",
3498            dest_w, dest_h, dest_x, dest_y, img_idx,
3499        );
3500    }
3501
3502    /// and populate the image_index_map for content stream reference.
3503    fn register_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3504        for (page_idx, page) in pages.iter().enumerate() {
3505            let mut element_counter = 0usize;
3506            Self::collect_images_recursive(&page.elements, page_idx, &mut element_counter, builder);
3507        }
3508    }
3509
3510    fn collect_images_recursive(
3511        elements: &[LayoutElement],
3512        page_idx: usize,
3513        element_counter: &mut usize,
3514        builder: &mut PdfBuilder,
3515    ) {
3516        for element in elements {
3517            match &element.draw {
3518                DrawCommand::Image { image_data } => {
3519                    let elem_idx = *element_counter;
3520                    *element_counter += 1;
3521
3522                    let img_idx = builder.image_objects.len();
3523                    let xobj_id = Self::write_image_xobject(builder, image_data);
3524                    builder.image_objects.push(xobj_id);
3525                    builder
3526                        .image_index_map
3527                        .insert((page_idx, elem_idx), img_idx);
3528                }
3529                DrawCommand::ImagePlaceholder => {
3530                    *element_counter += 1;
3531                }
3532                _ => {
3533                    Self::collect_images_recursive(
3534                        &element.children,
3535                        page_idx,
3536                        element_counter,
3537                        builder,
3538                    );
3539                }
3540            }
3541        }
3542    }
3543
3544    /// Collect unique opacity values from all pages and create ExtGState PDF objects.
3545    fn register_ext_gstates(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3546        let mut unique_opacities: Vec<f64> = Vec::new();
3547        for page in pages {
3548            Self::collect_opacities_recursive(&page.elements, &mut unique_opacities);
3549            // Page background opacity (independent of element-level alphas).
3550            if let Some(o) = page.config.background_opacity {
3551                if o < 1.0 {
3552                    unique_opacities.push(o);
3553                }
3554            }
3555        }
3556        unique_opacities.sort_by(|a, b| a.partial_cmp(b).unwrap());
3557        unique_opacities.dedup();
3558
3559        for (idx, &opacity) in unique_opacities.iter().enumerate() {
3560            let obj_id = builder.objects.len();
3561            let gs_name = format!("GS{}", idx);
3562            let obj_data = format!(
3563                "<< /Type /ExtGState /ca {:.4} /CA {:.4} >>",
3564                opacity, opacity
3565            );
3566            builder.objects.push(PdfObject {
3567                id: obj_id,
3568                data: obj_data.into_bytes(),
3569            });
3570            let key = opacity.to_bits();
3571            builder.ext_gstate_map.insert(key, (obj_id, gs_name));
3572        }
3573    }
3574
3575    fn collect_opacities_recursive(elements: &[LayoutElement], opacities: &mut Vec<f64>) {
3576        for element in elements {
3577            // Element-level opacity wraps the whole subtree (including
3578            // children) in `q\n/GS{n} gs ... Q` so descendants render at
3579            // the cumulative alpha. Collect it independently of the
3580            // per-DrawCommand opacities below — they coexist for now,
3581            // and the per-Rect/Text/Watermark opacities are gradually
3582            // being deprecated in favor of the element-level one.
3583            if element.opacity < 1.0 {
3584                opacities.push(element.opacity);
3585            }
3586            // Shadow color alpha — needs its own ExtGState entry so the
3587            // shadow renders semi-transparently independent of the
3588            // element's opacity.
3589            if let DrawCommand::Rect {
3590                box_shadow: Some(shadow),
3591                ..
3592            } = &element.draw
3593            {
3594                if shadow.color.a < 1.0 {
3595                    opacities.push(shadow.color.a);
3596                }
3597            }
3598            match &element.draw {
3599                DrawCommand::Rect { opacity, .. }
3600                | DrawCommand::Text { opacity, .. }
3601                | DrawCommand::Watermark { opacity, .. }
3602                    if *opacity < 1.0 =>
3603                {
3604                    opacities.push(*opacity);
3605                }
3606                DrawCommand::Chart { primitives } => {
3607                    for prim in primitives {
3608                        if let crate::chart::ChartPrimitive::FilledPath { opacity, .. } = prim {
3609                            if *opacity < 1.0 {
3610                                opacities.push(*opacity);
3611                            }
3612                        }
3613                    }
3614                }
3615                DrawCommand::Svg { commands, .. } => {
3616                    for cmd in commands {
3617                        if let crate::svg::SvgCommand::SetOpacity(opacity) = cmd {
3618                            if *opacity < 1.0 {
3619                                opacities.push(*opacity);
3620                            }
3621                        }
3622                    }
3623                }
3624                _ => {}
3625            }
3626            Self::collect_opacities_recursive(&element.children, opacities);
3627        }
3628    }
3629
3630    /// Build the ExtGState resource dict entries for a page.
3631    fn build_ext_gstate_resource_dict(&self, builder: &PdfBuilder) -> String {
3632        if builder.ext_gstate_map.is_empty() {
3633            return String::new();
3634        }
3635        let mut entries: Vec<(&String, usize)> = builder
3636            .ext_gstate_map
3637            .values()
3638            .map(|(obj_id, name)| (name, *obj_id))
3639            .collect();
3640        entries.sort_by_key(|(name, _)| (*name).clone());
3641        entries
3642            .iter()
3643            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
3644            .collect::<Vec<_>>()
3645            .join(" ")
3646    }
3647
3648    /// Write a single image as one or two XObject PDF objects.
3649    /// Returns the main XObject ID.
3650    fn write_image_xobject(
3651        builder: &mut PdfBuilder,
3652        image: &crate::image_loader::LoadedImage,
3653    ) -> usize {
3654        use crate::image_loader::{ImagePixelData, JpegColorSpace};
3655
3656        match &image.pixel_data {
3657            ImagePixelData::Jpeg { data, color_space } => {
3658                let color_space_str = match color_space {
3659                    JpegColorSpace::DeviceRGB => "/DeviceRGB",
3660                    JpegColorSpace::DeviceGray => "/DeviceGray",
3661                };
3662
3663                let obj_id = builder.objects.len();
3664                let mut obj_data: Vec<u8> = Vec::new();
3665                let _ = write!(
3666                    obj_data,
3667                    "<< /Type /XObject /Subtype /Image \
3668                     /Width {} /Height {} \
3669                     /ColorSpace {} \
3670                     /BitsPerComponent 8 \
3671                     /Filter /DCTDecode \
3672                     /Length {} >>\nstream\n",
3673                    image.width_px,
3674                    image.height_px,
3675                    color_space_str,
3676                    data.len()
3677                );
3678                obj_data.extend_from_slice(data);
3679                obj_data.extend_from_slice(b"\nendstream");
3680                builder.objects.push(PdfObject {
3681                    id: obj_id,
3682                    data: obj_data,
3683                });
3684                obj_id
3685            }
3686
3687            ImagePixelData::Decoded { rgb, alpha } => {
3688                // Write SMask first if alpha channel exists
3689                let smask_id = alpha.as_ref().map(|alpha_data| {
3690                    let compressed_alpha = compress_to_vec_zlib(alpha_data, 6);
3691                    let smask_obj_id = builder.objects.len();
3692                    let mut smask_data: Vec<u8> = Vec::new();
3693                    let _ = write!(
3694                        smask_data,
3695                        "<< /Type /XObject /Subtype /Image \
3696                         /Width {} /Height {} \
3697                         /ColorSpace /DeviceGray \
3698                         /BitsPerComponent 8 \
3699                         /Filter /FlateDecode \
3700                         /Length {} >>\nstream\n",
3701                        image.width_px,
3702                        image.height_px,
3703                        compressed_alpha.len()
3704                    );
3705                    smask_data.extend_from_slice(&compressed_alpha);
3706                    smask_data.extend_from_slice(b"\nendstream");
3707                    builder.objects.push(PdfObject {
3708                        id: smask_obj_id,
3709                        data: smask_data,
3710                    });
3711                    smask_obj_id
3712                });
3713
3714                // Write main RGB image XObject
3715                let compressed_rgb = compress_to_vec_zlib(rgb, 6);
3716                let obj_id = builder.objects.len();
3717                let mut obj_data: Vec<u8> = Vec::new();
3718
3719                let smask_ref = smask_id
3720                    .map(|id| format!(" /SMask {} 0 R", id))
3721                    .unwrap_or_default();
3722
3723                let _ = write!(
3724                    obj_data,
3725                    "<< /Type /XObject /Subtype /Image \
3726                     /Width {} /Height {} \
3727                     /ColorSpace /DeviceRGB \
3728                     /BitsPerComponent 8 \
3729                     /Filter /FlateDecode \
3730                     /Length {}{} >>\nstream\n",
3731                    image.width_px,
3732                    image.height_px,
3733                    compressed_rgb.len(),
3734                    smask_ref
3735                );
3736                obj_data.extend_from_slice(&compressed_rgb);
3737                obj_data.extend_from_slice(b"\nendstream");
3738                builder.objects.push(PdfObject {
3739                    id: obj_id,
3740                    data: obj_data,
3741                });
3742                obj_id
3743            }
3744        }
3745    }
3746
3747    /// Build the /XObject resource dict entries for a specific page.
3748    /// Build the page's `/Shading << ... >>` resource dict from the
3749    /// shading_map entries that match `page_idx`.
3750    fn build_shading_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
3751        let mut entries: Vec<(String, usize)> = builder
3752            .shading_map
3753            .iter()
3754            .filter(|(&(p, _), _)| p == page_idx)
3755            .map(|(_, (obj_id, name))| (name.clone(), *obj_id))
3756            .collect();
3757        if entries.is_empty() {
3758            return String::new();
3759        }
3760        entries.sort_by(|a, b| a.0.cmp(&b.0));
3761        entries
3762            .iter()
3763            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
3764            .collect::<Vec<_>>()
3765            .join(" ")
3766    }
3767
3768    fn build_xobject_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
3769        let mut entries: Vec<(usize, usize)> = Vec::new();
3770        for (&(pidx, _), &img_idx) in &builder.image_index_map {
3771            if pidx == page_idx {
3772                let obj_id = builder.image_objects[img_idx];
3773                entries.push((img_idx, obj_id));
3774            }
3775        }
3776        // Include the page's background image (if any) so the `/Im{n} Do`
3777        // operator at the start of the content stream resolves.
3778        if let Some(&(img_idx, _, _)) = builder.page_background_image_map.get(&page_idx) {
3779            let obj_id = builder.image_objects[img_idx];
3780            entries.push((img_idx, obj_id));
3781        }
3782        if entries.is_empty() {
3783            return String::new();
3784        }
3785        entries.sort_by_key(|(idx, _)| *idx);
3786        entries.dedup();
3787        entries
3788            .iter()
3789            .map(|(idx, obj_id)| format!("/Im{} {} 0 R", idx, obj_id))
3790            .collect::<Vec<_>>()
3791            .join(" ")
3792    }
3793
3794    /// Write the 5 CIDFont PDF objects for a custom TrueType font.
3795    /// Returns the object ID of the Type0 root font dictionary.
3796    ///
3797    /// `used_glyph_ids`: original glyph IDs from shaping (from PositionedGlyph.glyph_id).
3798    /// `used_chars`: characters used (for char→gid fallback, e.g., page number placeholders).
3799    /// `glyph_to_char_map`: maps original glyph ID → first Unicode char (for ToUnicode CMap).
3800    fn write_custom_font_objects(
3801        builder: &mut PdfBuilder,
3802        key: &FontKey,
3803        ttf_data: &[u8],
3804        used_glyph_ids: HashSet<u16>,
3805        used_chars: HashSet<char>,
3806        glyph_to_char_map: HashMap<u16, char>,
3807    ) -> Result<usize, FormeError> {
3808        let face = ttf_parser::Face::parse(ttf_data, 0).map_err(|e| {
3809            FormeError::FontError(format!(
3810                "Failed to parse TTF data for font '{}': {}",
3811                key.family, e
3812            ))
3813        })?;
3814
3815        let units_per_em = face.units_per_em();
3816        let ascender = face.ascender();
3817        let descender = face.descender();
3818
3819        // Build char → original glyph ID mapping (for fallback/placeholders)
3820        let mut char_to_orig_gid: HashMap<char, u16> = HashMap::new();
3821        for &ch in &used_chars {
3822            if let Some(gid) = face.glyph_index(ch) {
3823                char_to_orig_gid.insert(ch, gid.0);
3824            }
3825        }
3826
3827        // Combine shaped glyph IDs + char-based glyph IDs for subsetting.
3828        // This ensures ligature glyphs (from shaping) AND individual char glyphs
3829        // (for placeholder fallback) are all included.
3830        let mut all_orig_gids: HashSet<u16> = used_glyph_ids.clone();
3831        for &gid in char_to_orig_gid.values() {
3832            all_orig_gids.insert(gid);
3833        }
3834
3835        // Subset the font to only include used glyphs
3836        let (embed_ttf, gid_remap) = match subset_ttf(ttf_data, &all_orig_gids) {
3837            Ok(subset_result) => (subset_result.ttf_data, subset_result.gid_remap),
3838            Err(_) => {
3839                // Subsetting failed — fall back to embedding the full font (identity remap)
3840                let identity: HashMap<u16, u16> =
3841                    all_orig_gids.iter().map(|&gid| (gid, gid)).collect();
3842                (ttf_data.to_vec(), identity)
3843            }
3844        };
3845
3846        // Build char→new_gid mapping (for placeholder fallback in content stream)
3847        let char_to_gid: HashMap<char, u16> = char_to_orig_gid
3848            .iter()
3849            .filter_map(|(&ch, &orig_gid)| gid_remap.get(&orig_gid).map(|&new_gid| (ch, new_gid)))
3850            .collect();
3851
3852        // Build glyph_id→new_gid mapping (for shaped content stream)
3853        let gid_remap_for_embed = gid_remap.clone();
3854
3855        // Build new_gid→char mapping for ToUnicode CMap
3856        let mut new_gid_to_char: HashMap<u16, char> = HashMap::new();
3857        // From shaped glyph→char mapping
3858        for (&orig_gid, &ch) in &glyph_to_char_map {
3859            if let Some(&new_gid) = gid_remap.get(&orig_gid) {
3860                new_gid_to_char.entry(new_gid).or_insert(ch);
3861            }
3862        }
3863        // Fill in from char→gid mapping too
3864        for (&ch, &new_gid) in &char_to_gid {
3865            new_gid_to_char.entry(new_gid).or_insert(ch);
3866        }
3867
3868        let pdf_font_name = Self::sanitize_font_name(&key.family, key.weight, key.italic);
3869
3870        // 1. FontFile2 stream — compressed subset TTF bytes
3871        let compressed_ttf = compress_to_vec_zlib(&embed_ttf, 6);
3872        let fontfile2_id = builder.objects.len();
3873        let mut fontfile2_data: Vec<u8> = Vec::new();
3874        let _ = write!(
3875            fontfile2_data,
3876            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
3877            compressed_ttf.len(),
3878            embed_ttf.len()
3879        );
3880        fontfile2_data.extend_from_slice(&compressed_ttf);
3881        fontfile2_data.extend_from_slice(b"\nendstream");
3882        builder.objects.push(PdfObject {
3883            id: fontfile2_id,
3884            data: fontfile2_data,
3885        });
3886
3887        // Parse the subset font for metrics (width array uses subset GIDs)
3888        let subset_face = ttf_parser::Face::parse(&embed_ttf, 0).unwrap_or_else(|_| face.clone());
3889        let subset_upem = subset_face.units_per_em();
3890
3891        // 2. FontDescriptor
3892        let font_descriptor_id = builder.objects.len();
3893        let bbox = face.global_bounding_box();
3894        let scale = 1000.0 / units_per_em as f64;
3895        let bbox_str = format!(
3896            "[{} {} {} {}]",
3897            (bbox.x_min as f64 * scale) as i32,
3898            (bbox.y_min as f64 * scale) as i32,
3899            (bbox.x_max as f64 * scale) as i32,
3900            (bbox.y_max as f64 * scale) as i32,
3901        );
3902
3903        let flags = 4u32;
3904        let cap_height = face.capital_height().unwrap_or(ascender) as f64 * scale;
3905        let stem_v = if key.weight >= 700 { 120 } else { 80 };
3906
3907        let font_descriptor_dict = format!(
3908            "<< /Type /FontDescriptor /FontName /{} /Flags {} \
3909             /FontBBox {} /ItalicAngle {} \
3910             /Ascent {} /Descent {} /CapHeight {} /StemV {} \
3911             /FontFile2 {} 0 R >>",
3912            pdf_font_name,
3913            flags,
3914            bbox_str,
3915            if key.italic { -12 } else { 0 },
3916            (ascender as f64 * scale) as i32,
3917            (descender as f64 * scale) as i32,
3918            cap_height as i32,
3919            stem_v,
3920            fontfile2_id,
3921        );
3922        builder.objects.push(PdfObject {
3923            id: font_descriptor_id,
3924            data: font_descriptor_dict.into_bytes(),
3925        });
3926
3927        // 3. CIDFont dictionary (DescendantFont)
3928        let cidfont_id = builder.objects.len();
3929        // Build /W array using new_gid→width from subset face
3930        let w_array = Self::build_w_array_from_gids(&gid_remap, &subset_face, subset_upem);
3931        let default_width = subset_face
3932            .glyph_hor_advance(ttf_parser::GlyphId(0))
3933            .map(|adv| (adv as f64 * 1000.0 / subset_upem as f64) as u32)
3934            .unwrap_or(1000);
3935        let cidfont_dict = format!(
3936            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{} \
3937             /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
3938             /FontDescriptor {} 0 R /DW {} /W {} \
3939             /CIDToGIDMap /Identity >>",
3940            pdf_font_name, font_descriptor_id, default_width, w_array,
3941        );
3942        builder.objects.push(PdfObject {
3943            id: cidfont_id,
3944            data: cidfont_dict.into_bytes(),
3945        });
3946
3947        // 4. ToUnicode CMap
3948        let tounicode_id = builder.objects.len();
3949        let cmap_content = Self::build_tounicode_cmap_from_gids(&new_gid_to_char, &pdf_font_name);
3950        let compressed_cmap = compress_to_vec_zlib(cmap_content.as_bytes(), 6);
3951        let mut tounicode_data: Vec<u8> = Vec::new();
3952        let _ = write!(
3953            tounicode_data,
3954            "<< /Length {} /Filter /FlateDecode >>\nstream\n",
3955            compressed_cmap.len()
3956        );
3957        tounicode_data.extend_from_slice(&compressed_cmap);
3958        tounicode_data.extend_from_slice(b"\nendstream");
3959        builder.objects.push(PdfObject {
3960            id: tounicode_id,
3961            data: tounicode_data,
3962        });
3963
3964        // 5. Type0 font dictionary (the root, referenced by /Resources)
3965        let type0_id = builder.objects.len();
3966        let type0_dict = format!(
3967            "<< /Type /Font /Subtype /Type0 /BaseFont /{} \
3968             /Encoding /Identity-H \
3969             /DescendantFonts [{} 0 R] \
3970             /ToUnicode {} 0 R >>",
3971            pdf_font_name, cidfont_id, tounicode_id,
3972        );
3973        builder.objects.push(PdfObject {
3974            id: type0_id,
3975            data: type0_dict.into_bytes(),
3976        });
3977
3978        // Store embedding data for content stream encoding
3979        builder.custom_font_data.insert(
3980            key.clone(),
3981            CustomFontEmbedData {
3982                ttf_data: embed_ttf,
3983                gid_remap: gid_remap_for_embed,
3984                glyph_to_char: glyph_to_char_map,
3985                char_to_gid,
3986                units_per_em,
3987                ascender,
3988                descender,
3989            },
3990        );
3991
3992        Ok(type0_id)
3993    }
3994
3995    /// Build the /W array from gid_remap (orig_gid→new_gid) using the subset face.
3996    fn build_w_array_from_gids(
3997        gid_remap: &HashMap<u16, u16>,
3998        face: &ttf_parser::Face,
3999        units_per_em: u16,
4000    ) -> String {
4001        let scale = 1000.0 / units_per_em as f64;
4002
4003        let mut entries: Vec<(u16, u32)> = Vec::new();
4004        let mut seen_gids: HashSet<u16> = HashSet::new();
4005
4006        for &new_gid in gid_remap.values() {
4007            if seen_gids.contains(&new_gid) {
4008                continue;
4009            }
4010            seen_gids.insert(new_gid);
4011            let advance = face
4012                .glyph_hor_advance(ttf_parser::GlyphId(new_gid))
4013                .unwrap_or(0);
4014            let width = (advance as f64 * scale) as u32;
4015            entries.push((new_gid, width));
4016        }
4017
4018        entries.sort_by_key(|(gid, _)| *gid);
4019
4020        // Build the W array using individual entries: gid [width]
4021        let mut result = String::from("[");
4022        for (gid, width) in &entries {
4023            let _ = write!(result, " {} [{}]", gid, width);
4024        }
4025        result.push_str(" ]");
4026        result
4027    }
4028
4029    /// Build a ToUnicode CMap from new_gid → char mapping.
4030    fn build_tounicode_cmap_from_gids(gid_to_char: &HashMap<u16, char>, font_name: &str) -> String {
4031        let mut gid_to_unicode: Vec<(u16, u32)> = gid_to_char
4032            .iter()
4033            .map(|(&gid, &ch)| (gid, ch as u32))
4034            .collect();
4035        gid_to_unicode.sort_by_key(|(gid, _)| *gid);
4036
4037        let mut cmap = String::new();
4038        let _ = writeln!(cmap, "/CIDInit /ProcSet findresource begin");
4039        let _ = writeln!(cmap, "12 dict begin");
4040        let _ = writeln!(cmap, "begincmap");
4041        let _ = writeln!(cmap, "/CIDSystemInfo");
4042        let _ = writeln!(
4043            cmap,
4044            "<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def"
4045        );
4046        let _ = writeln!(cmap, "/CMapName /{}-UTF16 def", font_name);
4047        let _ = writeln!(cmap, "/CMapType 2 def");
4048        let _ = writeln!(cmap, "1 begincodespacerange");
4049        let _ = writeln!(cmap, "<0000> <FFFF>");
4050        let _ = writeln!(cmap, "endcodespacerange");
4051
4052        // PDF spec limits beginbfchar to 100 entries per block
4053        for chunk in gid_to_unicode.chunks(100) {
4054            let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
4055            for &(gid, unicode) in chunk {
4056                let _ = writeln!(cmap, "<{:04X}> <{:04X}>", gid, unicode);
4057            }
4058            let _ = writeln!(cmap, "endbfchar");
4059        }
4060
4061        let _ = writeln!(cmap, "endcmap");
4062        let _ = writeln!(cmap, "CMapName currentdict /CMap defineresource pop");
4063        let _ = writeln!(cmap, "end");
4064        let _ = writeln!(cmap, "end");
4065
4066        cmap
4067    }
4068
4069    /// Sanitize a font name for use as a PDF name object.
4070    /// Strips spaces and special characters, appends weight/style suffixes.
4071    fn sanitize_font_name(family: &str, weight: u32, italic: bool) -> String {
4072        let mut name: String = family
4073            .chars()
4074            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
4075            .collect();
4076
4077        if weight >= 700 {
4078            name.push_str("-Bold");
4079        }
4080        if italic {
4081            name.push_str("-Italic");
4082        }
4083
4084        // If name is empty after sanitization, use a fallback
4085        if name.is_empty() {
4086            name = "CustomFont".to_string();
4087        }
4088
4089        name
4090    }
4091
4092    fn build_font_resource_dict(&self, font_objects: &[(FontKey, usize)]) -> String {
4093        font_objects
4094            .iter()
4095            .enumerate()
4096            .map(|(i, (_, obj_id))| format!("/F{} {} 0 R", i, obj_id))
4097            .collect::<Vec<_>>()
4098            .join(" ")
4099    }
4100
4101    /// Look up the font index (/F0, /F1, etc.) for a given family+weight+style.
4102    fn font_index(
4103        &self,
4104        family: &str,
4105        weight: u32,
4106        font_style: FontStyle,
4107        font_objects: &[(FontKey, usize)],
4108    ) -> usize {
4109        let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
4110
4111        // Exact weight match
4112        for (i, (key, _)) in font_objects.iter().enumerate() {
4113            if key.family == family && key.weight == weight && key.italic == italic {
4114                return i;
4115            }
4116        }
4117
4118        // Fallback: snapped weight (400/700)
4119        let snapped = if weight >= 600 { 700 } else { 400 };
4120        for (i, (key, _)) in font_objects.iter().enumerate() {
4121            if key.family == family && key.weight == snapped && key.italic == italic {
4122                return i;
4123            }
4124        }
4125
4126        // Fallback: try Helvetica with same weight/style
4127        for (i, (key, _)) in font_objects.iter().enumerate() {
4128            if key.family == "Helvetica" && key.weight == snapped && key.italic == italic {
4129                return i;
4130            }
4131        }
4132
4133        // Last resort: first font
4134        0
4135    }
4136
4137    /// Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
4138    /// for multi-font text run rendering.
4139    fn group_glyphs_by_style(glyphs: &[PositionedGlyph]) -> Vec<Vec<&PositionedGlyph>> {
4140        if glyphs.is_empty() {
4141            return vec![];
4142        }
4143
4144        let mut groups: Vec<Vec<&PositionedGlyph>> = Vec::new();
4145        let mut current_group: Vec<&PositionedGlyph> = vec![&glyphs[0]];
4146
4147        for glyph in &glyphs[1..] {
4148            let prev = current_group.last().unwrap();
4149            let same_style = glyph.font_family == prev.font_family
4150                && glyph.font_weight == prev.font_weight
4151                && std::mem::discriminant(&glyph.font_style)
4152                    == std::mem::discriminant(&prev.font_style)
4153                && (glyph.font_size - prev.font_size).abs() < 0.01
4154                && Self::colors_equal(&glyph.color, &prev.color)
4155                && std::mem::discriminant(&glyph.text_decoration)
4156                    == std::mem::discriminant(&prev.text_decoration);
4157
4158            if same_style {
4159                current_group.push(glyph);
4160            } else {
4161                groups.push(current_group);
4162                current_group = vec![glyph];
4163            }
4164        }
4165        groups.push(current_group);
4166        groups
4167    }
4168
4169    fn colors_equal(a: &Option<Color>, b: &Option<Color>) -> bool {
4170        match (a, b) {
4171            (None, None) => true,
4172            (Some(ca), Some(cb)) => {
4173                (ca.r - cb.r).abs() < 0.001
4174                    && (ca.g - cb.g).abs() < 0.001
4175                    && (ca.b - cb.b).abs() < 0.001
4176                    && (ca.a - cb.a).abs() < 0.001
4177            }
4178            _ => false,
4179        }
4180    }
4181
4182    /// Collect link annotations from layout elements recursively.
4183    /// When an element has an href, its rect covers all children, so we skip
4184    /// recursing into children to avoid duplicate annotations.
4185    fn collect_link_annotations(
4186        elements: &[LayoutElement],
4187        page_height: f64,
4188        annotations: &mut Vec<LinkAnnotation>,
4189    ) {
4190        for element in elements {
4191            if let Some(ref href) = element.href {
4192                if !href.is_empty() {
4193                    let pdf_y = page_height - element.y - element.height;
4194                    annotations.push(LinkAnnotation {
4195                        x: element.x,
4196                        y: pdf_y,
4197                        width: element.width,
4198                        height: element.height,
4199                        href: href.clone(),
4200                    });
4201                    // Don't recurse — parent annotation covers children
4202                    continue;
4203                }
4204            }
4205            Self::collect_link_annotations(&element.children, page_height, annotations);
4206        }
4207    }
4208
4209    /// Collect form field annotations from layout elements.
4210    fn collect_form_fields(
4211        elements: &[LayoutElement],
4212        page_height: f64,
4213        page_idx: usize,
4214        fields: &mut Vec<FormFieldData>,
4215    ) {
4216        for element in elements {
4217            if let DrawCommand::FormField {
4218                ref field_type,
4219                ref name,
4220            } = element.draw
4221            {
4222                let pdf_y = page_height - element.y - element.height;
4223                fields.push(FormFieldData {
4224                    field_type: field_type.clone(),
4225                    name: name.clone(),
4226                    x: element.x,
4227                    y: pdf_y,
4228                    width: element.width,
4229                    height: element.height,
4230                    page_idx,
4231                });
4232            }
4233            Self::collect_form_fields(&element.children, page_height, page_idx, fields);
4234        }
4235    }
4236
4237    /// Collect bookmarks from layout elements.
4238    fn collect_bookmarks(
4239        elements: &[LayoutElement],
4240        page_height: f64,
4241        page_obj_id: usize,
4242        bookmarks: &mut Vec<PdfBookmark>,
4243    ) {
4244        for element in elements {
4245            if let Some(ref title) = element.bookmark {
4246                let y_pdf = page_height - element.y;
4247                bookmarks.push(PdfBookmark {
4248                    title: title.clone(),
4249                    page_obj_id,
4250                    y_pdf,
4251                });
4252            }
4253            Self::collect_bookmarks(&element.children, page_height, page_obj_id, bookmarks);
4254        }
4255    }
4256
4257    /// Build the PDF outline tree from bookmark entries.
4258    /// Returns the object ID of the /Outlines dictionary.
4259    fn write_outline_tree(
4260        &self,
4261        builder: &mut PdfBuilder,
4262        bookmarks: &[PdfBookmark],
4263        mut tag_builder: Option<&mut tagged::TagBuilder>,
4264    ) -> usize {
4265        // Reserve the Outlines dictionary object
4266        let outlines_id = builder.objects.len();
4267        builder.objects.push(PdfObject {
4268            id: outlines_id,
4269            data: vec![],
4270        });
4271
4272        // Create outline item objects
4273        let mut item_ids: Vec<usize> = Vec::new();
4274        for _bm in bookmarks {
4275            let item_id = builder.objects.len();
4276            builder.objects.push(PdfObject {
4277                id: item_id,
4278                data: vec![],
4279            });
4280            item_ids.push(item_id);
4281        }
4282
4283        // Fill in outline items with /Prev, /Next, /Parent, /Dest
4284        for (i, (bm, &item_id)) in bookmarks.iter().zip(item_ids.iter()).enumerate() {
4285            // ISO 14289-2 8.8: "All destinations whose target lies within
4286            // the current document shall be structure destinations" — and
4287            // veraPDF flags a plain page /Dest array itself, /SD sibling or
4288            // not. Under UA-2 the outline item therefore carries ONLY /SD,
4289            // targeting the bookmark's structure element (placeholder
4290            // patched after write_objects, like the link annotations).
4291            let wants_sd = tag_builder
4292                .as_mut()
4293                .map(|tb| tb.request_struct_destination(&bm.title, item_id))
4294                .unwrap_or(false);
4295            let dest = if wants_sd {
4296                format!("/SD [999999999 0 R /XYZ 0 {:.2} null]", bm.y_pdf)
4297            } else {
4298                format!("/Dest [{} 0 R /XYZ 0 {:.2} null]", bm.page_obj_id, bm.y_pdf)
4299            };
4300            let mut dict = format!(
4301                "<< /Title ({}) /Parent {} 0 R {}",
4302                Self::escape_pdf_string(&bm.title),
4303                outlines_id,
4304                dest,
4305            );
4306            if i > 0 {
4307                let _ = write!(dict, " /Prev {} 0 R", item_ids[i - 1]);
4308            }
4309            if i + 1 < item_ids.len() {
4310                let _ = write!(dict, " /Next {} 0 R", item_ids[i + 1]);
4311            }
4312            dict.push_str(" >>");
4313            builder.objects[item_id].data = dict.into_bytes();
4314        }
4315
4316        // Fill in Outlines dictionary
4317        let first_id = item_ids.first().copied().unwrap_or(0);
4318        let last_id = item_ids.last().copied().unwrap_or(0);
4319        let outlines_dict = format!(
4320            "<< /Type /Outlines /First {} 0 R /Last {} 0 R /Count {} >>",
4321            first_id,
4322            last_id,
4323            bookmarks.len()
4324        );
4325        builder.objects[outlines_id].data = outlines_dict.into_bytes();
4326
4327        outlines_id
4328    }
4329
4330    /// Write SVG drawing commands to a PDF content stream.
4331    fn write_svg_commands(
4332        stream: &mut String,
4333        commands: &[SvgCommand],
4334        ext_gstate_map: &HashMap<u64, (usize, String)>,
4335    ) {
4336        for cmd in commands {
4337            match cmd {
4338                SvgCommand::MoveTo(x, y) => {
4339                    let _ = writeln!(stream, "{:.2} {:.2} m", x, y);
4340                }
4341                SvgCommand::LineTo(x, y) => {
4342                    let _ = writeln!(stream, "{:.2} {:.2} l", x, y);
4343                }
4344                SvgCommand::CurveTo(x1, y1, x2, y2, x3, y3) => {
4345                    let _ = writeln!(
4346                        stream,
4347                        "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4348                        x1, y1, x2, y2, x3, y3
4349                    );
4350                }
4351                SvgCommand::ClosePath => {
4352                    let _ = writeln!(stream, "h");
4353                }
4354                SvgCommand::SetFill(r, g, b) => {
4355                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", r, g, b);
4356                }
4357                SvgCommand::SetFillNone => {
4358                    // No-op in PDF; handled by fill/stroke selection
4359                }
4360                SvgCommand::SetStroke(r, g, b) => {
4361                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", r, g, b);
4362                }
4363                SvgCommand::SetStrokeNone => {
4364                    // No-op in PDF
4365                }
4366                SvgCommand::SetStrokeWidth(w) => {
4367                    let _ = writeln!(stream, "{:.2} w", w);
4368                }
4369                SvgCommand::Fill => {
4370                    let _ = writeln!(stream, "f");
4371                }
4372                SvgCommand::Stroke => {
4373                    let _ = writeln!(stream, "S");
4374                }
4375                SvgCommand::FillAndStroke => {
4376                    let _ = writeln!(stream, "B");
4377                }
4378                SvgCommand::SetLineCap(cap) => {
4379                    let _ = writeln!(stream, "{} J", cap);
4380                }
4381                SvgCommand::SetLineJoin(join) => {
4382                    let _ = writeln!(stream, "{} j", join);
4383                }
4384                SvgCommand::SaveState => {
4385                    let _ = writeln!(stream, "q");
4386                }
4387                SvgCommand::RestoreState => {
4388                    let _ = writeln!(stream, "Q");
4389                }
4390                SvgCommand::SetOpacity(opacity) => {
4391                    if let Some((_, gs_name)) = ext_gstate_map.get(&opacity.to_bits()) {
4392                        let _ = writeln!(stream, "/{} gs", gs_name);
4393                    }
4394                }
4395            }
4396        }
4397    }
4398
4399    /// Escape special characters in a PDF string.
4400    pub(crate) fn escape_pdf_string(s: &str) -> String {
4401        s.replace('\\', "\\\\")
4402            .replace('(', "\\(")
4403            .replace(')', "\\)")
4404    }
4405
4406    /// Decode an attachment `src`: plain base64, with an optional
4407    /// `data:...;base64,` prefix tolerated (same convention as fonts).
4408    fn decode_attachment_src(src: &str) -> Result<Vec<u8>, FormeError> {
4409        use base64::Engine as _;
4410        let b64 = src.rsplit_once(";base64,").map(|(_, d)| d).unwrap_or(src);
4411        base64::engine::general_purpose::STANDARD
4412            .decode(b64.trim())
4413            .map_err(|e| {
4414                FormeError::RenderError(format!(
4415                    "attachment src is not valid base64 (expected base64 bytes or a data: URI): {e}"
4416                ))
4417            })
4418    }
4419
4420    /// Encode a MIME type as a PDF name (PDF 32000 §7.3.5): delimiter and
4421    /// non-regular characters become #XX — `text/xml` → `text#2Fxml`.
4422    fn mime_to_pdf_name(mime: &str) -> String {
4423        let mut out = String::with_capacity(mime.len() + 2);
4424        for b in mime.bytes() {
4425            let regular =
4426                b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+' | b'\'' | b'"');
4427            if regular {
4428                out.push(b as char);
4429            } else {
4430                let _ = write!(out, "#{:02X}", b);
4431            }
4432        }
4433        out
4434    }
4435
4436    /// Encode a string for use in a PDF content stream with WinAnsi encoding.
4437    /// Characters outside WinAnsi range are replaced with '?' and recorded
4438    /// for the missing-glyph render defect.
4439    fn encode_winansi_text(builder: &PdfBuilder, s: &str) -> String {
4440        let mut result = String::with_capacity(s.len());
4441        for ch in s.chars() {
4442            let b = Self::unicode_to_winansi(ch).unwrap_or_else(|| {
4443                builder.missing_glyphs.borrow_mut().insert(ch);
4444                b'?'
4445            });
4446            match b {
4447                b'\\' => result.push_str("\\\\"),
4448                b'(' => result.push_str("\\("),
4449                b')' => result.push_str("\\)"),
4450                0x20..=0x7E => result.push(b as char),
4451                _ => {
4452                    let _ = write!(result, "\\{:03o}", b);
4453                }
4454            }
4455        }
4456        result
4457    }
4458
4459    /// Map a Unicode codepoint to a WinAnsiEncoding byte value.
4460    fn unicode_to_winansi(ch: char) -> Option<u8> {
4461        crate::font::unicode_to_winansi(ch)
4462    }
4463
4464    /// Serialize all objects into the final PDF byte stream.
4465    fn serialize(&self, builder: &PdfBuilder, info_obj_id: Option<usize>) -> Vec<u8> {
4466        let mut output: Vec<u8> = Vec::new();
4467        let mut offsets: Vec<usize> = vec![0; builder.objects.len()];
4468
4469        // Header
4470        output.extend_from_slice(match builder.pdf_version {
4471            crate::model::PdfVersion::V1_7 => b"%PDF-1.7\n".as_slice(),
4472            crate::model::PdfVersion::V2_0 => b"%PDF-2.0\n".as_slice(),
4473        });
4474        output.extend_from_slice(b"%\xe2\xe3\xcf\xd3\n");
4475
4476        for (i, obj) in builder.objects.iter().enumerate().skip(1) {
4477            offsets[i] = output.len();
4478            let header = format!("{} 0 obj\n", i);
4479            output.extend_from_slice(header.as_bytes());
4480            output.extend_from_slice(&obj.data);
4481            output.extend_from_slice(b"\nendobj\n\n");
4482        }
4483
4484        let xref_offset = output.len();
4485        let _ = writeln!(output, "xref\n0 {}", builder.objects.len());
4486        let _ = writeln!(output, "0000000000 65535 f ");
4487        for offset in offsets.iter().skip(1) {
4488            let _ = writeln!(output, "{:010} 00000 n ", offset);
4489        }
4490
4491        let _ = write!(
4492            output,
4493            "trailer\n<< /Size {} /Root 1 0 R",
4494            builder.objects.len()
4495        );
4496        if let Some(info_id) = info_obj_id {
4497            let _ = write!(output, " /Info {} 0 R", info_id);
4498        }
4499        // /ID — required by PDF/A (6.1.3) and generally expected. Derived
4500        // deterministically from the file content (SHA-256 of everything written
4501        // so far), NOT a timestamp or random bytes, so native and WASM builds
4502        // stay byte-identical. The two identifiers are equal for a freshly
4503        // created (never incrementally updated) file, per ISO 32000-1 14.4.
4504        {
4505            use sha2::Digest as _;
4506            let digest = sha2::Sha256::digest(&output);
4507            let mut id_hex = String::with_capacity(32);
4508            for b in &digest[..16] {
4509                let _ = write!(id_hex, "{:02X}", b);
4510            }
4511            let _ = write!(output, " /ID [<{id_hex}> <{id_hex}>]");
4512        }
4513        let _ = writeln!(output, " >>\nstartxref\n{}\n%%EOF", xref_offset);
4514
4515        output
4516    }
4517}
4518
4519/// Write a single chart drawing primitive to the PDF content stream.
4520///
4521/// Called within a Y-flipped coordinate system (1 0 0 -1 x page_h-y cm),
4522/// so chart primitives use top-left origin (Y increases downward).
4523fn write_chart_primitive(
4524    stream: &mut String,
4525    prim: &crate::chart::ChartPrimitive,
4526    _chart_height: f64,
4527    builder: &PdfBuilder,
4528) {
4529    use crate::chart::{ChartPrimitive, TextAnchor};
4530    use crate::font::metrics::unicode_to_winansi;
4531
4532    match prim {
4533        ChartPrimitive::Rect { x, y, w, h, fill } => {
4534            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4535            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re f", x, y, w, h);
4536        }
4537
4538        ChartPrimitive::Line {
4539            x1,
4540            y1,
4541            x2,
4542            y2,
4543            stroke,
4544            width,
4545        } => {
4546            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
4547            let _ = writeln!(stream, "{:.2} w", width);
4548            let _ = writeln!(stream, "{:.2} {:.2} m {:.2} {:.2} l S", x1, y1, x2, y2);
4549        }
4550
4551        ChartPrimitive::Polyline {
4552            points,
4553            stroke,
4554            width,
4555        } => {
4556            if points.len() < 2 {
4557                return;
4558            }
4559            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
4560            let _ = writeln!(stream, "{:.2} w", width);
4561            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
4562            for &(px, py) in &points[1..] {
4563                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
4564            }
4565            let _ = writeln!(stream, "S");
4566        }
4567
4568        ChartPrimitive::FilledPath {
4569            points,
4570            fill,
4571            opacity,
4572        } => {
4573            if points.len() < 3 {
4574                return;
4575            }
4576            let _ = writeln!(stream, "q");
4577            // Set opacity via ExtGState if available
4578            if *opacity < 1.0 {
4579                if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
4580                    let _ = writeln!(stream, "/{} gs", gs_name);
4581                }
4582            }
4583            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4584            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
4585            for &(px, py) in &points[1..] {
4586                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
4587            }
4588            let _ = writeln!(stream, "h f");
4589            let _ = writeln!(stream, "Q");
4590        }
4591
4592        ChartPrimitive::Circle { cx, cy, r, fill } => {
4593            // Approximate circle with 4 cubic bezier curves
4594            let kappa: f64 = 0.5523;
4595            let kr = kappa * r;
4596            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4597            let _ = writeln!(stream, "{:.2} {:.2} m", cx + r, cy);
4598            let _ = writeln!(
4599                stream,
4600                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4601                cx + r,
4602                cy + kr,
4603                cx + kr,
4604                cy + r,
4605                cx,
4606                cy + r
4607            );
4608            let _ = writeln!(
4609                stream,
4610                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4611                cx - kr,
4612                cy + r,
4613                cx - r,
4614                cy + kr,
4615                cx - r,
4616                cy
4617            );
4618            let _ = writeln!(
4619                stream,
4620                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4621                cx - r,
4622                cy - kr,
4623                cx - kr,
4624                cy - r,
4625                cx,
4626                cy - r
4627            );
4628            let _ = writeln!(
4629                stream,
4630                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4631                cx + kr,
4632                cy - r,
4633                cx + r,
4634                cy - kr,
4635                cx + r,
4636                cy
4637            );
4638            let _ = writeln!(stream, "f");
4639        }
4640
4641        ChartPrimitive::ArcSector {
4642            cx,
4643            cy,
4644            r,
4645            start_angle,
4646            end_angle,
4647            fill,
4648        } => {
4649            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4650            // Move to center
4651            let _ = writeln!(stream, "{:.2} {:.2} m", cx, cy);
4652            // Line to arc start
4653            let sx = cx + r * start_angle.cos();
4654            let sy = cy + r * start_angle.sin();
4655            let _ = writeln!(stream, "{:.2} {:.2} l", sx, sy);
4656
4657            // Approximate arc with cubic bezier segments (max 90° per segment)
4658            let mut angle = *start_angle;
4659            let total = end_angle - start_angle;
4660            let segments = ((total.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
4661            let step = total / segments as f64;
4662
4663            for _ in 0..segments {
4664                let a1 = angle;
4665                let a2 = angle + step;
4666                let alpha = 4.0 / 3.0 * ((a2 - a1) / 4.0).tan();
4667
4668                let p1x = cx + r * a1.cos();
4669                let p1y = cy + r * a1.sin();
4670                let p2x = cx + r * a2.cos();
4671                let p2y = cy + r * a2.sin();
4672
4673                let cp1x = p1x - alpha * r * a1.sin();
4674                let cp1y = p1y + alpha * r * a1.cos();
4675                let cp2x = p2x + alpha * r * a2.sin();
4676                let cp2y = p2y - alpha * r * a2.cos();
4677
4678                let _ = writeln!(
4679                    stream,
4680                    "{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c",
4681                    cp1x, cp1y, cp2x, cp2y, p2x, p2y
4682                );
4683                angle = a2;
4684            }
4685
4686            // Close path back to center and fill
4687            let _ = writeln!(stream, "h f");
4688        }
4689
4690        ChartPrimitive::Label {
4691            text,
4692            x,
4693            y,
4694            font_size,
4695            color,
4696            anchor,
4697        } => {
4698            // Measure text width for anchor alignment
4699            let metrics = crate::font::StandardFont::Helvetica.metrics();
4700            let text_width = metrics.measure_string(text, *font_size, 0.0);
4701            let x_offset = match anchor {
4702                TextAnchor::Left => 0.0,
4703                TextAnchor::Center => -text_width / 2.0,
4704                TextAnchor::Right => -text_width,
4705            };
4706
4707            // Find Helvetica font index in font_objects
4708            let font_idx = builder
4709                .font_objects
4710                .iter()
4711                .enumerate()
4712                .find(|(_, (key, _))| key.family == "Helvetica" && key.weight == 400 && !key.italic)
4713                .map(|(i, _)| i)
4714                .unwrap_or(0);
4715
4716            // Encode text to WinAnsi
4717            let encoded: String = text
4718                .chars()
4719                .map(|ch| {
4720                    if let Some(code) = unicode_to_winansi(ch) {
4721                        code as char
4722                    } else if (ch as u32) >= 32 && (ch as u32) <= 255 {
4723                        ch
4724                    } else {
4725                        builder.missing_glyphs.borrow_mut().insert(ch);
4726                        '?'
4727                    }
4728                })
4729                .collect();
4730            let escaped = pdf_escape_string(&encoded);
4731
4732            // Undo Y-flip for text rendering, then position
4733            let _ = writeln!(stream, "q");
4734            let _ = writeln!(stream, "1 0 0 -1 {:.4} {:.4} cm", x + x_offset, *y);
4735            let _ = writeln!(
4736                stream,
4737                "BT /F{} {:.1} Tf {:.3} {:.3} {:.3} rg 0 0 Td ({}) Tj ET",
4738                font_idx, font_size, color.r, color.g, color.b, escaped
4739            );
4740            let _ = writeln!(stream, "Q");
4741        }
4742    }
4743}
4744
4745/// Normalize a list of gradient stops for PDF Shading emission. Clamps
4746/// positions to [0, 1], sorts ascending by position, and pads with
4747/// implicit stops at 0 and 1 (using the closest defined stop's color)
4748/// when the input doesn't cover the full range. Empty input collapses to
4749/// two `fallback`-colored stops at 0 and 1 so the caller never has to
4750/// special-case zero stops.
4751fn normalize_gradient_stops(
4752    stops: &[crate::style::GradientStop],
4753    fallback: Color,
4754) -> Vec<crate::style::GradientStop> {
4755    use crate::style::GradientStop;
4756    if stops.is_empty() {
4757        return vec![
4758            GradientStop {
4759                position: 0.0,
4760                color: fallback,
4761            },
4762            GradientStop {
4763                position: 1.0,
4764                color: fallback,
4765            },
4766        ];
4767    }
4768    let mut sorted: Vec<GradientStop> = stops
4769        .iter()
4770        .map(|s| GradientStop {
4771            position: s.position.clamp(0.0, 1.0),
4772            color: s.color,
4773        })
4774        .collect();
4775    sorted.sort_by(|a, b| {
4776        a.position
4777            .partial_cmp(&b.position)
4778            .unwrap_or(std::cmp::Ordering::Equal)
4779    });
4780    if sorted[0].position > 0.0 {
4781        sorted.insert(
4782            0,
4783            GradientStop {
4784                position: 0.0,
4785                color: sorted[0].color,
4786            },
4787        );
4788    }
4789    if sorted[sorted.len() - 1].position < 1.0 {
4790        let last = sorted[sorted.len() - 1].color;
4791        sorted.push(GradientStop {
4792            position: 1.0,
4793            color: last,
4794        });
4795    }
4796    sorted
4797}
4798
4799fn pdf_escape_string(s: &str) -> String {
4800    let mut out = String::with_capacity(s.len());
4801    for ch in s.chars() {
4802        match ch {
4803            '(' => out.push_str("\\("),
4804            ')' => out.push_str("\\)"),
4805            '\\' => out.push_str("\\\\"),
4806            _ => out.push(ch),
4807        }
4808    }
4809    out
4810}
4811
4812#[cfg(test)]
4813mod tests {
4814    use super::*;
4815    use crate::font::FontContext;
4816
4817    /// The embedded sRGB profile must be a REAL ICC profile suitable for a
4818    /// PDF/A OutputIntent — not, say, an HTML error page a `curl` returned and
4819    /// nobody inspected (which is exactly what shipped from v0.6.0 through 0.15.0,
4820    /// silently making every PDF/A OutputIntent invalid). This is the check
4821    /// that would have caught it: ICC signature, an OutputIntent-legal device
4822    /// class (`mntr`/`prtr`), and an RGB data colour space.
4823    #[test]
4824    fn test_embedded_srgb_is_a_valid_icc_profile() {
4825        let icc: &[u8] = include_bytes!("sRGB.icc");
4826        assert!(
4827            icc.len() >= 128,
4828            "ICC shorter than its 128-byte header: {}",
4829            icc.len()
4830        );
4831        // Not HTML / not a text error page.
4832        assert_ne!(
4833            icc[0], b'<',
4834            "embedded ICC starts with '<' — looks like HTML, not a profile"
4835        );
4836        // 'acsp' profile-file signature at bytes 36..40 (ISO 15076-1 / ICC.1).
4837        assert_eq!(&icc[36..40], b"acsp", "missing ICC 'acsp' signature");
4838        // Device class (bytes 12..16) must be monitor or output for an OutputIntent.
4839        let device_class = &icc[12..16];
4840        assert!(
4841            device_class == b"mntr" || device_class == b"prtr",
4842            "ICC device class {:?} is not mntr/prtr (PDF/A 6.2.3)",
4843            String::from_utf8_lossy(device_class),
4844        );
4845        // Data colour space (bytes 16..20) must be RGB for an sRGB OutputIntent.
4846        assert_eq!(&icc[16..20], b"RGB ", "ICC data colour space is not RGB");
4847    }
4848
4849    #[test]
4850    fn test_escape_pdf_string() {
4851        assert_eq!(
4852            PdfWriter::escape_pdf_string("Hello (World)"),
4853            "Hello \\(World\\)"
4854        );
4855        assert_eq!(PdfWriter::escape_pdf_string("back\\slash"), "back\\\\slash");
4856    }
4857
4858    #[test]
4859    fn test_empty_document_produces_valid_pdf() {
4860        let writer = PdfWriter::new();
4861        let font_context = FontContext::new();
4862        let pages = vec![LayoutPage {
4863            width: 595.28,
4864            height: 841.89,
4865            elements: vec![],
4866            fixed_header: vec![],
4867            fixed_footer: vec![],
4868            watermarks: vec![],
4869            config: PageConfig::default(),
4870            page_name: None,
4871        }];
4872        let metadata = Metadata::default();
4873        let (bytes, _warnings) = writer
4874            .write(
4875                &pages,
4876                &metadata,
4877                &font_context,
4878                false,
4879                None,
4880                false,
4881                None,
4882                &[],
4883                None,
4884                false,
4885                crate::model::PdfVersion::V1_7,
4886                false,
4887            )
4888            .unwrap();
4889
4890        assert!(bytes.starts_with(b"%PDF-1.7"));
4891        assert!(bytes.windows(5).any(|w| w == b"%%EOF"));
4892        assert!(bytes.windows(4).any(|w| w == b"xref"));
4893        assert!(bytes.windows(7).any(|w| w == b"trailer"));
4894    }
4895
4896    #[test]
4897    fn test_metadata_in_pdf() {
4898        let writer = PdfWriter::new();
4899        let font_context = FontContext::new();
4900        let pages = vec![LayoutPage {
4901            width: 595.28,
4902            height: 841.89,
4903            elements: vec![],
4904            fixed_header: vec![],
4905            fixed_footer: vec![],
4906            watermarks: vec![],
4907            config: PageConfig::default(),
4908            page_name: None,
4909        }];
4910        let metadata = Metadata {
4911            title: Some("Test Document".to_string()),
4912            author: Some("Forme".to_string()),
4913            subject: None,
4914            creator: None,
4915            lang: None,
4916        };
4917        let (bytes, _warnings) = writer
4918            .write(
4919                &pages,
4920                &metadata,
4921                &font_context,
4922                false,
4923                None,
4924                false,
4925                None,
4926                &[],
4927                None,
4928                false,
4929                crate::model::PdfVersion::V1_7,
4930                false,
4931            )
4932            .unwrap();
4933        let text = String::from_utf8_lossy(&bytes);
4934
4935        assert!(text.contains("/Title (Test Document)"));
4936        assert!(text.contains("/Author (Forme)"));
4937    }
4938
4939    #[test]
4940    fn test_bold_font_registered_separately() {
4941        let writer = PdfWriter::new();
4942        let font_context = FontContext::new();
4943
4944        // Create pages with both regular and bold text
4945        let pages = vec![LayoutPage {
4946            width: 595.28,
4947            height: 841.89,
4948            elements: vec![
4949                LayoutElement {
4950                    x: 54.0,
4951                    y: 54.0,
4952                    width: 100.0,
4953                    height: 16.8,
4954                    draw: DrawCommand::Text {
4955                        lines: vec![TextLine {
4956                            x: 54.0,
4957                            y: 66.0,
4958                            width: 50.0,
4959                            height: 16.8,
4960                            glyphs: vec![PositionedGlyph {
4961                                glyph_id: 65,
4962                                x_offset: 0.0,
4963                                y_offset: 0.0,
4964                                x_advance: 8.0,
4965                                font_size: 12.0,
4966                                font_family: "Helvetica".into(),
4967                                font_weight: 400,
4968                                font_style: FontStyle::Normal,
4969                                char_value: 'A',
4970                                color: None,
4971                                href: None,
4972                                text_decoration: TextDecoration::None,
4973                                letter_spacing: 0.0,
4974                                cluster_text: None,
4975                            }],
4976                            word_spacing: 0.0,
4977                        }],
4978                        color: Color::BLACK,
4979                        text_decoration: TextDecoration::None,
4980                        opacity: 1.0,
4981                    },
4982                    children: vec![],
4983                    node_type: None,
4984                    resolved_style: None,
4985                    source_location: None,
4986                    href: None,
4987                    bookmark: None,
4988                    alt: None,
4989                    is_header_row: false,
4990                    actual_text: None,
4991                    list_numbering: None,
4992                    col_span: 1,
4993                    overflow: Overflow::default(),
4994                    opacity: 1.0,
4995                },
4996                LayoutElement {
4997                    x: 54.0,
4998                    y: 74.0,
4999                    width: 100.0,
5000                    height: 16.8,
5001                    draw: DrawCommand::Text {
5002                        lines: vec![TextLine {
5003                            x: 54.0,
5004                            y: 86.0,
5005                            width: 50.0,
5006                            height: 16.8,
5007                            glyphs: vec![PositionedGlyph {
5008                                glyph_id: 65,
5009                                x_offset: 0.0,
5010                                y_offset: 0.0,
5011                                x_advance: 8.0,
5012                                font_size: 12.0,
5013                                font_family: "Helvetica".into(),
5014                                font_weight: 700,
5015                                font_style: FontStyle::Normal,
5016                                char_value: 'A',
5017                                color: None,
5018                                href: None,
5019                                text_decoration: TextDecoration::None,
5020                                letter_spacing: 0.0,
5021                                cluster_text: None,
5022                            }],
5023                            word_spacing: 0.0,
5024                        }],
5025                        color: Color::BLACK,
5026                        text_decoration: TextDecoration::None,
5027                        opacity: 1.0,
5028                    },
5029                    children: vec![],
5030                    node_type: None,
5031                    resolved_style: None,
5032                    source_location: None,
5033                    href: None,
5034                    bookmark: None,
5035                    alt: None,
5036                    is_header_row: false,
5037                    actual_text: None,
5038                    list_numbering: None,
5039                    col_span: 1,
5040                    overflow: Overflow::default(),
5041                    opacity: 1.0,
5042                },
5043            ],
5044            fixed_header: vec![],
5045            fixed_footer: vec![],
5046            watermarks: vec![],
5047            config: PageConfig::default(),
5048            page_name: None,
5049        }];
5050
5051        let metadata = Metadata::default();
5052        let (bytes, _warnings) = writer
5053            .write(
5054                &pages,
5055                &metadata,
5056                &font_context,
5057                false,
5058                None,
5059                false,
5060                None,
5061                &[],
5062                None,
5063                false,
5064                crate::model::PdfVersion::V1_7,
5065                false,
5066            )
5067            .unwrap();
5068        let text = String::from_utf8_lossy(&bytes);
5069
5070        // Should have both Helvetica and Helvetica-Bold registered
5071        assert!(
5072            text.contains("Helvetica"),
5073            "Should contain regular Helvetica"
5074        );
5075        assert!(
5076            text.contains("Helvetica-Bold"),
5077            "Should contain Helvetica-Bold"
5078        );
5079    }
5080
5081    #[test]
5082    fn test_sanitize_font_name() {
5083        assert_eq!(PdfWriter::sanitize_font_name("Inter", 400, false), "Inter");
5084        assert_eq!(
5085            PdfWriter::sanitize_font_name("Inter", 700, false),
5086            "Inter-Bold"
5087        );
5088        assert_eq!(
5089            PdfWriter::sanitize_font_name("Inter", 400, true),
5090            "Inter-Italic"
5091        );
5092        assert_eq!(
5093            PdfWriter::sanitize_font_name("Inter", 700, true),
5094            "Inter-Bold-Italic"
5095        );
5096        assert_eq!(
5097            PdfWriter::sanitize_font_name("Noto Sans", 400, false),
5098            "NotoSans"
5099        );
5100        assert_eq!(
5101            PdfWriter::sanitize_font_name("Font (Display)", 400, false),
5102            "FontDisplay"
5103        );
5104    }
5105
5106    #[test]
5107    fn test_tounicode_cmap_format() {
5108        // glyph_to_char: maps subset glyph IDs → Unicode chars
5109        let mut glyph_to_char = HashMap::new();
5110        glyph_to_char.insert(36u16, 'A');
5111        glyph_to_char.insert(37u16, 'B');
5112
5113        let cmap = PdfWriter::build_tounicode_cmap_from_gids(&glyph_to_char, "TestFont");
5114
5115        assert!(cmap.contains("begincmap"), "CMap should contain begincmap");
5116        assert!(cmap.contains("endcmap"), "CMap should contain endcmap");
5117        assert!(
5118            cmap.contains("beginbfchar"),
5119            "CMap should contain beginbfchar"
5120        );
5121        assert!(cmap.contains("endbfchar"), "CMap should contain endbfchar");
5122        assert!(
5123            cmap.contains("<0024> <0041>"),
5124            "Should map gid 0x0024 to Unicode 'A' 0x0041"
5125        );
5126        assert!(
5127            cmap.contains("<0025> <0042>"),
5128            "Should map gid 0x0025 to Unicode 'B' 0x0042"
5129        );
5130        assert!(
5131            cmap.contains("begincodespacerange"),
5132            "Should define codespace range"
5133        );
5134        assert!(
5135            cmap.contains("<0000> <FFFF>"),
5136            "Codespace should be 0000-FFFF"
5137        );
5138    }
5139
5140    #[test]
5141    fn test_w_array_format() {
5142        let mut char_to_gid = HashMap::new();
5143        char_to_gid.insert('A', 36u16);
5144
5145        // We need actual font data to test this properly, so just verify format
5146        // with a minimal check that the function produces valid output
5147        let w_array_str = "[ 36 [600] ]";
5148        assert!(w_array_str.starts_with('['));
5149        assert!(w_array_str.ends_with(']'));
5150    }
5151
5152    #[test]
5153    fn test_hex_glyph_encoding() {
5154        // Verify the hex format used for custom font text encoding
5155        let gid: u16 = 0x0041;
5156        let hex = format!("{:04X}", gid);
5157        assert_eq!(hex, "0041");
5158
5159        let gids = [0x0041u16, 0x0042, 0x0043];
5160        let hex_str: String = gids.iter().map(|g| format!("{:04X}", g)).collect();
5161        assert_eq!(hex_str, "004100420043");
5162    }
5163
5164    #[test]
5165    fn test_standard_font_still_uses_text_string() {
5166        let writer = PdfWriter::new();
5167        let font_context = FontContext::new();
5168
5169        let pages = vec![LayoutPage {
5170            width: 595.28,
5171            height: 841.89,
5172            elements: vec![LayoutElement {
5173                x: 54.0,
5174                y: 54.0,
5175                width: 100.0,
5176                height: 16.8,
5177                draw: DrawCommand::Text {
5178                    lines: vec![TextLine {
5179                        x: 54.0,
5180                        y: 66.0,
5181                        width: 50.0,
5182                        height: 16.8,
5183                        glyphs: vec![PositionedGlyph {
5184                            glyph_id: 65,
5185                            x_offset: 0.0,
5186                            y_offset: 0.0,
5187                            x_advance: 8.0,
5188                            font_size: 12.0,
5189                            font_family: "Helvetica".into(),
5190                            font_weight: 400,
5191                            font_style: FontStyle::Normal,
5192                            char_value: 'H',
5193                            color: None,
5194                            href: None,
5195                            text_decoration: TextDecoration::None,
5196                            letter_spacing: 0.0,
5197                            cluster_text: None,
5198                        }],
5199                        word_spacing: 0.0,
5200                    }],
5201                    color: Color::BLACK,
5202                    text_decoration: TextDecoration::None,
5203                    opacity: 1.0,
5204                },
5205                children: vec![],
5206                node_type: None,
5207                resolved_style: None,
5208                source_location: None,
5209                href: None,
5210                bookmark: None,
5211                alt: None,
5212                is_header_row: false,
5213                actual_text: None,
5214                list_numbering: None,
5215                col_span: 1,
5216                overflow: Overflow::default(),
5217                opacity: 1.0,
5218            }],
5219            fixed_header: vec![],
5220            fixed_footer: vec![],
5221            watermarks: vec![],
5222            config: PageConfig::default(),
5223            page_name: None,
5224        }];
5225
5226        let metadata = Metadata::default();
5227        let (bytes, _warnings) = writer
5228            .write(
5229                &pages,
5230                &metadata,
5231                &font_context,
5232                false,
5233                None,
5234                false,
5235                None,
5236                &[],
5237                None,
5238                false,
5239                crate::model::PdfVersion::V1_7,
5240                false,
5241            )
5242            .unwrap();
5243        let text = String::from_utf8_lossy(&bytes);
5244
5245        // Standard fonts should use Type1, not CIDFontType2
5246        assert!(
5247            text.contains("/Type1"),
5248            "Standard font should use Type1 subtype"
5249        );
5250        assert!(
5251            !text.contains("CIDFontType2"),
5252            "Standard font should not use CIDFontType2"
5253        );
5254    }
5255}