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