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