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        // Horizontal content clip (`PageConfig.clip_content_x`): the paged
1353        // equivalent of `body { overflow-x: hidden }`. X is clipped to the
1354        // content box; Y spans the full page so nothing vertical is lost.
1355        let clip_x = page.config.clip_content_x;
1356        if clip_x {
1357            let x = page.config.margin.left;
1358            let w = page.width - page.config.margin.left - page.config.margin.right;
1359            stream.push_str(&format!(
1360                "q\n{:.2} 0 {:.2} {:.2} re W n\n",
1361                x, w, page.height
1362            ));
1363        }
1364
1365        for element in &page.elements {
1366            self.write_element(
1367                &mut stream,
1368                element,
1369                page_height,
1370                builder,
1371                page_idx,
1372                &mut element_counter,
1373                &mut gradient_counter,
1374                page_number,
1375                total_pages,
1376                tag_builder.as_deref_mut(),
1377                flatten_forms,
1378            );
1379        }
1380
1381        if clip_x {
1382            stream.push_str("Q\n");
1383        }
1384
1385        stream
1386    }
1387
1388    /// Write a single layout element as PDF operators.
1389    #[allow(clippy::too_many_arguments)]
1390    #[allow(clippy::too_many_arguments)]
1391    fn write_element(
1392        &self,
1393        stream: &mut String,
1394        element: &LayoutElement,
1395        page_height: f64,
1396        builder: &PdfBuilder,
1397        page_idx: usize,
1398        element_counter: &mut usize,
1399        gradient_counter: &mut usize,
1400        page_number: usize,
1401        total_pages: usize,
1402        mut tag_builder: Option<&mut tagged::TagBuilder>,
1403        flatten_forms: bool,
1404    ) {
1405        // Tagged PDF: emit BDC (begin marked content) for elements with a node_type,
1406        // or /Artifact BMC for decorative elements (watermarks, untagged drawing).
1407        let mut is_artifact = false;
1408        let tagged_mcid = if let Some(ref mut tb) = tag_builder {
1409            if let Some(ref nt) = element.node_type {
1410                if nt == "Watermark" {
1411                    // Watermarks are decorative — mark as artifact, not structure
1412                    let _ = writeln!(stream, "/Artifact BMC");
1413                    is_artifact = true;
1414                    None
1415                } else {
1416                    let is_header = element.is_header_row;
1417                    let href = element.href.as_deref();
1418                    let mcid = tb.begin_element(
1419                        nt,
1420                        is_header,
1421                        element.alt.as_deref(),
1422                        page_idx,
1423                        href,
1424                        element.col_span,
1425                    );
1426                    // An href'd element tags as /Link (see begin_element); the
1427                    // BDC role must match the structure role, so key on href too.
1428                    let role = if href.is_some() {
1429                        "Link"
1430                    } else {
1431                        tb.map_role_public(nt, is_header)
1432                    };
1433                    let _ = writeln!(stream, "/{} <</MCID {}>> BDC", role, mcid);
1434                    Some(mcid)
1435                }
1436            } else if !matches!(element.draw, DrawCommand::None) {
1437                // No node_type but has drawing — wrap as artifact
1438                let _ = writeln!(stream, "/Artifact BMC");
1439                is_artifact = true;
1440                None
1441            } else {
1442                None
1443            }
1444        } else {
1445            None
1446        };
1447
1448        // Element-level opacity wrap. Open `q\n/GS{n} gs` AFTER the BMC/BDC
1449        // marker block (so opacity affects content, not the marker), and
1450        // close the matching `Q` BEFORE the EMC. The wrap encompasses both
1451        // the element's own DrawCommand emission AND the recursion into
1452        // `element.children`, so descendants render at the cumulative
1453        // alpha (PDF graphics state stack multiplies naturally — a 0.5
1454        // child of a 0.5 parent renders at effective 0.25).
1455        let needs_element_opacity = element.opacity < 1.0;
1456        if needs_element_opacity {
1457            if let Some((_, gs_name)) = builder.ext_gstate_map.get(&element.opacity.to_bits()) {
1458                let _ = writeln!(stream, "q\n/{} gs", gs_name);
1459            }
1460        }
1461
1462        // CSS-style `transform` wrap. Sits INSIDE the opacity wrap so the
1463        // opacity applies to the transformed output. Layout flow is NOT
1464        // affected by the transform (matches CSS) — element.x/y/width/height
1465        // are still the axis-aligned box; the transform is paint-only and
1466        // also propagates to children via the graphics state stack.
1467        let transform_ops: &[TransformOp] = element
1468            .resolved_style
1469            .as_ref()
1470            .map(|s| s.transform.as_slice())
1471            .unwrap_or(&[]);
1472        let has_transform = !transform_ops.is_empty();
1473        if has_transform {
1474            let rs = element.resolved_style.as_ref().unwrap();
1475            let pdf_x = element.x;
1476            let pdf_y_bottom = page_height - element.y - element.height;
1477            let (ox_frac, oy_frac) = rs.transform_origin;
1478            let origin_x = pdf_x + element.width * ox_frac;
1479            // transform_origin's y is 0=top / 1=bottom in layout (CSS) space.
1480            // Flip for PDF (1=top / 0=bottom).
1481            let origin_y = pdf_y_bottom + (1.0 - oy_frac) * element.height;
1482
1483            let _ = writeln!(stream, "q");
1484            // Shift origin point to PDF (0,0) so subsequent transforms pivot there.
1485            let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", -origin_x, -origin_y);
1486            // User transforms: emit in REVERSE of the CSS list order. CSS lists
1487            // transforms left-to-right with the LAST one applied first
1488            // (closest to the point being drawn). PDF `cm` left-multiplies the
1489            // CTM, so the FIRST emitted cm becomes the innermost. Reversing
1490            // makes the leftmost CSS transform the last cm emitted = outermost
1491            // multiplication = applied last to a point — which matches "first
1492            // listed wraps everything inside it" semantics.
1493            for op in transform_ops.iter().rev() {
1494                match op {
1495                    TransformOp::Rotate { deg } => {
1496                        // CSS rotates clockwise in screen space. With PDF's
1497                        // flipped y-axis, the same matrix would rotate
1498                        // counter-clockwise visually. Negate the angle so a
1499                        // CSS `rotate(45deg)` looks identical in the PDF.
1500                        let theta = (-deg).to_radians();
1501                        let c = theta.cos();
1502                        let s = theta.sin();
1503                        let _ = writeln!(stream, "{:.6} {:.6} {:.6} {:.6} 0 0 cm", c, s, -s, c);
1504                    }
1505                    TransformOp::Scale { x, y } => {
1506                        let _ = writeln!(stream, "{:.6} 0 0 {:.6} 0 0 cm", x, y);
1507                    }
1508                    TransformOp::Translate { x, y } => {
1509                        // CSS y is down, PDF y is up — negate the y component.
1510                        let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", x, -y);
1511                    }
1512                }
1513            }
1514            // Shift origin back to its real position.
1515            let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", origin_x, origin_y);
1516        }
1517
1518        match &element.draw {
1519            DrawCommand::None => {}
1520
1521            DrawCommand::Rect {
1522                background,
1523                border_width,
1524                border_color,
1525                border_style,
1526                border_radius,
1527                opacity,
1528                box_shadow,
1529                background_gradient,
1530            } => {
1531                let x = element.x;
1532                let y = page_height - element.y - element.height;
1533                let w = element.width;
1534                let h = element.height;
1535
1536                // Apply opacity via ExtGState
1537                let needs_opacity = *opacity < 1.0;
1538                if needs_opacity {
1539                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1540                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
1541                    }
1542                }
1543
1544                // Box shadow: paint a filled rect offset by (offsetX, offsetY)
1545                // BEFORE the background so the shadow sits behind. Shadow
1546                // color alpha goes through the per-shadow ExtGState. Shadow
1547                // path uses the same border_radius as the element so rounded
1548                // boxes get rounded shadows.
1549                if let Some(shadow) = box_shadow {
1550                    if shadow.color.a > 0.0 {
1551                        // PDF y-axis is flipped vs CSS, so a positive
1552                        // offsetY (CSS: shadow goes down) → subtract from
1553                        // pdf_y to move the shadow rect downward in
1554                        // visual terms.
1555                        let sx = x + shadow.offset_x;
1556                        let sy = y - shadow.offset_y;
1557                        let needs_shadow_alpha = shadow.color.a < 1.0;
1558                        if needs_shadow_alpha {
1559                            if let Some((_, gs_name)) =
1560                                builder.ext_gstate_map.get(&shadow.color.a.to_bits())
1561                            {
1562                                let _ = writeln!(stream, "q\n/{} gs", gs_name);
1563                            } else {
1564                                let _ = writeln!(stream, "q");
1565                            }
1566                        } else {
1567                            let _ = writeln!(stream, "q");
1568                        }
1569                        let _ = writeln!(
1570                            stream,
1571                            "{:.3} {:.3} {:.3} rg",
1572                            shadow.color.r, shadow.color.g, shadow.color.b
1573                        );
1574                        if border_radius.top_left > 0.0
1575                            || border_radius.top_right > 0.0
1576                            || border_radius.bottom_right > 0.0
1577                            || border_radius.bottom_left > 0.0
1578                        {
1579                            self.write_rounded_rect(stream, sx, sy, w, h, border_radius);
1580                        } else {
1581                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", sx, sy, w, h);
1582                        }
1583                        let _ = writeln!(stream, "f\nQ");
1584                    }
1585                }
1586
1587                // Background paint: gradient takes precedence over the
1588                // solid color when both are set. Gradient emission uses
1589                // `q + clip path + cm + sh + Q`; the cm translate moves
1590                // the shading's local 0,0 to the rect's bottom-left so
1591                // the Coords (computed during register_shadings) line up.
1592                if background_gradient.is_some() {
1593                    let key = (page_idx, *gradient_counter);
1594                    *gradient_counter += 1;
1595                    if let Some((_, sh_name)) = builder.shading_map.get(&key) {
1596                        let _ = writeln!(stream, "q");
1597                        // Clip to the rect (rounded if borderRadius set).
1598                        if border_radius.top_left > 0.0
1599                            || border_radius.top_right > 0.0
1600                            || border_radius.bottom_right > 0.0
1601                            || border_radius.bottom_left > 0.0
1602                        {
1603                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1604                            let _ = writeln!(stream, "W n");
1605                        } else {
1606                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re W n", x, y, w, h);
1607                        }
1608                        // Translate so the shading's local 0,0 sits at
1609                        // the rect's bottom-left.
1610                        let _ =
1611                            writeln!(stream, "1 0 0 1 {:.3} {:.3} cm\n/{} sh\nQ", x, y, sh_name);
1612                    }
1613                } else if let Some(bg) = background {
1614                    if bg.a > 0.0 {
1615                        let _ = writeln!(stream, "q\n{:.3} {:.3} {:.3} rg", bg.r, bg.g, bg.b);
1616
1617                        if border_radius.top_left > 0.0 {
1618                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1619                        } else {
1620                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
1621                        }
1622
1623                        let _ = writeln!(stream, "f\nQ");
1624                    }
1625                }
1626
1627                let bw = border_width;
1628                if bw.top > 0.0 || bw.right > 0.0 || bw.bottom > 0.0 || bw.left > 0.0 {
1629                    use crate::style::BorderStyle::Solid;
1630                    let all_solid = border_style.top == Solid
1631                        && border_style.right == Solid
1632                        && border_style.bottom == Solid
1633                        && border_style.left == Solid;
1634                    // The uniform fast path draws one rounded/plain rect stroke;
1635                    // it only applies to a solid, equal-width border. Any
1636                    // dashed/dotted or mixed-style border goes per-side (which
1637                    // also emits the dash pattern; radius is dropped there, per
1638                    // Chrome's own dashed-with-radius handling).
1639                    if all_solid
1640                        && (bw.top - bw.right).abs() < 0.001
1641                        && (bw.right - bw.bottom).abs() < 0.001
1642                        && (bw.bottom - bw.left).abs() < 0.001
1643                    {
1644                        let bc = &border_color.top;
1645                        let _ = writeln!(
1646                            stream,
1647                            "q\n{:.3} {:.3} {:.3} RG\n{:.2} w",
1648                            bc.r, bc.g, bc.b, bw.top
1649                        );
1650
1651                        if border_radius.top_left > 0.0 {
1652                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1653                        } else {
1654                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
1655                        }
1656
1657                        let _ = writeln!(stream, "S\nQ");
1658                    } else {
1659                        self.write_border_sides(stream, x, y, w, h, bw, border_color, border_style);
1660                    }
1661                }
1662
1663                if needs_opacity {
1664                    let _ = writeln!(stream, "Q");
1665                }
1666            }
1667
1668            DrawCommand::Text {
1669                lines,
1670                color,
1671                text_decoration,
1672                opacity,
1673            } => {
1674                // Apply opacity via ExtGState
1675                let needs_opacity = *opacity < 1.0;
1676                if needs_opacity {
1677                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1678                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
1679                    }
1680                }
1681
1682                for line in lines {
1683                    if line.glyphs.is_empty() {
1684                        continue;
1685                    }
1686
1687                    // Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
1688                    // to support multi-font text runs
1689                    let groups = Self::group_glyphs_by_style(&line.glyphs);
1690                    let pdf_y = page_height - line.y;
1691
1692                    let _ = writeln!(stream, "BT");
1693
1694                    // Set word spacing for justification (PDF Tw operator)
1695                    if line.word_spacing.abs() > 0.001 {
1696                        let _ = writeln!(stream, "{:.4} Tw", line.word_spacing);
1697                    }
1698
1699                    // Track current text matrix position for relative Td moves
1700                    let mut tm_x = 0.0_f64;
1701                    let mut tm_y = 0.0_f64;
1702                    let mut x_cursor = line.x;
1703
1704                    // Track group spans for per-group text decoration
1705                    let mut group_spans: Vec<(f64, f64, TextDecoration, Color)> = Vec::new();
1706
1707                    for group in &groups {
1708                        let first = &group[0];
1709                        let glyph_color = first.color.unwrap_or(*color);
1710
1711                        let idx = self.font_index(
1712                            &first.font_family,
1713                            first.font_weight,
1714                            first.font_style,
1715                            &builder.font_objects,
1716                        );
1717                        let italic =
1718                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
1719                        let font_key = FontKey {
1720                            family: first.font_family.to_string(),
1721                            weight: first.font_weight,
1722                            italic,
1723                        };
1724                        let font_name = format!("F{}", idx);
1725
1726                        // Td is relative to current text matrix position
1727                        let dx = x_cursor - tm_x;
1728                        let dy = pdf_y - tm_y;
1729                        let _ = writeln!(
1730                            stream,
1731                            "{:.3} {:.3} {:.3} rg\n/{} {:.1} Tf\n{:.2} Tc\n{:.2} {:.2} Td",
1732                            glyph_color.r,
1733                            glyph_color.g,
1734                            glyph_color.b,
1735                            font_name,
1736                            first.font_size,
1737                            first.letter_spacing,
1738                            dx,
1739                            dy
1740                        );
1741                        tm_x = x_cursor;
1742                        tm_y = pdf_y;
1743
1744                        // Check for page number sentinel characters
1745                        let raw_text: String = group.iter().map(|g| g.char_value).collect();
1746                        let has_placeholder = raw_text.contains(PAGE_NUMBER_SENTINEL)
1747                            || raw_text.contains(TOTAL_PAGES_SENTINEL);
1748
1749                        let is_custom = builder.custom_font_data.contains_key(&font_key);
1750
1751                        if is_custom {
1752                            if let Some(embed_data) = builder.custom_font_data.get(&font_key) {
1753                                let mut hex = String::new();
1754                                if has_placeholder {
1755                                    // Sentinel text: replace with actual values and use char→gid fallback
1756                                    let pn = PAGE_NUMBER_SENTINEL.to_string();
1757                                    let tp = TOTAL_PAGES_SENTINEL.to_string();
1758                                    let text_after = raw_text
1759                                        .replace(&pn, &page_number.to_string())
1760                                        .replace(&tp, &total_pages.to_string());
1761                                    for ch in text_after.chars() {
1762                                        let gid =
1763                                            embed_data.char_to_gid.get(&ch).copied().unwrap_or(0);
1764                                        let _ = write!(hex, "{:04X}", gid);
1765                                    }
1766                                } else {
1767                                    // Shaped text: use glyph IDs directly (remapped through subset)
1768                                    for g in group.iter() {
1769                                        let new_gid = embed_data
1770                                            .gid_remap
1771                                            .get(&g.glyph_id)
1772                                            .copied()
1773                                            .unwrap_or_else(|| {
1774                                                // Fallback: try char→gid
1775                                                embed_data
1776                                                    .char_to_gid
1777                                                    .get(&g.char_value)
1778                                                    .copied()
1779                                                    .unwrap_or(0)
1780                                            });
1781                                        let _ = write!(hex, "{:04X}", new_gid);
1782                                    }
1783                                }
1784                                let _ = writeln!(stream, "<{}> Tj", hex);
1785                            } else {
1786                                let _ = writeln!(stream, "<> Tj");
1787                            }
1788                        } else {
1789                            let pn = PAGE_NUMBER_SENTINEL.to_string();
1790                            let tp = TOTAL_PAGES_SENTINEL.to_string();
1791                            let text_after = raw_text
1792                                .replace(&pn, &page_number.to_string())
1793                                .replace(&tp, &total_pages.to_string());
1794                            let mut text_str = String::new();
1795                            for ch in text_after.chars() {
1796                                let b = Self::unicode_to_winansi(ch).unwrap_or(b'?');
1797                                match b {
1798                                    b'\\' => text_str.push_str("\\\\"),
1799                                    b'(' => text_str.push_str("\\("),
1800                                    b')' => text_str.push_str("\\)"),
1801                                    0x20..=0x7E => text_str.push(b as char),
1802                                    _ => {
1803                                        let _ = write!(text_str, "\\{:03o}", b);
1804                                    }
1805                                }
1806                            }
1807                            let _ = writeln!(stream, "({}) Tj", text_str);
1808                        }
1809
1810                        // Record span for per-group text decoration
1811                        let group_start_x = x_cursor;
1812
1813                        // Advance x_cursor past this group using shaped advances
1814                        // Account for word_spacing on spaces (Tw adds to each space char)
1815                        if let Some(last) = group.last() {
1816                            let space_count_in_group =
1817                                group.iter().filter(|g| g.char_value == ' ').count();
1818                            x_cursor = line.x
1819                                + last.x_offset
1820                                + last.x_advance
1821                                + space_count_in_group as f64 * line.word_spacing;
1822                        }
1823
1824                        // Check if this group has text decoration
1825                        let group_dec = first.text_decoration;
1826                        if !matches!(group_dec, TextDecoration::None) {
1827                            group_spans.push((group_start_x, x_cursor, group_dec, glyph_color));
1828                        }
1829                    }
1830
1831                    let _ = writeln!(stream, "ET");
1832
1833                    // Draw per-group text decorations
1834                    for (span_x, span_end_x, dec, dec_color) in &group_spans {
1835                        match dec {
1836                            TextDecoration::Underline => {
1837                                let underline_y = pdf_y - 1.5;
1838                                let _ = write!(
1839                                    stream,
1840                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1841                                    dec_color.r, dec_color.g, dec_color.b,
1842                                    span_x, underline_y,
1843                                    span_end_x, underline_y
1844                                );
1845                            }
1846                            TextDecoration::LineThrough => {
1847                                let first_size =
1848                                    line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
1849                                let strikethrough_y = pdf_y + first_size * 0.3;
1850                                let _ = write!(
1851                                    stream,
1852                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1853                                    dec_color.r, dec_color.g, dec_color.b,
1854                                    span_x, strikethrough_y,
1855                                    span_end_x, strikethrough_y
1856                                );
1857                            }
1858                            TextDecoration::None => {}
1859                        }
1860                    }
1861
1862                    // Also handle whole-line decoration from parent style
1863                    if group_spans.is_empty() {
1864                        if matches!(text_decoration, TextDecoration::Underline) {
1865                            let underline_y = pdf_y - 1.5;
1866                            let _ = write!(
1867                                stream,
1868                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1869                                color.r, color.g, color.b,
1870                                line.x, underline_y,
1871                                line.x + line.width, underline_y
1872                            );
1873                        }
1874                        if matches!(text_decoration, TextDecoration::LineThrough) {
1875                            let first_size =
1876                                line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
1877                            let strikethrough_y = pdf_y + first_size * 0.3;
1878                            let _ = write!(
1879                                stream,
1880                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1881                                color.r, color.g, color.b,
1882                                line.x, strikethrough_y,
1883                                line.x + line.width, strikethrough_y
1884                            );
1885                        }
1886                    }
1887                }
1888
1889                if needs_opacity {
1890                    let _ = writeln!(stream, "Q");
1891                }
1892            }
1893
1894            DrawCommand::Image { .. } => {
1895                let elem_idx = *element_counter;
1896                *element_counter += 1;
1897                if let Some(&img_idx) = builder.image_index_map.get(&(page_idx, elem_idx)) {
1898                    let x = element.x;
1899                    let y = page_height - element.y - element.height;
1900                    let _ = write!(
1901                        stream,
1902                        "q\n{:.4} 0 0 {:.4} {:.2} {:.2} cm\n/Im{} Do\nQ\n",
1903                        element.width, element.height, x, y, img_idx
1904                    );
1905                } else {
1906                    // Fallback: grey placeholder if image index not found
1907                    let x = element.x;
1908                    let y = page_height - element.y - element.height;
1909                    let _ = write!(
1910                        stream,
1911                        "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
1912                        x, y, element.width, element.height
1913                    );
1914                }
1915                if tagged_mcid.is_some() {
1916                    let _ = writeln!(stream, "EMC");
1917                    if let Some(ref mut tb) = tag_builder {
1918                        tb.end_element();
1919                    }
1920                } else if is_artifact {
1921                    let _ = writeln!(stream, "EMC");
1922                }
1923                return; // Don't increment counter again for children
1924            }
1925
1926            DrawCommand::ImagePlaceholder => {
1927                *element_counter += 1;
1928                let x = element.x;
1929                let y = page_height - element.y - element.height;
1930                let _ = write!(
1931                    stream,
1932                    "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
1933                    x, y, element.width, element.height
1934                );
1935                if tagged_mcid.is_some() {
1936                    let _ = writeln!(stream, "EMC");
1937                    if let Some(ref mut tb) = tag_builder {
1938                        tb.end_element();
1939                    }
1940                } else if is_artifact {
1941                    let _ = writeln!(stream, "EMC");
1942                }
1943                return;
1944            }
1945
1946            DrawCommand::Svg {
1947                commands,
1948                width: _svg_w,
1949                height: _svg_h,
1950                viewbox_min_x,
1951                viewbox_min_y,
1952                viewbox_width,
1953                viewbox_height,
1954                clip,
1955            } => {
1956                let x = element.x;
1957                let y = page_height - element.y - element.height;
1958
1959                // Save state, translate to position
1960                let _ = writeln!(stream, "q");
1961                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", x, y);
1962
1963                // SVG viewport algorithm with `xMidYMid meet` as the default
1964                // preserveAspectRatio: uniform scale to fit, center the
1965                // remainder. When viewBox matches the display box (the
1966                // no-viewBox case, populated as 0/0/w/h in layout) the scale
1967                // is 1 and the translate is 0 — behavior unchanged.
1968                if *viewbox_width > 0.0 && *viewbox_height > 0.0 {
1969                    let raw_sx = element.width / *viewbox_width;
1970                    let raw_sy = element.height / *viewbox_height;
1971                    let s = raw_sx.min(raw_sy);
1972                    let tx = (element.width - s * *viewbox_width) / 2.0;
1973                    let ty = (element.height - s * *viewbox_height) / 2.0;
1974                    let _ = writeln!(stream, "{:.4} 0 0 {:.4} {:.2} {:.2} cm", s, s, tx, ty);
1975                }
1976
1977                // Flip Y so SVG-coord Y-down becomes PDF Y-up. The flip
1978                // height is the viewBox height (we're now in viewBox space).
1979                let _ = writeln!(stream, "1 0 0 -1 0 {:.2} cm", *viewbox_height);
1980
1981                // Shift origin so the viewBox's (min_x, min_y) lands at (0, 0).
1982                if *viewbox_min_x != 0.0 || *viewbox_min_y != 0.0 {
1983                    let _ = writeln!(
1984                        stream,
1985                        "1 0 0 1 {:.2} {:.2} cm",
1986                        -*viewbox_min_x, -*viewbox_min_y
1987                    );
1988                }
1989
1990                // Clip to viewBox bounds (Canvas always clips, SVG does not).
1991                if *clip {
1992                    let _ = writeln!(
1993                        stream,
1994                        "{:.2} {:.2} {:.2} {:.2} re W n",
1995                        *viewbox_min_x, *viewbox_min_y, *viewbox_width, *viewbox_height
1996                    );
1997                }
1998
1999                Self::write_svg_commands(stream, commands, &builder.ext_gstate_map);
2000
2001                let _ = writeln!(stream, "Q");
2002                if tagged_mcid.is_some() {
2003                    let _ = writeln!(stream, "EMC");
2004                    if let Some(ref mut tb) = tag_builder {
2005                        tb.end_element();
2006                    }
2007                } else if is_artifact {
2008                    let _ = writeln!(stream, "EMC");
2009                }
2010                return;
2011            }
2012
2013            DrawCommand::Barcode {
2014                bars,
2015                bar_width,
2016                height,
2017                color,
2018            } => {
2019                *element_counter += 1;
2020                let _ = writeln!(stream, "q");
2021                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2022                for (i, &bar) in bars.iter().enumerate() {
2023                    if bar == 1 {
2024                        let bx = element.x + i as f64 * bar_width;
2025                        let by = page_height - element.y - height;
2026                        let _ = writeln!(
2027                            stream,
2028                            "{:.2} {:.2} {:.2} {:.2} re",
2029                            bx, by, bar_width, height
2030                        );
2031                    }
2032                }
2033                let _ = writeln!(stream, "f\nQ");
2034                if tagged_mcid.is_some() {
2035                    let _ = writeln!(stream, "EMC");
2036                    if let Some(ref mut tb) = tag_builder {
2037                        tb.end_element();
2038                    }
2039                } else if is_artifact {
2040                    let _ = writeln!(stream, "EMC");
2041                }
2042                return;
2043            }
2044
2045            DrawCommand::QrCode {
2046                modules,
2047                module_size,
2048                color,
2049            } => {
2050                *element_counter += 1;
2051                let _ = writeln!(stream, "q");
2052                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2053                for (row_idx, row) in modules.iter().enumerate() {
2054                    for (col_idx, &dark) in row.iter().enumerate() {
2055                        if dark {
2056                            let mx = element.x + col_idx as f64 * module_size;
2057                            let my = page_height - element.y - (row_idx as f64 + 1.0) * module_size;
2058                            let _ = writeln!(
2059                                stream,
2060                                "{:.2} {:.2} {:.2} {:.2} re",
2061                                mx, my, module_size, module_size
2062                            );
2063                        }
2064                    }
2065                }
2066                let _ = writeln!(stream, "f\nQ");
2067                if tagged_mcid.is_some() {
2068                    let _ = writeln!(stream, "EMC");
2069                    if let Some(ref mut tb) = tag_builder {
2070                        tb.end_element();
2071                    }
2072                } else if is_artifact {
2073                    let _ = writeln!(stream, "EMC");
2074                }
2075                return;
2076            }
2077
2078            DrawCommand::Chart { primitives } => {
2079                *element_counter += 1;
2080                let _ = writeln!(stream, "q");
2081                // Set up coordinate transform: Y-flip so chart primitives use top-left origin
2082                let _ = writeln!(
2083                    stream,
2084                    "1 0 0 -1 {:.4} {:.4} cm",
2085                    element.x,
2086                    page_height - element.y
2087                );
2088
2089                for prim in primitives {
2090                    write_chart_primitive(stream, prim, element.height, builder);
2091                }
2092
2093                let _ = writeln!(stream, "Q");
2094                if tagged_mcid.is_some() {
2095                    let _ = writeln!(stream, "EMC");
2096                    if let Some(ref mut tb) = tag_builder {
2097                        tb.end_element();
2098                    }
2099                } else if is_artifact {
2100                    let _ = writeln!(stream, "EMC");
2101                }
2102                return;
2103            }
2104
2105            DrawCommand::Watermark {
2106                lines,
2107                color,
2108                opacity,
2109                angle_rad,
2110                font_family: _,
2111            } => {
2112                let _ = writeln!(stream, "q");
2113                // Set opacity via ExtGState if not fully opaque
2114                if *opacity < 1.0 {
2115                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
2116                        let _ = writeln!(stream, "/{} gs", gs_name);
2117                    }
2118                }
2119                // Translate to center position (element.x, element.y = page center)
2120                let pdf_cx = element.x;
2121                let pdf_cy = page_height - element.y;
2122                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", pdf_cx, pdf_cy);
2123                // Rotate by angle
2124                let cos_a = angle_rad.cos();
2125                let sin_a = angle_rad.sin();
2126                let _ = writeln!(
2127                    stream,
2128                    "{:.6} {:.6} {:.6} {:.6} 0 0 cm",
2129                    cos_a, sin_a, -sin_a, cos_a
2130                );
2131                // Render text centered on origin
2132                let _ = writeln!(stream, "BT");
2133                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2134                if let Some(line) = lines.first() {
2135                    let groups = Self::group_glyphs_by_style(&line.glyphs);
2136                    let text_width = line.width;
2137                    let cap_height = line.height * 0.7;
2138                    let _ = writeln!(
2139                        stream,
2140                        "{:.2} {:.2} Td",
2141                        -text_width / 2.0,
2142                        -cap_height / 2.0
2143                    );
2144                    for group in &groups {
2145                        let first = &group[0];
2146                        let italic =
2147                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
2148                        let fk = FontKey {
2149                            family: first.font_family.to_string(),
2150                            weight: first.font_weight,
2151                            italic,
2152                        };
2153                        let idx = self.font_index(
2154                            &first.font_family,
2155                            first.font_weight,
2156                            first.font_style,
2157                            &builder.font_objects,
2158                        );
2159                        let font_name = format!("F{}", idx);
2160                        let _ = writeln!(stream, "/{} {:.1} Tf", font_name, first.font_size);
2161                        let is_custom = builder.custom_font_data.contains_key(&fk);
2162                        if is_custom {
2163                            if let Some(embed_data) = builder.custom_font_data.get(&fk) {
2164                                let mut hex = String::new();
2165                                for g in group.iter() {
2166                                    let gid =
2167                                        embed_data.gid_remap.get(&g.glyph_id).copied().unwrap_or(0);
2168                                    let _ = write!(hex, "{:04X}", gid);
2169                                }
2170                                let _ = writeln!(stream, "<{}> Tj", hex);
2171                            }
2172                        } else {
2173                            let hex_str: String = group
2174                                .iter()
2175                                .map(|g| format!("{:02X}", g.glyph_id as u8))
2176                                .collect();
2177                            let _ = writeln!(stream, "<{}> Tj", hex_str);
2178                        }
2179                    }
2180                }
2181                let _ = writeln!(stream, "ET");
2182                let _ = writeln!(stream, "Q");
2183                if tagged_mcid.is_some() {
2184                    let _ = writeln!(stream, "EMC");
2185                    if let Some(ref mut tb) = tag_builder {
2186                        tb.end_element();
2187                    }
2188                } else if is_artifact {
2189                    let _ = writeln!(stream, "EMC");
2190                }
2191                return;
2192            }
2193
2194            DrawCommand::FormField { field_type, .. } => {
2195                // Draw a visual placeholder so form fields are visible in previews
2196                // and non-form-aware viewers. When flatten_forms is true, also render
2197                // the field value as static text and skip interactive widgets.
2198                let pdf_x = element.x;
2199                let pdf_y = page_height - element.y - element.height;
2200                let w = element.width;
2201                let h = element.height;
2202                let _ = writeln!(stream, "q");
2203                match field_type {
2204                    FormFieldType::Checkbox { checked, .. } => {
2205                        // Draw a border square
2206                        let _ = writeln!(stream, "0.6 0.6 0.6 RG"); // grey stroke
2207                        let _ = writeln!(stream, "0.5 w");
2208                        let _ =
2209                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re S", pdf_x, pdf_y, w, h);
2210                        if *checked {
2211                            // Draw a checkmark scaled to field dimensions
2212                            let _ = writeln!(stream, "0.2 0.2 0.2 rg");
2213                            let sx = w / 14.0;
2214                            let sy = h / 14.0;
2215                            let _ = writeln!(
2216                                stream,
2217                                "{:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l f",
2218                                pdf_x + 2.0 * sx, pdf_y + 6.0 * sy,
2219                                pdf_x + 5.5 * sx, pdf_y + 2.0 * sy,
2220                                pdf_x + 12.0 * sx, pdf_y + 11.0 * sy,
2221                                pdf_x + 11.0 * sx, pdf_y + 12.0 * sy,
2222                                pdf_x + 5.5 * sx, pdf_y + 4.5 * sy,
2223                                pdf_x + 3.0 * sx, pdf_y + 7.0 * sy,
2224                                pdf_x + 2.0 * sx, pdf_y + 6.0 * sy,
2225                            );
2226                        }
2227                    }
2228                    FormFieldType::RadioButton { checked, .. } => {
2229                        // Draw a border square
2230                        let _ = writeln!(stream, "0.6 0.6 0.6 RG"); // grey stroke
2231                        let _ = writeln!(stream, "0.5 w");
2232                        let _ =
2233                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re S", pdf_x, pdf_y, w, h);
2234                        if *checked {
2235                            // Draw a filled circle
2236                            let cx = pdf_x + w / 2.0;
2237                            let cy = pdf_y + h / 2.0;
2238                            let r = (w.min(h) / 2.0) * 0.6;
2239                            let k = r * 0.5523;
2240                            let _ = writeln!(stream, "0.2 0.2 0.2 rg");
2241                            let _ = writeln!(
2242                                stream,
2243                                "{:.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",
2244                                cx, cy + r,
2245                                cx + k, cy + r, cx + r, cy + k, cx + r, cy,
2246                                cx + r, cy - k, cx + k, cy - r, cx, cy - r,
2247                                cx - k, cy - r, cx - r, cy - k, cx - r, cy,
2248                                cx - r, cy + k, cx - k, cy + r, cx, cy + r,
2249                            );
2250                        }
2251                    }
2252                    FormFieldType::TextField {
2253                        value,
2254                        placeholder,
2255                        font_size,
2256                        multiline,
2257                        password,
2258                        ..
2259                    } => {
2260                        // White fill + grey border
2261                        let _ = writeln!(stream, "1 1 1 rg");
2262                        let _ = writeln!(stream, "0.6 0.6 0.6 RG");
2263                        let _ = writeln!(stream, "0.5 w");
2264                        let _ =
2265                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re B", pdf_x, pdf_y, w, h);
2266                        // Render value text when flattening
2267                        if flatten_forms {
2268                            let has_value = value.as_ref().is_some_and(|v| !v.is_empty());
2269                            if has_value {
2270                                let val = value.as_ref().unwrap();
2271                                let display_text = if *password {
2272                                    "\u{2022}".repeat(val.len())
2273                                } else {
2274                                    val.clone()
2275                                };
2276                                let font_idx = builder
2277                                    .font_objects
2278                                    .iter()
2279                                    .enumerate()
2280                                    .find(|(_, (key, _))| {
2281                                        key.family == "Helvetica"
2282                                            && key.weight == 400
2283                                            && !key.italic
2284                                    })
2285                                    .map(|(i, _)| i)
2286                                    .unwrap_or(0);
2287                                if *multiline {
2288                                    // Simple word-wrap for multiline
2289                                    let metrics = crate::font::StandardFont::Helvetica.metrics();
2290                                    let max_w = w - 4.0;
2291                                    let mut lines: Vec<String> = Vec::new();
2292                                    for paragraph in display_text.split('\n') {
2293                                        let mut line = String::new();
2294                                        let mut line_w = 0.0;
2295                                        for word in paragraph.split_whitespace() {
2296                                            let word_w =
2297                                                metrics.measure_string(word, *font_size, 0.0);
2298                                            let space_w = if line.is_empty() {
2299                                                0.0
2300                                            } else {
2301                                                metrics.measure_string(" ", *font_size, 0.0)
2302                                            };
2303                                            // Word wider than field — break at character boundary
2304                                            if word_w > max_w {
2305                                                let mut char_line = String::new();
2306                                                let mut char_w = 0.0;
2307                                                for ch in word.chars() {
2308                                                    let cw = metrics.char_width(ch, *font_size);
2309                                                    if !char_line.is_empty() && char_w + cw > max_w
2310                                                    {
2311                                                        if !line.is_empty() {
2312                                                            lines.push(line.clone());
2313                                                            line.clear();
2314                                                            line_w = 0.0;
2315                                                        }
2316                                                        lines.push(char_line.clone());
2317                                                        char_line.clear();
2318                                                        char_w = 0.0;
2319                                                    }
2320                                                    char_line.push(ch);
2321                                                    char_w += cw;
2322                                                }
2323                                                // Remaining chars join the current line
2324                                                if !char_line.is_empty() {
2325                                                    if !line.is_empty() {
2326                                                        line.push(' ');
2327                                                        line_w += metrics
2328                                                            .measure_string(" ", *font_size, 0.0);
2329                                                    }
2330                                                    line.push_str(&char_line);
2331                                                    line_w += char_w;
2332                                                }
2333                                                continue;
2334                                            }
2335                                            if !line.is_empty() && line_w + space_w + word_w > max_w
2336                                            {
2337                                                lines.push(line.clone());
2338                                                line.clear();
2339                                                line_w = 0.0;
2340                                            }
2341                                            if !line.is_empty() {
2342                                                line.push(' ');
2343                                                line_w += space_w;
2344                                            }
2345                                            line.push_str(word);
2346                                            line_w += word_w;
2347                                        }
2348                                        if !line.is_empty() {
2349                                            lines.push(line);
2350                                        }
2351                                    }
2352                                    let text_y = pdf_y + h - font_size - 2.0;
2353                                    for (i, line_text) in lines.iter().enumerate() {
2354                                        let ly = text_y - (i as f64) * (font_size * 1.2);
2355                                        if ly < pdf_y {
2356                                            break;
2357                                        }
2358                                        let esc = Self::encode_winansi_text(line_text);
2359                                        let _ = writeln!(
2360                                            stream,
2361                                            "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2362                                            font_idx,
2363                                            font_size,
2364                                            pdf_x + 2.0,
2365                                            ly,
2366                                            esc
2367                                        );
2368                                    }
2369                                } else {
2370                                    let escaped = Self::encode_winansi_text(&display_text);
2371                                    let text_y = pdf_y + (h - font_size) / 2.0;
2372                                    let _ = writeln!(
2373                                        stream,
2374                                        "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2375                                        font_idx,
2376                                        font_size,
2377                                        pdf_x + 2.0,
2378                                        text_y,
2379                                        escaped
2380                                    );
2381                                }
2382                            } else if let Some(ref ph) = placeholder {
2383                                if !ph.is_empty() {
2384                                    // Render placeholder in grey
2385                                    let font_idx = builder
2386                                        .font_objects
2387                                        .iter()
2388                                        .enumerate()
2389                                        .find(|(_, (key, _))| {
2390                                            key.family == "Helvetica"
2391                                                && key.weight == 400
2392                                                && !key.italic
2393                                        })
2394                                        .map(|(i, _)| i)
2395                                        .unwrap_or(0);
2396                                    let escaped = Self::encode_winansi_text(ph);
2397                                    let text_y = pdf_y + (h - font_size) / 2.0;
2398                                    let _ = writeln!(
2399                                        stream,
2400                                        "BT /F{} {:.1} Tf 0.6 g {:.2} {:.2} Td ({}) Tj ET",
2401                                        font_idx,
2402                                        font_size,
2403                                        pdf_x + 2.0,
2404                                        text_y,
2405                                        escaped
2406                                    );
2407                                }
2408                            }
2409                        }
2410                    }
2411                    FormFieldType::Dropdown {
2412                        value, font_size, ..
2413                    } => {
2414                        // White fill + grey border
2415                        let _ = writeln!(stream, "1 1 1 rg");
2416                        let _ = writeln!(stream, "0.6 0.6 0.6 RG");
2417                        let _ = writeln!(stream, "0.5 w");
2418                        let _ =
2419                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re B", pdf_x, pdf_y, w, h);
2420                        // Render selected value text when flattening
2421                        if flatten_forms {
2422                            if let Some(ref val) = value {
2423                                if !val.is_empty() {
2424                                    let font_idx = builder
2425                                        .font_objects
2426                                        .iter()
2427                                        .enumerate()
2428                                        .find(|(_, (key, _))| {
2429                                            key.family == "Helvetica"
2430                                                && key.weight == 400
2431                                                && !key.italic
2432                                        })
2433                                        .map(|(i, _)| i)
2434                                        .unwrap_or(0);
2435                                    let escaped = Self::encode_winansi_text(val);
2436                                    let text_y = pdf_y + (h - font_size) / 2.0;
2437                                    let _ = writeln!(
2438                                        stream,
2439                                        "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2440                                        font_idx,
2441                                        font_size,
2442                                        pdf_x + 2.0,
2443                                        text_y,
2444                                        escaped
2445                                    );
2446                                }
2447                            }
2448                        }
2449                    }
2450                }
2451                let _ = writeln!(stream, "Q");
2452            }
2453        }
2454
2455        // Overflow clipping: wrap children in q/clip/Q when overflow is Hidden.
2456        // When the element's Rect has a non-zero border_radius, clip to the
2457        // rounded path so descendants don't visually overflow the rounded
2458        // corners. Plain rectangular clip otherwise.
2459        let clip_overflow = matches!(element.overflow, Overflow::Hidden);
2460        if clip_overflow {
2461            let clip_x = element.x;
2462            let clip_y = page_height - element.y - element.height;
2463            let clip_w = element.width;
2464            let clip_h = element.height;
2465            // Pull border_radius from the Rect DrawCommand if present.
2466            // Other element kinds (Text, Image, Svg, ...) don't carry a
2467            // border_radius — they fall back to a rectangular clip.
2468            let radius = if let DrawCommand::Rect { border_radius, .. } = &element.draw {
2469                Some(border_radius)
2470            } else {
2471                None
2472            };
2473            let has_rounded_corners = radius.is_some_and(|r| {
2474                r.top_left > 0.0 || r.top_right > 0.0 || r.bottom_right > 0.0 || r.bottom_left > 0.0
2475            });
2476            let _ = writeln!(stream, "q");
2477            if has_rounded_corners {
2478                self.write_rounded_rect(stream, clip_x, clip_y, clip_w, clip_h, radius.unwrap());
2479                let _ = writeln!(stream, "W n");
2480            } else {
2481                let _ = writeln!(
2482                    stream,
2483                    "{:.2} {:.2} {:.2} {:.2} re W n",
2484                    clip_x, clip_y, clip_w, clip_h
2485                );
2486            }
2487        }
2488
2489        for child in &element.children {
2490            self.write_element(
2491                stream,
2492                child,
2493                page_height,
2494                builder,
2495                page_idx,
2496                element_counter,
2497                gradient_counter,
2498                page_number,
2499                total_pages,
2500                tag_builder.as_deref_mut(),
2501                flatten_forms,
2502            );
2503        }
2504
2505        if clip_overflow {
2506            let _ = writeln!(stream, "Q");
2507        }
2508
2509        // Close the transform wrap (paired with the inner q above).
2510        if has_transform {
2511            let _ = writeln!(stream, "Q");
2512        }
2513
2514        // Close the element-level opacity wrap (paired with the q above).
2515        // Goes before EMC so the marker boundary is preserved.
2516        if needs_element_opacity {
2517            let _ = writeln!(stream, "Q");
2518        }
2519
2520        // Tagged PDF: emit EMC (end marked content)
2521        if tagged_mcid.is_some() {
2522            let _ = writeln!(stream, "EMC");
2523            if let Some(ref mut tb) = tag_builder {
2524                tb.end_element();
2525            }
2526        } else if is_artifact {
2527            let _ = writeln!(stream, "EMC");
2528        }
2529    }
2530
2531    fn write_rounded_rect(
2532        &self,
2533        stream: &mut String,
2534        x: f64,
2535        y: f64,
2536        w: f64,
2537        h: f64,
2538        r: &crate::style::CornerValues,
2539    ) {
2540        let k = 0.5522847498;
2541
2542        let tl = r.top_left.min(w / 2.0).min(h / 2.0);
2543        let tr = r.top_right.min(w / 2.0).min(h / 2.0);
2544        let br = r.bottom_right.min(w / 2.0).min(h / 2.0);
2545        let bl = r.bottom_left.min(w / 2.0).min(h / 2.0);
2546
2547        let _ = writeln!(stream, "{:.2} {:.2} m", x + bl, y);
2548
2549        let _ = writeln!(stream, "{:.2} {:.2} l", x + w - br, y);
2550        if br > 0.0 {
2551            let _ = writeln!(
2552                stream,
2553                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2554                x + w - br + br * k,
2555                y,
2556                x + w,
2557                y + br - br * k,
2558                x + w,
2559                y + br
2560            );
2561        }
2562
2563        let _ = writeln!(stream, "{:.2} {:.2} l", x + w, y + h - tr);
2564        if tr > 0.0 {
2565            let _ = writeln!(
2566                stream,
2567                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2568                x + w,
2569                y + h - tr + tr * k,
2570                x + w - tr + tr * k,
2571                y + h,
2572                x + w - tr,
2573                y + h
2574            );
2575        }
2576
2577        let _ = writeln!(stream, "{:.2} {:.2} l", x + tl, y + h);
2578        if tl > 0.0 {
2579            let _ = writeln!(
2580                stream,
2581                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2582                x + tl - tl * k,
2583                y + h,
2584                x,
2585                y + h - tl + tl * k,
2586                x,
2587                y + h - tl
2588            );
2589        }
2590
2591        let _ = writeln!(stream, "{:.2} {:.2} l", x, y + bl);
2592        if bl > 0.0 {
2593            let _ = writeln!(
2594                stream,
2595                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2596                x,
2597                y + bl - bl * k,
2598                x + bl - bl * k,
2599                y,
2600                x + bl,
2601                y
2602            );
2603        }
2604
2605        let _ = writeln!(stream, "h");
2606    }
2607
2608    #[allow(clippy::too_many_arguments)]
2609    fn write_border_sides(
2610        &self,
2611        stream: &mut String,
2612        x: f64,
2613        y: f64,
2614        w: f64,
2615        h: f64,
2616        bw: &Edges,
2617        bc: &crate::style::EdgeValues<Color>,
2618        bs: &crate::style::EdgeValues<crate::style::BorderStyle>,
2619    ) {
2620        // PDF dash + line-cap ops for a side, calibrated against Chrome:
2621        //   dashed → dash 2×width, gap 1×width (butt cap)
2622        //   dotted → round-capped dots, diameter 1×width, 2×width centre spacing
2623        // Each side is wrapped in q/Q so the graphics state (cap, dash) resets.
2624        fn dash_ops(style: crate::style::BorderStyle, width: f64) -> String {
2625            use crate::style::BorderStyle::*;
2626            match style {
2627                Solid => String::new(),
2628                Dashed => format!("[{:.2} {:.2}] 0 d\n", width * 2.0, width),
2629                Dotted => format!("1 J\n[0 {:.2}] 0 d\n", width * 2.0),
2630            }
2631        }
2632        // side: (color, width, style, x0,y0, x1,y1)
2633        let sides = [
2634            (bc.top, bw.top, bs.top, x, y + h, x + w, y + h),
2635            (bc.bottom, bw.bottom, bs.bottom, x, y, x + w, y),
2636            (bc.left, bw.left, bs.left, x, y, x, y + h),
2637            (bc.right, bw.right, bs.right, x + w, y, x + w, y + h),
2638        ];
2639        for (color, width, style, x0, y0, x1, y1) in sides {
2640            if width <= 0.0 {
2641                continue;
2642            }
2643            let _ = write!(
2644                stream,
2645                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{}{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2646                color.r,
2647                color.g,
2648                color.b,
2649                width,
2650                dash_ops(style, width),
2651                x0,
2652                y0,
2653                x1,
2654                y1
2655            );
2656        }
2657    }
2658
2659    /// Register fonts used across all pages — each unique (family, weight, italic)
2660    /// combination gets its own PDF font object.
2661    /// pdfUa: embed a metric-compatible substitute (Liberation, via
2662    /// `@formepdf/fonts-standard`) for a base-14 font, as a SIMPLE TrueType
2663    /// font carrying the base-14 AFM `/Widths` and WinAnsiEncoding. Because the
2664    /// widths, encoding, and font key are unchanged, the content stream is
2665    /// byte-identical to the non-embedded base-14 path — only the font
2666    /// dictionary gains an embedded program, so text positions are exact by
2667    /// construction. Returns `false` (caller emits the non-embedded base-14)
2668    /// when there is no metric-compatible substitute (Symbol/ZapfDingbats) or
2669    /// `@formepdf/fonts-standard` is not registered.
2670    fn emit_pdfua_embedded_standard(
2671        builder: &mut PdfBuilder,
2672        key: &FontKey,
2673        std_font: &crate::font::StandardFont,
2674        metrics: &crate::font::StandardFontMetrics,
2675        font_context: &FontContext,
2676    ) -> bool {
2677        let lib_family = match std_font.liberation_family() {
2678            Some(f) => f,
2679            None => return false, // Symbol / ZapfDingbats — no substitute
2680        };
2681        // The substitute must have been registered (fonts-standard) — otherwise
2682        // it resolves back to a Standard font and there is nothing to embed.
2683        let lib_bytes: &[u8] = match font_context.resolve(lib_family, key.weight, key.italic) {
2684            FontData::Custom { data, .. } => data,
2685            FontData::Standard(_) => return false,
2686        };
2687        let face = match ttf_parser::Face::parse(lib_bytes, 0) {
2688            Ok(f) => f,
2689            Err(_) => return false,
2690        };
2691        let scale = 1000.0 / face.units_per_em() as f64;
2692        let bbox = face.global_bounding_box();
2693        let pdf_name = Self::sanitize_font_name(lib_family, key.weight, key.italic);
2694
2695        // 1. FontFile2 — the full Liberation program, zlib-compressed.
2696        let compressed = compress_to_vec_zlib(lib_bytes, 6);
2697        let fontfile2_id = builder.objects.len();
2698        let mut ff2: Vec<u8> = Vec::new();
2699        let _ = write!(
2700            ff2,
2701            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
2702            compressed.len(),
2703            lib_bytes.len()
2704        );
2705        ff2.extend_from_slice(&compressed);
2706        ff2.extend_from_slice(b"\nendstream");
2707        builder.objects.push(PdfObject {
2708            id: fontfile2_id,
2709            data: ff2,
2710        });
2711
2712        // 2. FontDescriptor.
2713        let fd_id = builder.objects.len();
2714        let cap_height =
2715            (face.capital_height().unwrap_or_else(|| face.ascender()) as f64 * scale) as i32;
2716        let fd = format!(
2717            "<< /Type /FontDescriptor /FontName /{name} /Flags {flags} \
2718             /FontBBox [{x0} {y0} {x1} {y1}] /ItalicAngle {ia} \
2719             /Ascent {asc} /Descent {desc} /CapHeight {cap} /StemV {stem} \
2720             /FontFile2 {ff2} 0 R >>",
2721            name = pdf_name,
2722            flags = std_font.descriptor_flags(),
2723            x0 = (bbox.x_min as f64 * scale) as i32,
2724            y0 = (bbox.y_min as f64 * scale) as i32,
2725            x1 = (bbox.x_max as f64 * scale) as i32,
2726            y1 = (bbox.y_max as f64 * scale) as i32,
2727            ia = if key.italic { -12 } else { 0 },
2728            asc = (face.ascender() as f64 * scale) as i32,
2729            desc = (face.descender() as f64 * scale) as i32,
2730            cap = cap_height,
2731            stem = if key.weight >= 700 { 120 } else { 80 },
2732            ff2 = fontfile2_id,
2733        );
2734        builder.objects.push(PdfObject {
2735            id: fd_id,
2736            data: fd.into_bytes(),
2737        });
2738
2739        // 3. Simple TrueType font dict — base-14 AFM widths + WinAnsiEncoding,
2740        //    with the PDF/A width carve-out.
2741        //
2742        // For most glyphs the substitute's advance equals the base-14 AFM
2743        // width (Liberation is metric-compatible), so we declare the AFM value
2744        // and positioning stays exact. For the handful of rare accent/symbol
2745        // glyphs per proportional family where they diverge (e.g. macron,
2746        // grave, middot, ÷, ±, quotesingle, µ), we declare the substitute's
2747        // OWN advance instead — so /Widths agrees with the embedded program,
2748        // which ISO 19005 (PDF/A) requires and veraPDF's PDF/A profile checks.
2749        // The trade is a sub-glyph advance drift on those rare glyphs, which
2750        // real documents almost never contain. (Liberation Mono has zero
2751        // divergent glyphs; the carve-out is a no-op there.)
2752        let declared_widths: Vec<u16> = metrics
2753            .widths
2754            .iter()
2755            .enumerate()
2756            .map(|(i, &afm)| {
2757                let code = 32u8.wrapping_add(i as u8); // index 0 = WinAnsi code 32
2758                if let Some(ch) = crate::font::winansi_to_char(code) {
2759                    if let Some(gid) = face.glyph_index(ch) {
2760                        if let Some(adv) = face.glyph_hor_advance(gid) {
2761                            let hmtx = (adv as f64 * scale).round() as u16;
2762                            if (hmtx as i32 - afm as i32).abs() > 1 {
2763                                return hmtx;
2764                            }
2765                        }
2766                    }
2767                }
2768                afm
2769            })
2770            .collect();
2771        let widths_str: String = declared_widths
2772            .iter()
2773            .map(|w| w.to_string())
2774            .collect::<Vec<_>>()
2775            .join(" ");
2776        let obj_id = builder.objects.len();
2777        let font_dict = format!(
2778            "<< /Type /Font /Subtype /TrueType /BaseFont /{name} \
2779             /Encoding /WinAnsiEncoding \
2780             /FirstChar 32 /LastChar 255 /Widths [{w}] \
2781             /FontDescriptor {fd} 0 R >>",
2782            name = pdf_name,
2783            w = widths_str,
2784            fd = fd_id,
2785        );
2786        builder.objects.push(PdfObject {
2787            id: obj_id,
2788            data: font_dict.into_bytes(),
2789        });
2790        builder.font_objects.push((key.clone(), obj_id));
2791        // Record that this base-14 family is embedded (via substitution) so the
2792        // PDF/A all-fonts-embedded check accepts it — this is what lets PDF/A
2793        // and PDF/UA compose.
2794        builder.embedded_standard_fonts.insert(key.clone());
2795        true
2796    }
2797
2798    fn register_fonts(
2799        &self,
2800        builder: &mut PdfBuilder,
2801        pages: &[LayoutPage],
2802        font_context: &FontContext,
2803        pdf_ua: bool,
2804    ) -> Result<(), FormeError> {
2805        // Collect font usage: glyph IDs, chars, and glyph→char mapping per font
2806        let mut font_usage_map: HashMap<FontKey, FontUsage> = HashMap::new();
2807
2808        for page in pages {
2809            Self::collect_font_usage(&page.elements, &mut font_usage_map);
2810        }
2811
2812        let mut keys: Vec<FontKey> = font_usage_map.keys().cloned().collect();
2813
2814        // Sort for deterministic ordering, then dedup
2815        keys.sort_by(|a, b| {
2816            a.family
2817                .cmp(&b.family)
2818                .then(a.weight.cmp(&b.weight))
2819                .then(a.italic.cmp(&b.italic))
2820        });
2821        keys.dedup();
2822
2823        // Always have at least Helvetica
2824        if keys.is_empty() {
2825            keys.push(FontKey {
2826                family: "Helvetica".to_string(),
2827                weight: 400,
2828                italic: false,
2829            });
2830        }
2831
2832        for key in &keys {
2833            let font_data = font_context.resolve(&key.family, key.weight, key.italic);
2834
2835            match font_data {
2836                FontData::Standard(std_font) => {
2837                    let metrics = std_font.metrics();
2838
2839                    // PDF/UA + PDF/A require every font embedded, which the
2840                    // base-14 fonts are not. In pdfUa mode, if a
2841                    // metric-compatible substitute (Liberation, via
2842                    // @formepdf/fonts-standard) is registered, embed it as a
2843                    // SIMPLE TrueType carrying the base-14 AFM /Widths and
2844                    // WinAnsiEncoding — the content stream is untouched (same
2845                    // `(text) Tj` WinAnsi path, same positions), only the font
2846                    // dictionary gains an embedded program.
2847                    if pdf_ua {
2848                        if Self::emit_pdfua_embedded_standard(
2849                            builder,
2850                            key,
2851                            std_font,
2852                            &metrics,
2853                            font_context,
2854                        ) {
2855                            continue;
2856                        }
2857                        // Substitution didn't happen. If a metric-compatible
2858                        // substitute exists but wasn't registered, say so by
2859                        // name with the remedy — never silently emit a
2860                        // non-conforming file. (Symbol/ZapfDingbats have no
2861                        // substitute, so there is nothing to suggest.)
2862                        if let Some(lib) = std_font.liberation_family() {
2863                            builder.warnings.push(format!(
2864                                "pdfUa: font '{}' is not embedded, so the PDF will not conform to \
2865                                 PDF/UA (all fonts must be embedded). Install \
2866                                 @formepdf/fonts-standard and register its fonts \
2867                                 (`for (const f of standardFonts()) Font.register(f)`) — Forme \
2868                                 will then embed the metric-compatible {} in its place.",
2869                                std_font.pdf_name(),
2870                                lib,
2871                            ));
2872                        }
2873                    }
2874
2875                    let obj_id = builder.objects.len();
2876                    // Include /Widths so PDF viewers use our exact metrics
2877                    // instead of substituting a system font with different widths
2878                    let widths_str: String = metrics
2879                        .widths
2880                        .iter()
2881                        .map(|w| w.to_string())
2882                        .collect::<Vec<_>>()
2883                        .join(" ");
2884                    let font_dict = format!(
2885                        "<< /Type /Font /Subtype /Type1 /BaseFont /{} \
2886                         /Encoding /WinAnsiEncoding \
2887                         /FirstChar 32 /LastChar 255 /Widths [{}] >>",
2888                        std_font.pdf_name(),
2889                        widths_str,
2890                    );
2891                    builder.objects.push(PdfObject {
2892                        id: obj_id,
2893                        data: font_dict.into_bytes(),
2894                    });
2895                    builder.font_objects.push((key.clone(), obj_id));
2896                }
2897                FontData::Custom { data, .. } => {
2898                    let usage = font_usage_map.get(key);
2899                    let used_glyph_ids = usage.map(|u| &u.glyph_ids);
2900                    let used_chars = usage.map(|u| &u.chars);
2901                    let glyph_to_char = usage.map(|u| &u.glyph_to_char);
2902                    let type0_obj_id = Self::write_custom_font_objects(
2903                        builder,
2904                        key,
2905                        data,
2906                        used_glyph_ids.cloned().unwrap_or_default(),
2907                        used_chars.cloned().unwrap_or_default(),
2908                        glyph_to_char.cloned().unwrap_or_default(),
2909                    )?;
2910                    builder.font_objects.push((key.clone(), type0_obj_id));
2911                }
2912            }
2913        }
2914
2915        Ok(())
2916    }
2917
2918    /// Collect font usage data from layout elements: used chars, glyph IDs, and glyph→char mapping.
2919    fn collect_font_usage(
2920        elements: &[LayoutElement],
2921        font_usage: &mut HashMap<FontKey, FontUsage>,
2922    ) {
2923        for element in elements {
2924            let lines_opt = match &element.draw {
2925                DrawCommand::Text { lines, .. } => Some(lines),
2926                DrawCommand::Watermark { lines, .. } => Some(lines),
2927                _ => None,
2928            };
2929            if let Some(lines) = lines_opt {
2930                for line in lines {
2931                    for glyph in &line.glyphs {
2932                        let italic =
2933                            matches!(glyph.font_style, FontStyle::Italic | FontStyle::Oblique);
2934                        let key = FontKey {
2935                            family: glyph.font_family.to_string(),
2936                            weight: glyph.font_weight,
2937                            italic,
2938                        };
2939                        let usage = font_usage.entry(key).or_insert_with(|| FontUsage {
2940                            chars: HashSet::new(),
2941                            glyph_ids: HashSet::new(),
2942                            glyph_to_char: HashMap::new(),
2943                        });
2944                        usage.chars.insert(glyph.char_value);
2945                        // A page-number sentinel becomes digits at write
2946                        // time — subset all ten for this font, or the
2947                        // substituted numbers would render as .notdef
2948                        // (char_to_gid would have no digit entries).
2949                        if glyph.char_value == PAGE_NUMBER_SENTINEL
2950                            || glyph.char_value == TOTAL_PAGES_SENTINEL
2951                        {
2952                            usage.chars.extend('0'..='9');
2953                        }
2954                        usage.glyph_ids.insert(glyph.glyph_id);
2955                        // For ligatures, use the first char of the cluster
2956                        usage
2957                            .glyph_to_char
2958                            .entry(glyph.glyph_id)
2959                            .or_insert(glyph.char_value);
2960                        // If there's cluster_text, record all chars for this glyph
2961                        if let Some(ref ct) = glyph.cluster_text {
2962                            // First char already recorded above; cluster_text is for ToUnicode
2963                            if let Some(first_char) = ct.chars().next() {
2964                                usage
2965                                    .glyph_to_char
2966                                    .entry(glyph.glyph_id)
2967                                    .or_insert(first_char);
2968                            }
2969                        }
2970                    }
2971                }
2972            }
2973            Self::collect_font_usage(&element.children, font_usage);
2974        }
2975    }
2976
2977    /// Walk all pages, create XObject PDF objects for each image,
2978    /// Register PDF Shading dictionaries for every Rect with a
2979    /// `background_gradient`. Walks the element tree once per page in
2980    /// pre-order (same order `write_element` recurses) so the counter-
2981    /// indexed `shading_map` lookups during emission match.
2982    fn register_shadings(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
2983        for (page_idx, page) in pages.iter().enumerate() {
2984            let mut counter = 0usize;
2985            Self::collect_shadings_recursive(&page.elements, page_idx, &mut counter, builder);
2986        }
2987    }
2988
2989    fn collect_shadings_recursive(
2990        elements: &[LayoutElement],
2991        page_idx: usize,
2992        counter: &mut usize,
2993        builder: &mut PdfBuilder,
2994    ) {
2995        for element in elements {
2996            if let DrawCommand::Rect {
2997                background_gradient: Some(gradient),
2998                ..
2999            } = &element.draw
3000            {
3001                let ordinal = *counter;
3002                *counter += 1;
3003                let (obj_id, name) =
3004                    Self::write_shading_objects(builder, gradient, element, ordinal);
3005                builder
3006                    .shading_map
3007                    .insert((page_idx, ordinal), (obj_id, name));
3008            }
3009            Self::collect_shadings_recursive(&element.children, page_idx, counter, builder);
3010        }
3011    }
3012
3013    /// Build the Function + Shading PDF objects for one gradient. Returns
3014    /// (shading_obj_id, "Sh{n}"). 2-stop gradients use a single Type 2
3015    /// (exponential) function. 3+ stop gradients use a Type 3 (stitching)
3016    /// function combining N-1 Type 2 sub-functions, with /Bounds at each
3017    /// interior stop position.
3018    fn write_shading_objects(
3019        builder: &mut PdfBuilder,
3020        gradient: &crate::style::Background,
3021        element: &LayoutElement,
3022        ordinal: usize,
3023    ) -> (usize, String) {
3024        use crate::style::Background;
3025        use crate::style::GradientStop;
3026
3027        // Materialize the gradient as a normalized stop list (positions
3028        // sorted ascending, clamped to [0,1]). Solid-color backgrounds
3029        // collapse to two identical stops at 0 and 1.
3030        let black = Color {
3031            r: 0.0,
3032            g: 0.0,
3033            b: 0.0,
3034            a: 1.0,
3035        };
3036        let stops: Vec<GradientStop> = match gradient {
3037            Background::Color(c) => vec![
3038                GradientStop {
3039                    position: 0.0,
3040                    color: *c,
3041                },
3042                GradientStop {
3043                    position: 1.0,
3044                    color: *c,
3045                },
3046            ],
3047            Background::Linear(g) => normalize_gradient_stops(&g.stops, black),
3048            Background::Radial(g) => normalize_gradient_stops(&g.stops, black),
3049        };
3050
3051        // Build the color-interpolation function. With <=2 stops we emit
3052        // a single Type 2 (exponential) function; with 3+ stops we emit a
3053        // Type 3 (stitching) function combining N-1 Type 2 sub-functions.
3054        let function_id = if stops.len() <= 2 {
3055            let c0 = stops.first().map(|s| s.color).unwrap_or(black);
3056            let c1 = stops.last().map(|s| s.color).unwrap_or(c0);
3057            let id = builder.objects.len();
3058            let data = format!(
3059                "<< /FunctionType 2 /Domain [0 1] /C0 [{:.4} {:.4} {:.4}] /C1 [{:.4} {:.4} {:.4}] /N 1 >>",
3060                c0.r, c0.g, c0.b, c1.r, c1.g, c1.b,
3061            );
3062            builder.objects.push(PdfObject {
3063                id,
3064                data: data.into_bytes(),
3065            });
3066            id
3067        } else {
3068            // Reserve N-1 Type 2 sub-function objects.
3069            let mut sub_ids: Vec<usize> = Vec::with_capacity(stops.len() - 1);
3070            for window in stops.windows(2) {
3071                let c0 = window[0].color;
3072                let c1 = window[1].color;
3073                let id = builder.objects.len();
3074                let data = format!(
3075                    "<< /FunctionType 2 /Domain [0 1] /C0 [{:.4} {:.4} {:.4}] /C1 [{:.4} {:.4} {:.4}] /N 1 >>",
3076                    c0.r, c0.g, c0.b, c1.r, c1.g, c1.b,
3077                );
3078                builder.objects.push(PdfObject {
3079                    id,
3080                    data: data.into_bytes(),
3081                });
3082                sub_ids.push(id);
3083            }
3084            // Bounds = interior stop positions (exclude first and last).
3085            // Encode = [0 1] per sub-function — each sub-function uses its
3086            // full domain regardless of the bound interval width.
3087            let bounds: Vec<String> = stops[1..stops.len() - 1]
3088                .iter()
3089                .map(|s| format!("{:.4}", s.position))
3090                .collect();
3091            let encode: Vec<&str> = (0..sub_ids.len()).map(|_| "0 1").collect();
3092            let functions: Vec<String> = sub_ids.iter().map(|i| format!("{} 0 R", i)).collect();
3093            let id = builder.objects.len();
3094            let data = format!(
3095                "<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>",
3096                functions.join(" "),
3097                bounds.join(" "),
3098                encode.join(" "),
3099            );
3100            builder.objects.push(PdfObject {
3101                id,
3102                data: data.into_bytes(),
3103            });
3104            id
3105        };
3106
3107        // Element dimensions. The shading's coord space is local to the
3108        // rect (we cm-translate to the rect's bottom-left at draw time),
3109        // so x/y aren't needed here — only w/h.
3110        let _ = element.x;
3111        let _ = element.y;
3112        let w = element.width;
3113        let h = element.height;
3114
3115        let shading_id = builder.objects.len();
3116        let shading_data = match gradient {
3117            Background::Linear(g) => {
3118                // CSS angle convention: 0deg = bottom→top, 90deg = left→right,
3119                // 180deg = top→bottom (clockwise from up).
3120                // Our layout uses Y-down; PDF uses Y-up. Compute the axis
3121                // in PDF coords directly: dx = sin(θ), dy = cos(θ) where
3122                // CSS 0deg points "up" (positive PDF y).
3123                // CSS angle convention: 0deg = bottom→top, 180deg =
3124                // top→bottom. PDF y-axis is flipped vs CSS-on-screen, so
3125                // dy comes from cos(θ) directly (CSS 0deg points "up"
3126                // which is +y in PDF coords).
3127                let theta = g.angle_deg.to_radians();
3128                let dx = theta.sin();
3129                let dy = theta.cos();
3130                // Axis length spans the rect along the gradient direction
3131                // (CSS spec covering box).
3132                let axis_len = w * dx.abs() + h * dy.abs();
3133                // Coords are RELATIVE to the rect's bottom-left corner
3134                // (the cm-translate at draw time positions absolutely).
3135                let cx_rel = w / 2.0;
3136                let cy_rel = h / 2.0;
3137                let half = axis_len / 2.0;
3138                let x0 = cx_rel - dx * half;
3139                let y0 = cy_rel - dy * half;
3140                let x1 = cx_rel + dx * half;
3141                let y1 = cy_rel + dy * half;
3142                format!(
3143                    "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{:.3} {:.3} {:.3} {:.3}] /Function {} 0 R /Extend [true true] >>",
3144                    x0, y0, x1, y1, function_id,
3145                )
3146            }
3147            Background::Radial(_) => {
3148                // Circle from center, inner r=0, outer r=max(w/2, h/2),
3149                // expressed relative to rect bottom-left.
3150                let cx_rel = w / 2.0;
3151                let cy_rel = h / 2.0;
3152                let r_outer = (w / 2.0).max(h / 2.0);
3153                format!(
3154                    "<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [{:.3} {:.3} 0 {:.3} {:.3} {:.3}] /Function {} 0 R /Extend [true true] >>",
3155                    cx_rel, cy_rel, cx_rel, cy_rel, r_outer, function_id,
3156                )
3157            }
3158            Background::Color(_) => {
3159                // Solid: emit a constant 1.0-stop function via the Coords
3160                // collapsed to a point. (Shouldn't normally hit this path —
3161                // background_gradient should only be set for true gradients.)
3162                format!(
3163                    "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 0] /Function {} 0 R /Extend [true true] >>",
3164                    function_id,
3165                )
3166            }
3167        };
3168        builder.objects.push(PdfObject {
3169            id: shading_id,
3170            data: shading_data.into_bytes(),
3171        });
3172        (shading_id, format!("Sh{}", ordinal))
3173    }
3174
3175    /// Decode and embed each page's optional `background_image` as a PDF
3176    /// XObject. Identical URLs across pages share a single XObject (the
3177    /// `page_background_url_cache` does the deduplication).
3178    fn register_page_background_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3179        for (page_idx, page) in pages.iter().enumerate() {
3180            let Some(src) = &page.config.background_image else {
3181                continue;
3182            };
3183            // Reuse the XObject if a previous page used the same source.
3184            if let Some(&entry) = builder.page_background_url_cache.get(src) {
3185                builder.page_background_image_map.insert(page_idx, entry);
3186                continue;
3187            }
3188            // Decode + embed; on failure, log a warning and skip the
3189            // background for that page (don't fail the whole render).
3190            match crate::image_loader::load_image(src) {
3191                Ok(image_data) => {
3192                    let img_idx = builder.image_objects.len();
3193                    let dims = (img_idx, image_data.width_px, image_data.height_px);
3194                    let xobj_id = Self::write_image_xobject(builder, &image_data);
3195                    builder.image_objects.push(xobj_id);
3196                    builder.page_background_image_map.insert(page_idx, dims);
3197                    builder.page_background_url_cache.insert(src.clone(), dims);
3198                }
3199                Err(e) => {
3200                    eprintln!("[forme] page background image failed to load: {}", e);
3201                }
3202            }
3203        }
3204    }
3205
3206    /// Emit the page background paint (q + optional ExtGState + cm + Do + Q)
3207    /// at the start of a page's content stream. Sizing follows CSS
3208    /// `background-size` semantics (fill/cover/contain) with positioning
3209    /// per `background-position`.
3210    fn write_page_background(
3211        &self,
3212        stream: &mut String,
3213        page: &LayoutPage,
3214        page_bg: (usize, u32, u32),
3215        builder: &PdfBuilder,
3216    ) {
3217        use crate::model::{BackgroundPosition, BackgroundSize};
3218        let (img_idx, iw_px, ih_px) = page_bg;
3219        let page_w = page.width;
3220        let page_h = page.height;
3221        let iw = iw_px as f64;
3222        let ih = ih_px as f64;
3223
3224        let size = page.config.background_size.unwrap_or_default();
3225        let (dest_w, dest_h) = match size {
3226            BackgroundSize::Fill => (page_w, page_h),
3227            BackgroundSize::Cover => {
3228                let s = (page_w / iw).max(page_h / ih);
3229                (iw * s, ih * s)
3230            }
3231            BackgroundSize::Contain => {
3232                let s = (page_w / iw).min(page_h / ih);
3233                (iw * s, ih * s)
3234            }
3235        };
3236
3237        // Position: for `fill`, dest matches page exactly so position is
3238        // moot; otherwise place per `background-position` against the
3239        // page's bounding box.
3240        let position = page.config.background_position.unwrap_or_default();
3241        // PDF Y origin is bottom-left, so "top" means pdf_y = page_h - dest_h
3242        // and "bottom" means pdf_y = 0.
3243        let (dest_x, dest_y) = match position {
3244            BackgroundPosition::TopLeft => (0.0, page_h - dest_h),
3245            BackgroundPosition::TopRight => (page_w - dest_w, page_h - dest_h),
3246            BackgroundPosition::BottomLeft => (0.0, 0.0),
3247            BackgroundPosition::BottomRight => (page_w - dest_w, 0.0),
3248            BackgroundPosition::Center => ((page_w - dest_w) / 2.0, (page_h - dest_h) / 2.0),
3249        };
3250
3251        // Optional ExtGState wrap for backgroundOpacity < 1.0.
3252        let opacity = page.config.background_opacity.unwrap_or(1.0);
3253        let needs_opacity = opacity < 1.0;
3254        if needs_opacity {
3255            if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
3256                let _ = writeln!(stream, "q\n/{} gs", gs_name);
3257            } else {
3258                let _ = writeln!(stream, "q");
3259            }
3260        } else {
3261            let _ = writeln!(stream, "q");
3262        }
3263        // PDF cm: a b c d e f → matrix [[a c e][b d f][0 0 1]]; for a
3264        // simple scale + translate, that's: w 0 0 h x y cm.
3265        let _ = writeln!(
3266            stream,
3267            "{:.2} 0 0 {:.2} {:.2} {:.2} cm\n/Im{} Do\nQ",
3268            dest_w, dest_h, dest_x, dest_y, img_idx,
3269        );
3270    }
3271
3272    /// and populate the image_index_map for content stream reference.
3273    fn register_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3274        for (page_idx, page) in pages.iter().enumerate() {
3275            let mut element_counter = 0usize;
3276            Self::collect_images_recursive(&page.elements, page_idx, &mut element_counter, builder);
3277        }
3278    }
3279
3280    fn collect_images_recursive(
3281        elements: &[LayoutElement],
3282        page_idx: usize,
3283        element_counter: &mut usize,
3284        builder: &mut PdfBuilder,
3285    ) {
3286        for element in elements {
3287            match &element.draw {
3288                DrawCommand::Image { image_data } => {
3289                    let elem_idx = *element_counter;
3290                    *element_counter += 1;
3291
3292                    let img_idx = builder.image_objects.len();
3293                    let xobj_id = Self::write_image_xobject(builder, image_data);
3294                    builder.image_objects.push(xobj_id);
3295                    builder
3296                        .image_index_map
3297                        .insert((page_idx, elem_idx), img_idx);
3298                }
3299                DrawCommand::ImagePlaceholder => {
3300                    *element_counter += 1;
3301                }
3302                _ => {
3303                    Self::collect_images_recursive(
3304                        &element.children,
3305                        page_idx,
3306                        element_counter,
3307                        builder,
3308                    );
3309                }
3310            }
3311        }
3312    }
3313
3314    /// Collect unique opacity values from all pages and create ExtGState PDF objects.
3315    fn register_ext_gstates(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3316        let mut unique_opacities: Vec<f64> = Vec::new();
3317        for page in pages {
3318            Self::collect_opacities_recursive(&page.elements, &mut unique_opacities);
3319            // Page background opacity (independent of element-level alphas).
3320            if let Some(o) = page.config.background_opacity {
3321                if o < 1.0 {
3322                    unique_opacities.push(o);
3323                }
3324            }
3325        }
3326        unique_opacities.sort_by(|a, b| a.partial_cmp(b).unwrap());
3327        unique_opacities.dedup();
3328
3329        for (idx, &opacity) in unique_opacities.iter().enumerate() {
3330            let obj_id = builder.objects.len();
3331            let gs_name = format!("GS{}", idx);
3332            let obj_data = format!(
3333                "<< /Type /ExtGState /ca {:.4} /CA {:.4} >>",
3334                opacity, opacity
3335            );
3336            builder.objects.push(PdfObject {
3337                id: obj_id,
3338                data: obj_data.into_bytes(),
3339            });
3340            let key = opacity.to_bits();
3341            builder.ext_gstate_map.insert(key, (obj_id, gs_name));
3342        }
3343    }
3344
3345    fn collect_opacities_recursive(elements: &[LayoutElement], opacities: &mut Vec<f64>) {
3346        for element in elements {
3347            // Element-level opacity wraps the whole subtree (including
3348            // children) in `q\n/GS{n} gs ... Q` so descendants render at
3349            // the cumulative alpha. Collect it independently of the
3350            // per-DrawCommand opacities below — they coexist for now,
3351            // and the per-Rect/Text/Watermark opacities are gradually
3352            // being deprecated in favor of the element-level one.
3353            if element.opacity < 1.0 {
3354                opacities.push(element.opacity);
3355            }
3356            // Shadow color alpha — needs its own ExtGState entry so the
3357            // shadow renders semi-transparently independent of the
3358            // element's opacity.
3359            if let DrawCommand::Rect {
3360                box_shadow: Some(shadow),
3361                ..
3362            } = &element.draw
3363            {
3364                if shadow.color.a < 1.0 {
3365                    opacities.push(shadow.color.a);
3366                }
3367            }
3368            match &element.draw {
3369                DrawCommand::Rect { opacity, .. }
3370                | DrawCommand::Text { opacity, .. }
3371                | DrawCommand::Watermark { opacity, .. }
3372                    if *opacity < 1.0 =>
3373                {
3374                    opacities.push(*opacity);
3375                }
3376                DrawCommand::Chart { primitives } => {
3377                    for prim in primitives {
3378                        if let crate::chart::ChartPrimitive::FilledPath { opacity, .. } = prim {
3379                            if *opacity < 1.0 {
3380                                opacities.push(*opacity);
3381                            }
3382                        }
3383                    }
3384                }
3385                DrawCommand::Svg { commands, .. } => {
3386                    for cmd in commands {
3387                        if let crate::svg::SvgCommand::SetOpacity(opacity) = cmd {
3388                            if *opacity < 1.0 {
3389                                opacities.push(*opacity);
3390                            }
3391                        }
3392                    }
3393                }
3394                _ => {}
3395            }
3396            Self::collect_opacities_recursive(&element.children, opacities);
3397        }
3398    }
3399
3400    /// Build the ExtGState resource dict entries for a page.
3401    fn build_ext_gstate_resource_dict(&self, builder: &PdfBuilder) -> String {
3402        if builder.ext_gstate_map.is_empty() {
3403            return String::new();
3404        }
3405        let mut entries: Vec<(&String, usize)> = builder
3406            .ext_gstate_map
3407            .values()
3408            .map(|(obj_id, name)| (name, *obj_id))
3409            .collect();
3410        entries.sort_by_key(|(name, _)| (*name).clone());
3411        entries
3412            .iter()
3413            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
3414            .collect::<Vec<_>>()
3415            .join(" ")
3416    }
3417
3418    /// Write a single image as one or two XObject PDF objects.
3419    /// Returns the main XObject ID.
3420    fn write_image_xobject(
3421        builder: &mut PdfBuilder,
3422        image: &crate::image_loader::LoadedImage,
3423    ) -> usize {
3424        use crate::image_loader::{ImagePixelData, JpegColorSpace};
3425
3426        match &image.pixel_data {
3427            ImagePixelData::Jpeg { data, color_space } => {
3428                let color_space_str = match color_space {
3429                    JpegColorSpace::DeviceRGB => "/DeviceRGB",
3430                    JpegColorSpace::DeviceGray => "/DeviceGray",
3431                };
3432
3433                let obj_id = builder.objects.len();
3434                let mut obj_data: Vec<u8> = Vec::new();
3435                let _ = write!(
3436                    obj_data,
3437                    "<< /Type /XObject /Subtype /Image \
3438                     /Width {} /Height {} \
3439                     /ColorSpace {} \
3440                     /BitsPerComponent 8 \
3441                     /Filter /DCTDecode \
3442                     /Length {} >>\nstream\n",
3443                    image.width_px,
3444                    image.height_px,
3445                    color_space_str,
3446                    data.len()
3447                );
3448                obj_data.extend_from_slice(data);
3449                obj_data.extend_from_slice(b"\nendstream");
3450                builder.objects.push(PdfObject {
3451                    id: obj_id,
3452                    data: obj_data,
3453                });
3454                obj_id
3455            }
3456
3457            ImagePixelData::Decoded { rgb, alpha } => {
3458                // Write SMask first if alpha channel exists
3459                let smask_id = alpha.as_ref().map(|alpha_data| {
3460                    let compressed_alpha = compress_to_vec_zlib(alpha_data, 6);
3461                    let smask_obj_id = builder.objects.len();
3462                    let mut smask_data: Vec<u8> = Vec::new();
3463                    let _ = write!(
3464                        smask_data,
3465                        "<< /Type /XObject /Subtype /Image \
3466                         /Width {} /Height {} \
3467                         /ColorSpace /DeviceGray \
3468                         /BitsPerComponent 8 \
3469                         /Filter /FlateDecode \
3470                         /Length {} >>\nstream\n",
3471                        image.width_px,
3472                        image.height_px,
3473                        compressed_alpha.len()
3474                    );
3475                    smask_data.extend_from_slice(&compressed_alpha);
3476                    smask_data.extend_from_slice(b"\nendstream");
3477                    builder.objects.push(PdfObject {
3478                        id: smask_obj_id,
3479                        data: smask_data,
3480                    });
3481                    smask_obj_id
3482                });
3483
3484                // Write main RGB image XObject
3485                let compressed_rgb = compress_to_vec_zlib(rgb, 6);
3486                let obj_id = builder.objects.len();
3487                let mut obj_data: Vec<u8> = Vec::new();
3488
3489                let smask_ref = smask_id
3490                    .map(|id| format!(" /SMask {} 0 R", id))
3491                    .unwrap_or_default();
3492
3493                let _ = write!(
3494                    obj_data,
3495                    "<< /Type /XObject /Subtype /Image \
3496                     /Width {} /Height {} \
3497                     /ColorSpace /DeviceRGB \
3498                     /BitsPerComponent 8 \
3499                     /Filter /FlateDecode \
3500                     /Length {}{} >>\nstream\n",
3501                    image.width_px,
3502                    image.height_px,
3503                    compressed_rgb.len(),
3504                    smask_ref
3505                );
3506                obj_data.extend_from_slice(&compressed_rgb);
3507                obj_data.extend_from_slice(b"\nendstream");
3508                builder.objects.push(PdfObject {
3509                    id: obj_id,
3510                    data: obj_data,
3511                });
3512                obj_id
3513            }
3514        }
3515    }
3516
3517    /// Build the /XObject resource dict entries for a specific page.
3518    /// Build the page's `/Shading << ... >>` resource dict from the
3519    /// shading_map entries that match `page_idx`.
3520    fn build_shading_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
3521        let mut entries: Vec<(String, usize)> = builder
3522            .shading_map
3523            .iter()
3524            .filter(|(&(p, _), _)| p == page_idx)
3525            .map(|(_, (obj_id, name))| (name.clone(), *obj_id))
3526            .collect();
3527        if entries.is_empty() {
3528            return String::new();
3529        }
3530        entries.sort_by(|a, b| a.0.cmp(&b.0));
3531        entries
3532            .iter()
3533            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
3534            .collect::<Vec<_>>()
3535            .join(" ")
3536    }
3537
3538    fn build_xobject_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
3539        let mut entries: Vec<(usize, usize)> = Vec::new();
3540        for (&(pidx, _), &img_idx) in &builder.image_index_map {
3541            if pidx == page_idx {
3542                let obj_id = builder.image_objects[img_idx];
3543                entries.push((img_idx, obj_id));
3544            }
3545        }
3546        // Include the page's background image (if any) so the `/Im{n} Do`
3547        // operator at the start of the content stream resolves.
3548        if let Some(&(img_idx, _, _)) = builder.page_background_image_map.get(&page_idx) {
3549            let obj_id = builder.image_objects[img_idx];
3550            entries.push((img_idx, obj_id));
3551        }
3552        if entries.is_empty() {
3553            return String::new();
3554        }
3555        entries.sort_by_key(|(idx, _)| *idx);
3556        entries.dedup();
3557        entries
3558            .iter()
3559            .map(|(idx, obj_id)| format!("/Im{} {} 0 R", idx, obj_id))
3560            .collect::<Vec<_>>()
3561            .join(" ")
3562    }
3563
3564    /// Write the 5 CIDFont PDF objects for a custom TrueType font.
3565    /// Returns the object ID of the Type0 root font dictionary.
3566    ///
3567    /// `used_glyph_ids`: original glyph IDs from shaping (from PositionedGlyph.glyph_id).
3568    /// `used_chars`: characters used (for char→gid fallback, e.g., page number placeholders).
3569    /// `glyph_to_char_map`: maps original glyph ID → first Unicode char (for ToUnicode CMap).
3570    fn write_custom_font_objects(
3571        builder: &mut PdfBuilder,
3572        key: &FontKey,
3573        ttf_data: &[u8],
3574        used_glyph_ids: HashSet<u16>,
3575        used_chars: HashSet<char>,
3576        glyph_to_char_map: HashMap<u16, char>,
3577    ) -> Result<usize, FormeError> {
3578        let face = ttf_parser::Face::parse(ttf_data, 0).map_err(|e| {
3579            FormeError::FontError(format!(
3580                "Failed to parse TTF data for font '{}': {}",
3581                key.family, e
3582            ))
3583        })?;
3584
3585        let units_per_em = face.units_per_em();
3586        let ascender = face.ascender();
3587        let descender = face.descender();
3588
3589        // Build char → original glyph ID mapping (for fallback/placeholders)
3590        let mut char_to_orig_gid: HashMap<char, u16> = HashMap::new();
3591        for &ch in &used_chars {
3592            if let Some(gid) = face.glyph_index(ch) {
3593                char_to_orig_gid.insert(ch, gid.0);
3594            }
3595        }
3596
3597        // Combine shaped glyph IDs + char-based glyph IDs for subsetting.
3598        // This ensures ligature glyphs (from shaping) AND individual char glyphs
3599        // (for placeholder fallback) are all included.
3600        let mut all_orig_gids: HashSet<u16> = used_glyph_ids.clone();
3601        for &gid in char_to_orig_gid.values() {
3602            all_orig_gids.insert(gid);
3603        }
3604
3605        // Subset the font to only include used glyphs
3606        let (embed_ttf, gid_remap) = match subset_ttf(ttf_data, &all_orig_gids) {
3607            Ok(subset_result) => (subset_result.ttf_data, subset_result.gid_remap),
3608            Err(_) => {
3609                // Subsetting failed — fall back to embedding the full font (identity remap)
3610                let identity: HashMap<u16, u16> =
3611                    all_orig_gids.iter().map(|&gid| (gid, gid)).collect();
3612                (ttf_data.to_vec(), identity)
3613            }
3614        };
3615
3616        // Build char→new_gid mapping (for placeholder fallback in content stream)
3617        let char_to_gid: HashMap<char, u16> = char_to_orig_gid
3618            .iter()
3619            .filter_map(|(&ch, &orig_gid)| gid_remap.get(&orig_gid).map(|&new_gid| (ch, new_gid)))
3620            .collect();
3621
3622        // Build glyph_id→new_gid mapping (for shaped content stream)
3623        let gid_remap_for_embed = gid_remap.clone();
3624
3625        // Build new_gid→char mapping for ToUnicode CMap
3626        let mut new_gid_to_char: HashMap<u16, char> = HashMap::new();
3627        // From shaped glyph→char mapping
3628        for (&orig_gid, &ch) in &glyph_to_char_map {
3629            if let Some(&new_gid) = gid_remap.get(&orig_gid) {
3630                new_gid_to_char.entry(new_gid).or_insert(ch);
3631            }
3632        }
3633        // Fill in from char→gid mapping too
3634        for (&ch, &new_gid) in &char_to_gid {
3635            new_gid_to_char.entry(new_gid).or_insert(ch);
3636        }
3637
3638        let pdf_font_name = Self::sanitize_font_name(&key.family, key.weight, key.italic);
3639
3640        // 1. FontFile2 stream — compressed subset TTF bytes
3641        let compressed_ttf = compress_to_vec_zlib(&embed_ttf, 6);
3642        let fontfile2_id = builder.objects.len();
3643        let mut fontfile2_data: Vec<u8> = Vec::new();
3644        let _ = write!(
3645            fontfile2_data,
3646            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
3647            compressed_ttf.len(),
3648            embed_ttf.len()
3649        );
3650        fontfile2_data.extend_from_slice(&compressed_ttf);
3651        fontfile2_data.extend_from_slice(b"\nendstream");
3652        builder.objects.push(PdfObject {
3653            id: fontfile2_id,
3654            data: fontfile2_data,
3655        });
3656
3657        // Parse the subset font for metrics (width array uses subset GIDs)
3658        let subset_face = ttf_parser::Face::parse(&embed_ttf, 0).unwrap_or_else(|_| face.clone());
3659        let subset_upem = subset_face.units_per_em();
3660
3661        // 2. FontDescriptor
3662        let font_descriptor_id = builder.objects.len();
3663        let bbox = face.global_bounding_box();
3664        let scale = 1000.0 / units_per_em as f64;
3665        let bbox_str = format!(
3666            "[{} {} {} {}]",
3667            (bbox.x_min as f64 * scale) as i32,
3668            (bbox.y_min as f64 * scale) as i32,
3669            (bbox.x_max as f64 * scale) as i32,
3670            (bbox.y_max as f64 * scale) as i32,
3671        );
3672
3673        let flags = 4u32;
3674        let cap_height = face.capital_height().unwrap_or(ascender) as f64 * scale;
3675        let stem_v = if key.weight >= 700 { 120 } else { 80 };
3676
3677        let font_descriptor_dict = format!(
3678            "<< /Type /FontDescriptor /FontName /{} /Flags {} \
3679             /FontBBox {} /ItalicAngle {} \
3680             /Ascent {} /Descent {} /CapHeight {} /StemV {} \
3681             /FontFile2 {} 0 R >>",
3682            pdf_font_name,
3683            flags,
3684            bbox_str,
3685            if key.italic { -12 } else { 0 },
3686            (ascender as f64 * scale) as i32,
3687            (descender as f64 * scale) as i32,
3688            cap_height as i32,
3689            stem_v,
3690            fontfile2_id,
3691        );
3692        builder.objects.push(PdfObject {
3693            id: font_descriptor_id,
3694            data: font_descriptor_dict.into_bytes(),
3695        });
3696
3697        // 3. CIDFont dictionary (DescendantFont)
3698        let cidfont_id = builder.objects.len();
3699        // Build /W array using new_gid→width from subset face
3700        let w_array = Self::build_w_array_from_gids(&gid_remap, &subset_face, subset_upem);
3701        let default_width = subset_face
3702            .glyph_hor_advance(ttf_parser::GlyphId(0))
3703            .map(|adv| (adv as f64 * 1000.0 / subset_upem as f64) as u32)
3704            .unwrap_or(1000);
3705        let cidfont_dict = format!(
3706            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{} \
3707             /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
3708             /FontDescriptor {} 0 R /DW {} /W {} \
3709             /CIDToGIDMap /Identity >>",
3710            pdf_font_name, font_descriptor_id, default_width, w_array,
3711        );
3712        builder.objects.push(PdfObject {
3713            id: cidfont_id,
3714            data: cidfont_dict.into_bytes(),
3715        });
3716
3717        // 4. ToUnicode CMap
3718        let tounicode_id = builder.objects.len();
3719        let cmap_content = Self::build_tounicode_cmap_from_gids(&new_gid_to_char, &pdf_font_name);
3720        let compressed_cmap = compress_to_vec_zlib(cmap_content.as_bytes(), 6);
3721        let mut tounicode_data: Vec<u8> = Vec::new();
3722        let _ = write!(
3723            tounicode_data,
3724            "<< /Length {} /Filter /FlateDecode >>\nstream\n",
3725            compressed_cmap.len()
3726        );
3727        tounicode_data.extend_from_slice(&compressed_cmap);
3728        tounicode_data.extend_from_slice(b"\nendstream");
3729        builder.objects.push(PdfObject {
3730            id: tounicode_id,
3731            data: tounicode_data,
3732        });
3733
3734        // 5. Type0 font dictionary (the root, referenced by /Resources)
3735        let type0_id = builder.objects.len();
3736        let type0_dict = format!(
3737            "<< /Type /Font /Subtype /Type0 /BaseFont /{} \
3738             /Encoding /Identity-H \
3739             /DescendantFonts [{} 0 R] \
3740             /ToUnicode {} 0 R >>",
3741            pdf_font_name, cidfont_id, tounicode_id,
3742        );
3743        builder.objects.push(PdfObject {
3744            id: type0_id,
3745            data: type0_dict.into_bytes(),
3746        });
3747
3748        // Store embedding data for content stream encoding
3749        builder.custom_font_data.insert(
3750            key.clone(),
3751            CustomFontEmbedData {
3752                ttf_data: embed_ttf,
3753                gid_remap: gid_remap_for_embed,
3754                glyph_to_char: glyph_to_char_map,
3755                char_to_gid,
3756                units_per_em,
3757                ascender,
3758                descender,
3759            },
3760        );
3761
3762        Ok(type0_id)
3763    }
3764
3765    /// Build the /W array from gid_remap (orig_gid→new_gid) using the subset face.
3766    fn build_w_array_from_gids(
3767        gid_remap: &HashMap<u16, u16>,
3768        face: &ttf_parser::Face,
3769        units_per_em: u16,
3770    ) -> String {
3771        let scale = 1000.0 / units_per_em as f64;
3772
3773        let mut entries: Vec<(u16, u32)> = Vec::new();
3774        let mut seen_gids: HashSet<u16> = HashSet::new();
3775
3776        for &new_gid in gid_remap.values() {
3777            if seen_gids.contains(&new_gid) {
3778                continue;
3779            }
3780            seen_gids.insert(new_gid);
3781            let advance = face
3782                .glyph_hor_advance(ttf_parser::GlyphId(new_gid))
3783                .unwrap_or(0);
3784            let width = (advance as f64 * scale) as u32;
3785            entries.push((new_gid, width));
3786        }
3787
3788        entries.sort_by_key(|(gid, _)| *gid);
3789
3790        // Build the W array using individual entries: gid [width]
3791        let mut result = String::from("[");
3792        for (gid, width) in &entries {
3793            let _ = write!(result, " {} [{}]", gid, width);
3794        }
3795        result.push_str(" ]");
3796        result
3797    }
3798
3799    /// Build a ToUnicode CMap from new_gid → char mapping.
3800    fn build_tounicode_cmap_from_gids(gid_to_char: &HashMap<u16, char>, font_name: &str) -> String {
3801        let mut gid_to_unicode: Vec<(u16, u32)> = gid_to_char
3802            .iter()
3803            .map(|(&gid, &ch)| (gid, ch as u32))
3804            .collect();
3805        gid_to_unicode.sort_by_key(|(gid, _)| *gid);
3806
3807        let mut cmap = String::new();
3808        let _ = writeln!(cmap, "/CIDInit /ProcSet findresource begin");
3809        let _ = writeln!(cmap, "12 dict begin");
3810        let _ = writeln!(cmap, "begincmap");
3811        let _ = writeln!(cmap, "/CIDSystemInfo");
3812        let _ = writeln!(
3813            cmap,
3814            "<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def"
3815        );
3816        let _ = writeln!(cmap, "/CMapName /{}-UTF16 def", font_name);
3817        let _ = writeln!(cmap, "/CMapType 2 def");
3818        let _ = writeln!(cmap, "1 begincodespacerange");
3819        let _ = writeln!(cmap, "<0000> <FFFF>");
3820        let _ = writeln!(cmap, "endcodespacerange");
3821
3822        // PDF spec limits beginbfchar to 100 entries per block
3823        for chunk in gid_to_unicode.chunks(100) {
3824            let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
3825            for &(gid, unicode) in chunk {
3826                let _ = writeln!(cmap, "<{:04X}> <{:04X}>", gid, unicode);
3827            }
3828            let _ = writeln!(cmap, "endbfchar");
3829        }
3830
3831        let _ = writeln!(cmap, "endcmap");
3832        let _ = writeln!(cmap, "CMapName currentdict /CMap defineresource pop");
3833        let _ = writeln!(cmap, "end");
3834        let _ = writeln!(cmap, "end");
3835
3836        cmap
3837    }
3838
3839    /// Sanitize a font name for use as a PDF name object.
3840    /// Strips spaces and special characters, appends weight/style suffixes.
3841    fn sanitize_font_name(family: &str, weight: u32, italic: bool) -> String {
3842        let mut name: String = family
3843            .chars()
3844            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
3845            .collect();
3846
3847        if weight >= 700 {
3848            name.push_str("-Bold");
3849        }
3850        if italic {
3851            name.push_str("-Italic");
3852        }
3853
3854        // If name is empty after sanitization, use a fallback
3855        if name.is_empty() {
3856            name = "CustomFont".to_string();
3857        }
3858
3859        name
3860    }
3861
3862    fn build_font_resource_dict(&self, font_objects: &[(FontKey, usize)]) -> String {
3863        font_objects
3864            .iter()
3865            .enumerate()
3866            .map(|(i, (_, obj_id))| format!("/F{} {} 0 R", i, obj_id))
3867            .collect::<Vec<_>>()
3868            .join(" ")
3869    }
3870
3871    /// Look up the font index (/F0, /F1, etc.) for a given family+weight+style.
3872    fn font_index(
3873        &self,
3874        family: &str,
3875        weight: u32,
3876        font_style: FontStyle,
3877        font_objects: &[(FontKey, usize)],
3878    ) -> usize {
3879        let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
3880
3881        // Exact weight match
3882        for (i, (key, _)) in font_objects.iter().enumerate() {
3883            if key.family == family && key.weight == weight && key.italic == italic {
3884                return i;
3885            }
3886        }
3887
3888        // Fallback: snapped weight (400/700)
3889        let snapped = if weight >= 600 { 700 } else { 400 };
3890        for (i, (key, _)) in font_objects.iter().enumerate() {
3891            if key.family == family && key.weight == snapped && key.italic == italic {
3892                return i;
3893            }
3894        }
3895
3896        // Fallback: try Helvetica with same weight/style
3897        for (i, (key, _)) in font_objects.iter().enumerate() {
3898            if key.family == "Helvetica" && key.weight == snapped && key.italic == italic {
3899                return i;
3900            }
3901        }
3902
3903        // Last resort: first font
3904        0
3905    }
3906
3907    /// Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
3908    /// for multi-font text run rendering.
3909    fn group_glyphs_by_style(glyphs: &[PositionedGlyph]) -> Vec<Vec<&PositionedGlyph>> {
3910        if glyphs.is_empty() {
3911            return vec![];
3912        }
3913
3914        let mut groups: Vec<Vec<&PositionedGlyph>> = Vec::new();
3915        let mut current_group: Vec<&PositionedGlyph> = vec![&glyphs[0]];
3916
3917        for glyph in &glyphs[1..] {
3918            let prev = current_group.last().unwrap();
3919            let same_style = glyph.font_family == prev.font_family
3920                && glyph.font_weight == prev.font_weight
3921                && std::mem::discriminant(&glyph.font_style)
3922                    == std::mem::discriminant(&prev.font_style)
3923                && (glyph.font_size - prev.font_size).abs() < 0.01
3924                && Self::colors_equal(&glyph.color, &prev.color)
3925                && std::mem::discriminant(&glyph.text_decoration)
3926                    == std::mem::discriminant(&prev.text_decoration);
3927
3928            if same_style {
3929                current_group.push(glyph);
3930            } else {
3931                groups.push(current_group);
3932                current_group = vec![glyph];
3933            }
3934        }
3935        groups.push(current_group);
3936        groups
3937    }
3938
3939    fn colors_equal(a: &Option<Color>, b: &Option<Color>) -> bool {
3940        match (a, b) {
3941            (None, None) => true,
3942            (Some(ca), Some(cb)) => {
3943                (ca.r - cb.r).abs() < 0.001
3944                    && (ca.g - cb.g).abs() < 0.001
3945                    && (ca.b - cb.b).abs() < 0.001
3946                    && (ca.a - cb.a).abs() < 0.001
3947            }
3948            _ => false,
3949        }
3950    }
3951
3952    /// Collect link annotations from layout elements recursively.
3953    /// When an element has an href, its rect covers all children, so we skip
3954    /// recursing into children to avoid duplicate annotations.
3955    fn collect_link_annotations(
3956        elements: &[LayoutElement],
3957        page_height: f64,
3958        annotations: &mut Vec<LinkAnnotation>,
3959    ) {
3960        for element in elements {
3961            if let Some(ref href) = element.href {
3962                if !href.is_empty() {
3963                    let pdf_y = page_height - element.y - element.height;
3964                    annotations.push(LinkAnnotation {
3965                        x: element.x,
3966                        y: pdf_y,
3967                        width: element.width,
3968                        height: element.height,
3969                        href: href.clone(),
3970                    });
3971                    // Don't recurse — parent annotation covers children
3972                    continue;
3973                }
3974            }
3975            Self::collect_link_annotations(&element.children, page_height, annotations);
3976        }
3977    }
3978
3979    /// Collect form field annotations from layout elements.
3980    fn collect_form_fields(
3981        elements: &[LayoutElement],
3982        page_height: f64,
3983        page_idx: usize,
3984        fields: &mut Vec<FormFieldData>,
3985    ) {
3986        for element in elements {
3987            if let DrawCommand::FormField {
3988                ref field_type,
3989                ref name,
3990            } = element.draw
3991            {
3992                let pdf_y = page_height - element.y - element.height;
3993                fields.push(FormFieldData {
3994                    field_type: field_type.clone(),
3995                    name: name.clone(),
3996                    x: element.x,
3997                    y: pdf_y,
3998                    width: element.width,
3999                    height: element.height,
4000                    page_idx,
4001                });
4002            }
4003            Self::collect_form_fields(&element.children, page_height, page_idx, fields);
4004        }
4005    }
4006
4007    /// Collect bookmarks from layout elements.
4008    fn collect_bookmarks(
4009        elements: &[LayoutElement],
4010        page_height: f64,
4011        page_obj_id: usize,
4012        bookmarks: &mut Vec<PdfBookmark>,
4013    ) {
4014        for element in elements {
4015            if let Some(ref title) = element.bookmark {
4016                let y_pdf = page_height - element.y;
4017                bookmarks.push(PdfBookmark {
4018                    title: title.clone(),
4019                    page_obj_id,
4020                    y_pdf,
4021                });
4022            }
4023            Self::collect_bookmarks(&element.children, page_height, page_obj_id, bookmarks);
4024        }
4025    }
4026
4027    /// Build the PDF outline tree from bookmark entries.
4028    /// Returns the object ID of the /Outlines dictionary.
4029    fn write_outline_tree(&self, builder: &mut PdfBuilder, bookmarks: &[PdfBookmark]) -> usize {
4030        // Reserve the Outlines dictionary object
4031        let outlines_id = builder.objects.len();
4032        builder.objects.push(PdfObject {
4033            id: outlines_id,
4034            data: vec![],
4035        });
4036
4037        // Create outline item objects
4038        let mut item_ids: Vec<usize> = Vec::new();
4039        for _bm in bookmarks {
4040            let item_id = builder.objects.len();
4041            builder.objects.push(PdfObject {
4042                id: item_id,
4043                data: vec![],
4044            });
4045            item_ids.push(item_id);
4046        }
4047
4048        // Fill in outline items with /Prev, /Next, /Parent, /Dest
4049        for (i, (bm, &item_id)) in bookmarks.iter().zip(item_ids.iter()).enumerate() {
4050            let mut dict = format!(
4051                "<< /Title ({}) /Parent {} 0 R /Dest [{} 0 R /XYZ 0 {:.2} null]",
4052                Self::escape_pdf_string(&bm.title),
4053                outlines_id,
4054                bm.page_obj_id,
4055                bm.y_pdf,
4056            );
4057            if i > 0 {
4058                let _ = write!(dict, " /Prev {} 0 R", item_ids[i - 1]);
4059            }
4060            if i + 1 < item_ids.len() {
4061                let _ = write!(dict, " /Next {} 0 R", item_ids[i + 1]);
4062            }
4063            dict.push_str(" >>");
4064            builder.objects[item_id].data = dict.into_bytes();
4065        }
4066
4067        // Fill in Outlines dictionary
4068        let first_id = item_ids.first().copied().unwrap_or(0);
4069        let last_id = item_ids.last().copied().unwrap_or(0);
4070        let outlines_dict = format!(
4071            "<< /Type /Outlines /First {} 0 R /Last {} 0 R /Count {} >>",
4072            first_id,
4073            last_id,
4074            bookmarks.len()
4075        );
4076        builder.objects[outlines_id].data = outlines_dict.into_bytes();
4077
4078        outlines_id
4079    }
4080
4081    /// Write SVG drawing commands to a PDF content stream.
4082    fn write_svg_commands(
4083        stream: &mut String,
4084        commands: &[SvgCommand],
4085        ext_gstate_map: &HashMap<u64, (usize, String)>,
4086    ) {
4087        for cmd in commands {
4088            match cmd {
4089                SvgCommand::MoveTo(x, y) => {
4090                    let _ = writeln!(stream, "{:.2} {:.2} m", x, y);
4091                }
4092                SvgCommand::LineTo(x, y) => {
4093                    let _ = writeln!(stream, "{:.2} {:.2} l", x, y);
4094                }
4095                SvgCommand::CurveTo(x1, y1, x2, y2, x3, y3) => {
4096                    let _ = writeln!(
4097                        stream,
4098                        "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4099                        x1, y1, x2, y2, x3, y3
4100                    );
4101                }
4102                SvgCommand::ClosePath => {
4103                    let _ = writeln!(stream, "h");
4104                }
4105                SvgCommand::SetFill(r, g, b) => {
4106                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", r, g, b);
4107                }
4108                SvgCommand::SetFillNone => {
4109                    // No-op in PDF; handled by fill/stroke selection
4110                }
4111                SvgCommand::SetStroke(r, g, b) => {
4112                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", r, g, b);
4113                }
4114                SvgCommand::SetStrokeNone => {
4115                    // No-op in PDF
4116                }
4117                SvgCommand::SetStrokeWidth(w) => {
4118                    let _ = writeln!(stream, "{:.2} w", w);
4119                }
4120                SvgCommand::Fill => {
4121                    let _ = writeln!(stream, "f");
4122                }
4123                SvgCommand::Stroke => {
4124                    let _ = writeln!(stream, "S");
4125                }
4126                SvgCommand::FillAndStroke => {
4127                    let _ = writeln!(stream, "B");
4128                }
4129                SvgCommand::SetLineCap(cap) => {
4130                    let _ = writeln!(stream, "{} J", cap);
4131                }
4132                SvgCommand::SetLineJoin(join) => {
4133                    let _ = writeln!(stream, "{} j", join);
4134                }
4135                SvgCommand::SaveState => {
4136                    let _ = writeln!(stream, "q");
4137                }
4138                SvgCommand::RestoreState => {
4139                    let _ = writeln!(stream, "Q");
4140                }
4141                SvgCommand::SetOpacity(opacity) => {
4142                    if let Some((_, gs_name)) = ext_gstate_map.get(&opacity.to_bits()) {
4143                        let _ = writeln!(stream, "/{} gs", gs_name);
4144                    }
4145                }
4146            }
4147        }
4148    }
4149
4150    /// Escape special characters in a PDF string.
4151    pub(crate) fn escape_pdf_string(s: &str) -> String {
4152        s.replace('\\', "\\\\")
4153            .replace('(', "\\(")
4154            .replace(')', "\\)")
4155    }
4156
4157    /// Decode an attachment `src`: plain base64, with an optional
4158    /// `data:...;base64,` prefix tolerated (same convention as fonts).
4159    fn decode_attachment_src(src: &str) -> Result<Vec<u8>, FormeError> {
4160        use base64::Engine as _;
4161        let b64 = src.rsplit_once(";base64,").map(|(_, d)| d).unwrap_or(src);
4162        base64::engine::general_purpose::STANDARD
4163            .decode(b64.trim())
4164            .map_err(|e| {
4165                FormeError::RenderError(format!(
4166                    "attachment src is not valid base64 (expected base64 bytes or a data: URI): {e}"
4167                ))
4168            })
4169    }
4170
4171    /// Encode a MIME type as a PDF name (PDF 32000 §7.3.5): delimiter and
4172    /// non-regular characters become #XX — `text/xml` → `text#2Fxml`.
4173    fn mime_to_pdf_name(mime: &str) -> String {
4174        let mut out = String::with_capacity(mime.len() + 2);
4175        for b in mime.bytes() {
4176            let regular =
4177                b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+' | b'\'' | b'"');
4178            if regular {
4179                out.push(b as char);
4180            } else {
4181                let _ = write!(out, "#{:02X}", b);
4182            }
4183        }
4184        out
4185    }
4186
4187    /// Encode a string for use in a PDF content stream with WinAnsi encoding.
4188    /// Characters outside WinAnsi range are replaced with '?'.
4189    fn encode_winansi_text(s: &str) -> String {
4190        let mut result = String::with_capacity(s.len());
4191        for ch in s.chars() {
4192            let b = Self::unicode_to_winansi(ch).unwrap_or(b'?');
4193            match b {
4194                b'\\' => result.push_str("\\\\"),
4195                b'(' => result.push_str("\\("),
4196                b')' => result.push_str("\\)"),
4197                0x20..=0x7E => result.push(b as char),
4198                _ => {
4199                    let _ = write!(result, "\\{:03o}", b);
4200                }
4201            }
4202        }
4203        result
4204    }
4205
4206    /// Map a Unicode codepoint to a WinAnsiEncoding byte value.
4207    fn unicode_to_winansi(ch: char) -> Option<u8> {
4208        crate::font::unicode_to_winansi(ch)
4209    }
4210
4211    /// Serialize all objects into the final PDF byte stream.
4212    fn serialize(&self, builder: &PdfBuilder, info_obj_id: Option<usize>) -> Vec<u8> {
4213        let mut output: Vec<u8> = Vec::new();
4214        let mut offsets: Vec<usize> = vec![0; builder.objects.len()];
4215
4216        // Header
4217        output.extend_from_slice(b"%PDF-1.7\n");
4218        output.extend_from_slice(b"%\xe2\xe3\xcf\xd3\n");
4219
4220        for (i, obj) in builder.objects.iter().enumerate().skip(1) {
4221            offsets[i] = output.len();
4222            let header = format!("{} 0 obj\n", i);
4223            output.extend_from_slice(header.as_bytes());
4224            output.extend_from_slice(&obj.data);
4225            output.extend_from_slice(b"\nendobj\n\n");
4226        }
4227
4228        let xref_offset = output.len();
4229        let _ = writeln!(output, "xref\n0 {}", builder.objects.len());
4230        let _ = writeln!(output, "0000000000 65535 f ");
4231        for offset in offsets.iter().skip(1) {
4232            let _ = writeln!(output, "{:010} 00000 n ", offset);
4233        }
4234
4235        let _ = write!(
4236            output,
4237            "trailer\n<< /Size {} /Root 1 0 R",
4238            builder.objects.len()
4239        );
4240        if let Some(info_id) = info_obj_id {
4241            let _ = write!(output, " /Info {} 0 R", info_id);
4242        }
4243        // /ID — required by PDF/A (6.1.3) and generally expected. Derived
4244        // deterministically from the file content (SHA-256 of everything written
4245        // so far), NOT a timestamp or random bytes, so native and WASM builds
4246        // stay byte-identical. The two identifiers are equal for a freshly
4247        // created (never incrementally updated) file, per ISO 32000-1 14.4.
4248        {
4249            use sha2::Digest as _;
4250            let digest = sha2::Sha256::digest(&output);
4251            let mut id_hex = String::with_capacity(32);
4252            for b in &digest[..16] {
4253                let _ = write!(id_hex, "{:02X}", b);
4254            }
4255            let _ = write!(output, " /ID [<{id_hex}> <{id_hex}>]");
4256        }
4257        let _ = writeln!(output, " >>\nstartxref\n{}\n%%EOF", xref_offset);
4258
4259        output
4260    }
4261}
4262
4263/// Write a single chart drawing primitive to the PDF content stream.
4264///
4265/// Called within a Y-flipped coordinate system (1 0 0 -1 x page_h-y cm),
4266/// so chart primitives use top-left origin (Y increases downward).
4267fn write_chart_primitive(
4268    stream: &mut String,
4269    prim: &crate::chart::ChartPrimitive,
4270    _chart_height: f64,
4271    builder: &PdfBuilder,
4272) {
4273    use crate::chart::{ChartPrimitive, TextAnchor};
4274    use crate::font::metrics::unicode_to_winansi;
4275
4276    match prim {
4277        ChartPrimitive::Rect { x, y, w, h, fill } => {
4278            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4279            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re f", x, y, w, h);
4280        }
4281
4282        ChartPrimitive::Line {
4283            x1,
4284            y1,
4285            x2,
4286            y2,
4287            stroke,
4288            width,
4289        } => {
4290            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
4291            let _ = writeln!(stream, "{:.2} w", width);
4292            let _ = writeln!(stream, "{:.2} {:.2} m {:.2} {:.2} l S", x1, y1, x2, y2);
4293        }
4294
4295        ChartPrimitive::Polyline {
4296            points,
4297            stroke,
4298            width,
4299        } => {
4300            if points.len() < 2 {
4301                return;
4302            }
4303            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
4304            let _ = writeln!(stream, "{:.2} w", width);
4305            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
4306            for &(px, py) in &points[1..] {
4307                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
4308            }
4309            let _ = writeln!(stream, "S");
4310        }
4311
4312        ChartPrimitive::FilledPath {
4313            points,
4314            fill,
4315            opacity,
4316        } => {
4317            if points.len() < 3 {
4318                return;
4319            }
4320            let _ = writeln!(stream, "q");
4321            // Set opacity via ExtGState if available
4322            if *opacity < 1.0 {
4323                if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
4324                    let _ = writeln!(stream, "/{} gs", gs_name);
4325                }
4326            }
4327            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4328            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
4329            for &(px, py) in &points[1..] {
4330                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
4331            }
4332            let _ = writeln!(stream, "h f");
4333            let _ = writeln!(stream, "Q");
4334        }
4335
4336        ChartPrimitive::Circle { cx, cy, r, fill } => {
4337            // Approximate circle with 4 cubic bezier curves
4338            let kappa: f64 = 0.5523;
4339            let kr = kappa * r;
4340            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4341            let _ = writeln!(stream, "{:.2} {:.2} m", cx + r, cy);
4342            let _ = writeln!(
4343                stream,
4344                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4345                cx + r,
4346                cy + kr,
4347                cx + kr,
4348                cy + r,
4349                cx,
4350                cy + r
4351            );
4352            let _ = writeln!(
4353                stream,
4354                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4355                cx - kr,
4356                cy + r,
4357                cx - r,
4358                cy + kr,
4359                cx - r,
4360                cy
4361            );
4362            let _ = writeln!(
4363                stream,
4364                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4365                cx - r,
4366                cy - kr,
4367                cx - kr,
4368                cy - r,
4369                cx,
4370                cy - r
4371            );
4372            let _ = writeln!(
4373                stream,
4374                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4375                cx + kr,
4376                cy - r,
4377                cx + r,
4378                cy - kr,
4379                cx + r,
4380                cy
4381            );
4382            let _ = writeln!(stream, "f");
4383        }
4384
4385        ChartPrimitive::ArcSector {
4386            cx,
4387            cy,
4388            r,
4389            start_angle,
4390            end_angle,
4391            fill,
4392        } => {
4393            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
4394            // Move to center
4395            let _ = writeln!(stream, "{:.2} {:.2} m", cx, cy);
4396            // Line to arc start
4397            let sx = cx + r * start_angle.cos();
4398            let sy = cy + r * start_angle.sin();
4399            let _ = writeln!(stream, "{:.2} {:.2} l", sx, sy);
4400
4401            // Approximate arc with cubic bezier segments (max 90° per segment)
4402            let mut angle = *start_angle;
4403            let total = end_angle - start_angle;
4404            let segments = ((total.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
4405            let step = total / segments as f64;
4406
4407            for _ in 0..segments {
4408                let a1 = angle;
4409                let a2 = angle + step;
4410                let alpha = 4.0 / 3.0 * ((a2 - a1) / 4.0).tan();
4411
4412                let p1x = cx + r * a1.cos();
4413                let p1y = cy + r * a1.sin();
4414                let p2x = cx + r * a2.cos();
4415                let p2y = cy + r * a2.sin();
4416
4417                let cp1x = p1x - alpha * r * a1.sin();
4418                let cp1y = p1y + alpha * r * a1.cos();
4419                let cp2x = p2x + alpha * r * a2.sin();
4420                let cp2y = p2y - alpha * r * a2.cos();
4421
4422                let _ = writeln!(
4423                    stream,
4424                    "{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c",
4425                    cp1x, cp1y, cp2x, cp2y, p2x, p2y
4426                );
4427                angle = a2;
4428            }
4429
4430            // Close path back to center and fill
4431            let _ = writeln!(stream, "h f");
4432        }
4433
4434        ChartPrimitive::Label {
4435            text,
4436            x,
4437            y,
4438            font_size,
4439            color,
4440            anchor,
4441        } => {
4442            // Measure text width for anchor alignment
4443            let metrics = crate::font::StandardFont::Helvetica.metrics();
4444            let text_width = metrics.measure_string(text, *font_size, 0.0);
4445            let x_offset = match anchor {
4446                TextAnchor::Left => 0.0,
4447                TextAnchor::Center => -text_width / 2.0,
4448                TextAnchor::Right => -text_width,
4449            };
4450
4451            // Find Helvetica font index in font_objects
4452            let font_idx = builder
4453                .font_objects
4454                .iter()
4455                .enumerate()
4456                .find(|(_, (key, _))| key.family == "Helvetica" && key.weight == 400 && !key.italic)
4457                .map(|(i, _)| i)
4458                .unwrap_or(0);
4459
4460            // Encode text to WinAnsi
4461            let encoded: String = text
4462                .chars()
4463                .map(|ch| {
4464                    if let Some(code) = unicode_to_winansi(ch) {
4465                        code as char
4466                    } else if (ch as u32) >= 32 && (ch as u32) <= 255 {
4467                        ch
4468                    } else {
4469                        '?'
4470                    }
4471                })
4472                .collect();
4473            let escaped = pdf_escape_string(&encoded);
4474
4475            // Undo Y-flip for text rendering, then position
4476            let _ = writeln!(stream, "q");
4477            let _ = writeln!(stream, "1 0 0 -1 {:.4} {:.4} cm", x + x_offset, *y);
4478            let _ = writeln!(
4479                stream,
4480                "BT /F{} {:.1} Tf {:.3} {:.3} {:.3} rg 0 0 Td ({}) Tj ET",
4481                font_idx, font_size, color.r, color.g, color.b, escaped
4482            );
4483            let _ = writeln!(stream, "Q");
4484        }
4485    }
4486}
4487
4488/// Normalize a list of gradient stops for PDF Shading emission. Clamps
4489/// positions to [0, 1], sorts ascending by position, and pads with
4490/// implicit stops at 0 and 1 (using the closest defined stop's color)
4491/// when the input doesn't cover the full range. Empty input collapses to
4492/// two `fallback`-colored stops at 0 and 1 so the caller never has to
4493/// special-case zero stops.
4494fn normalize_gradient_stops(
4495    stops: &[crate::style::GradientStop],
4496    fallback: Color,
4497) -> Vec<crate::style::GradientStop> {
4498    use crate::style::GradientStop;
4499    if stops.is_empty() {
4500        return vec![
4501            GradientStop {
4502                position: 0.0,
4503                color: fallback,
4504            },
4505            GradientStop {
4506                position: 1.0,
4507                color: fallback,
4508            },
4509        ];
4510    }
4511    let mut sorted: Vec<GradientStop> = stops
4512        .iter()
4513        .map(|s| GradientStop {
4514            position: s.position.clamp(0.0, 1.0),
4515            color: s.color,
4516        })
4517        .collect();
4518    sorted.sort_by(|a, b| {
4519        a.position
4520            .partial_cmp(&b.position)
4521            .unwrap_or(std::cmp::Ordering::Equal)
4522    });
4523    if sorted[0].position > 0.0 {
4524        sorted.insert(
4525            0,
4526            GradientStop {
4527                position: 0.0,
4528                color: sorted[0].color,
4529            },
4530        );
4531    }
4532    if sorted[sorted.len() - 1].position < 1.0 {
4533        let last = sorted[sorted.len() - 1].color;
4534        sorted.push(GradientStop {
4535            position: 1.0,
4536            color: last,
4537        });
4538    }
4539    sorted
4540}
4541
4542fn pdf_escape_string(s: &str) -> String {
4543    let mut out = String::with_capacity(s.len());
4544    for ch in s.chars() {
4545        match ch {
4546            '(' => out.push_str("\\("),
4547            ')' => out.push_str("\\)"),
4548            '\\' => out.push_str("\\\\"),
4549            _ => out.push(ch),
4550        }
4551    }
4552    out
4553}
4554
4555#[cfg(test)]
4556mod tests {
4557    use super::*;
4558    use crate::font::FontContext;
4559
4560    /// The embedded sRGB profile must be a REAL ICC profile suitable for a
4561    /// PDF/A OutputIntent — not, say, an HTML error page a `curl` returned and
4562    /// nobody inspected (which is exactly what shipped from v0.6.0 through 0.15.0,
4563    /// silently making every PDF/A OutputIntent invalid). This is the check
4564    /// that would have caught it: ICC signature, an OutputIntent-legal device
4565    /// class (`mntr`/`prtr`), and an RGB data colour space.
4566    #[test]
4567    fn test_embedded_srgb_is_a_valid_icc_profile() {
4568        let icc: &[u8] = include_bytes!("sRGB.icc");
4569        assert!(
4570            icc.len() >= 128,
4571            "ICC shorter than its 128-byte header: {}",
4572            icc.len()
4573        );
4574        // Not HTML / not a text error page.
4575        assert_ne!(
4576            icc[0], b'<',
4577            "embedded ICC starts with '<' — looks like HTML, not a profile"
4578        );
4579        // 'acsp' profile-file signature at bytes 36..40 (ISO 15076-1 / ICC.1).
4580        assert_eq!(&icc[36..40], b"acsp", "missing ICC 'acsp' signature");
4581        // Device class (bytes 12..16) must be monitor or output for an OutputIntent.
4582        let device_class = &icc[12..16];
4583        assert!(
4584            device_class == b"mntr" || device_class == b"prtr",
4585            "ICC device class {:?} is not mntr/prtr (PDF/A 6.2.3)",
4586            String::from_utf8_lossy(device_class),
4587        );
4588        // Data colour space (bytes 16..20) must be RGB for an sRGB OutputIntent.
4589        assert_eq!(&icc[16..20], b"RGB ", "ICC data colour space is not RGB");
4590    }
4591
4592    #[test]
4593    fn test_escape_pdf_string() {
4594        assert_eq!(
4595            PdfWriter::escape_pdf_string("Hello (World)"),
4596            "Hello \\(World\\)"
4597        );
4598        assert_eq!(PdfWriter::escape_pdf_string("back\\slash"), "back\\\\slash");
4599    }
4600
4601    #[test]
4602    fn test_empty_document_produces_valid_pdf() {
4603        let writer = PdfWriter::new();
4604        let font_context = FontContext::new();
4605        let pages = vec![LayoutPage {
4606            width: 595.28,
4607            height: 841.89,
4608            elements: vec![],
4609            fixed_header: vec![],
4610            fixed_footer: vec![],
4611            watermarks: vec![],
4612            config: PageConfig::default(),
4613            page_name: None,
4614        }];
4615        let metadata = Metadata::default();
4616        let (bytes, _warnings) = writer
4617            .write(
4618                &pages,
4619                &metadata,
4620                &font_context,
4621                false,
4622                None,
4623                false,
4624                None,
4625                &[],
4626                None,
4627                false,
4628            )
4629            .unwrap();
4630
4631        assert!(bytes.starts_with(b"%PDF-1.7"));
4632        assert!(bytes.windows(5).any(|w| w == b"%%EOF"));
4633        assert!(bytes.windows(4).any(|w| w == b"xref"));
4634        assert!(bytes.windows(7).any(|w| w == b"trailer"));
4635    }
4636
4637    #[test]
4638    fn test_metadata_in_pdf() {
4639        let writer = PdfWriter::new();
4640        let font_context = FontContext::new();
4641        let pages = vec![LayoutPage {
4642            width: 595.28,
4643            height: 841.89,
4644            elements: vec![],
4645            fixed_header: vec![],
4646            fixed_footer: vec![],
4647            watermarks: vec![],
4648            config: PageConfig::default(),
4649            page_name: None,
4650        }];
4651        let metadata = Metadata {
4652            title: Some("Test Document".to_string()),
4653            author: Some("Forme".to_string()),
4654            subject: None,
4655            creator: None,
4656            lang: None,
4657        };
4658        let (bytes, _warnings) = writer
4659            .write(
4660                &pages,
4661                &metadata,
4662                &font_context,
4663                false,
4664                None,
4665                false,
4666                None,
4667                &[],
4668                None,
4669                false,
4670            )
4671            .unwrap();
4672        let text = String::from_utf8_lossy(&bytes);
4673
4674        assert!(text.contains("/Title (Test Document)"));
4675        assert!(text.contains("/Author (Forme)"));
4676    }
4677
4678    #[test]
4679    fn test_bold_font_registered_separately() {
4680        let writer = PdfWriter::new();
4681        let font_context = FontContext::new();
4682
4683        // Create pages with both regular and bold text
4684        let pages = vec![LayoutPage {
4685            width: 595.28,
4686            height: 841.89,
4687            elements: vec![
4688                LayoutElement {
4689                    x: 54.0,
4690                    y: 54.0,
4691                    width: 100.0,
4692                    height: 16.8,
4693                    draw: DrawCommand::Text {
4694                        lines: vec![TextLine {
4695                            x: 54.0,
4696                            y: 66.0,
4697                            width: 50.0,
4698                            height: 16.8,
4699                            glyphs: vec![PositionedGlyph {
4700                                glyph_id: 65,
4701                                x_offset: 0.0,
4702                                y_offset: 0.0,
4703                                x_advance: 8.0,
4704                                font_size: 12.0,
4705                                font_family: "Helvetica".into(),
4706                                font_weight: 400,
4707                                font_style: FontStyle::Normal,
4708                                char_value: 'A',
4709                                color: None,
4710                                href: None,
4711                                text_decoration: TextDecoration::None,
4712                                letter_spacing: 0.0,
4713                                cluster_text: None,
4714                            }],
4715                            word_spacing: 0.0,
4716                        }],
4717                        color: Color::BLACK,
4718                        text_decoration: TextDecoration::None,
4719                        opacity: 1.0,
4720                    },
4721                    children: vec![],
4722                    node_type: None,
4723                    resolved_style: None,
4724                    source_location: None,
4725                    href: None,
4726                    bookmark: None,
4727                    alt: None,
4728                    is_header_row: false,
4729                    col_span: 1,
4730                    overflow: Overflow::default(),
4731                    opacity: 1.0,
4732                },
4733                LayoutElement {
4734                    x: 54.0,
4735                    y: 74.0,
4736                    width: 100.0,
4737                    height: 16.8,
4738                    draw: DrawCommand::Text {
4739                        lines: vec![TextLine {
4740                            x: 54.0,
4741                            y: 86.0,
4742                            width: 50.0,
4743                            height: 16.8,
4744                            glyphs: vec![PositionedGlyph {
4745                                glyph_id: 65,
4746                                x_offset: 0.0,
4747                                y_offset: 0.0,
4748                                x_advance: 8.0,
4749                                font_size: 12.0,
4750                                font_family: "Helvetica".into(),
4751                                font_weight: 700,
4752                                font_style: FontStyle::Normal,
4753                                char_value: 'A',
4754                                color: None,
4755                                href: None,
4756                                text_decoration: TextDecoration::None,
4757                                letter_spacing: 0.0,
4758                                cluster_text: None,
4759                            }],
4760                            word_spacing: 0.0,
4761                        }],
4762                        color: Color::BLACK,
4763                        text_decoration: TextDecoration::None,
4764                        opacity: 1.0,
4765                    },
4766                    children: vec![],
4767                    node_type: None,
4768                    resolved_style: None,
4769                    source_location: None,
4770                    href: None,
4771                    bookmark: None,
4772                    alt: None,
4773                    is_header_row: false,
4774                    col_span: 1,
4775                    overflow: Overflow::default(),
4776                    opacity: 1.0,
4777                },
4778            ],
4779            fixed_header: vec![],
4780            fixed_footer: vec![],
4781            watermarks: vec![],
4782            config: PageConfig::default(),
4783            page_name: None,
4784        }];
4785
4786        let metadata = Metadata::default();
4787        let (bytes, _warnings) = writer
4788            .write(
4789                &pages,
4790                &metadata,
4791                &font_context,
4792                false,
4793                None,
4794                false,
4795                None,
4796                &[],
4797                None,
4798                false,
4799            )
4800            .unwrap();
4801        let text = String::from_utf8_lossy(&bytes);
4802
4803        // Should have both Helvetica and Helvetica-Bold registered
4804        assert!(
4805            text.contains("Helvetica"),
4806            "Should contain regular Helvetica"
4807        );
4808        assert!(
4809            text.contains("Helvetica-Bold"),
4810            "Should contain Helvetica-Bold"
4811        );
4812    }
4813
4814    #[test]
4815    fn test_sanitize_font_name() {
4816        assert_eq!(PdfWriter::sanitize_font_name("Inter", 400, false), "Inter");
4817        assert_eq!(
4818            PdfWriter::sanitize_font_name("Inter", 700, false),
4819            "Inter-Bold"
4820        );
4821        assert_eq!(
4822            PdfWriter::sanitize_font_name("Inter", 400, true),
4823            "Inter-Italic"
4824        );
4825        assert_eq!(
4826            PdfWriter::sanitize_font_name("Inter", 700, true),
4827            "Inter-Bold-Italic"
4828        );
4829        assert_eq!(
4830            PdfWriter::sanitize_font_name("Noto Sans", 400, false),
4831            "NotoSans"
4832        );
4833        assert_eq!(
4834            PdfWriter::sanitize_font_name("Font (Display)", 400, false),
4835            "FontDisplay"
4836        );
4837    }
4838
4839    #[test]
4840    fn test_tounicode_cmap_format() {
4841        // glyph_to_char: maps subset glyph IDs → Unicode chars
4842        let mut glyph_to_char = HashMap::new();
4843        glyph_to_char.insert(36u16, 'A');
4844        glyph_to_char.insert(37u16, 'B');
4845
4846        let cmap = PdfWriter::build_tounicode_cmap_from_gids(&glyph_to_char, "TestFont");
4847
4848        assert!(cmap.contains("begincmap"), "CMap should contain begincmap");
4849        assert!(cmap.contains("endcmap"), "CMap should contain endcmap");
4850        assert!(
4851            cmap.contains("beginbfchar"),
4852            "CMap should contain beginbfchar"
4853        );
4854        assert!(cmap.contains("endbfchar"), "CMap should contain endbfchar");
4855        assert!(
4856            cmap.contains("<0024> <0041>"),
4857            "Should map gid 0x0024 to Unicode 'A' 0x0041"
4858        );
4859        assert!(
4860            cmap.contains("<0025> <0042>"),
4861            "Should map gid 0x0025 to Unicode 'B' 0x0042"
4862        );
4863        assert!(
4864            cmap.contains("begincodespacerange"),
4865            "Should define codespace range"
4866        );
4867        assert!(
4868            cmap.contains("<0000> <FFFF>"),
4869            "Codespace should be 0000-FFFF"
4870        );
4871    }
4872
4873    #[test]
4874    fn test_w_array_format() {
4875        let mut char_to_gid = HashMap::new();
4876        char_to_gid.insert('A', 36u16);
4877
4878        // We need actual font data to test this properly, so just verify format
4879        // with a minimal check that the function produces valid output
4880        let w_array_str = "[ 36 [600] ]";
4881        assert!(w_array_str.starts_with('['));
4882        assert!(w_array_str.ends_with(']'));
4883    }
4884
4885    #[test]
4886    fn test_hex_glyph_encoding() {
4887        // Verify the hex format used for custom font text encoding
4888        let gid: u16 = 0x0041;
4889        let hex = format!("{:04X}", gid);
4890        assert_eq!(hex, "0041");
4891
4892        let gids = [0x0041u16, 0x0042, 0x0043];
4893        let hex_str: String = gids.iter().map(|g| format!("{:04X}", g)).collect();
4894        assert_eq!(hex_str, "004100420043");
4895    }
4896
4897    #[test]
4898    fn test_standard_font_still_uses_text_string() {
4899        let writer = PdfWriter::new();
4900        let font_context = FontContext::new();
4901
4902        let pages = vec![LayoutPage {
4903            width: 595.28,
4904            height: 841.89,
4905            elements: vec![LayoutElement {
4906                x: 54.0,
4907                y: 54.0,
4908                width: 100.0,
4909                height: 16.8,
4910                draw: DrawCommand::Text {
4911                    lines: vec![TextLine {
4912                        x: 54.0,
4913                        y: 66.0,
4914                        width: 50.0,
4915                        height: 16.8,
4916                        glyphs: vec![PositionedGlyph {
4917                            glyph_id: 65,
4918                            x_offset: 0.0,
4919                            y_offset: 0.0,
4920                            x_advance: 8.0,
4921                            font_size: 12.0,
4922                            font_family: "Helvetica".into(),
4923                            font_weight: 400,
4924                            font_style: FontStyle::Normal,
4925                            char_value: 'H',
4926                            color: None,
4927                            href: None,
4928                            text_decoration: TextDecoration::None,
4929                            letter_spacing: 0.0,
4930                            cluster_text: None,
4931                        }],
4932                        word_spacing: 0.0,
4933                    }],
4934                    color: Color::BLACK,
4935                    text_decoration: TextDecoration::None,
4936                    opacity: 1.0,
4937                },
4938                children: vec![],
4939                node_type: None,
4940                resolved_style: None,
4941                source_location: None,
4942                href: None,
4943                bookmark: None,
4944                alt: None,
4945                is_header_row: false,
4946                col_span: 1,
4947                overflow: Overflow::default(),
4948                opacity: 1.0,
4949            }],
4950            fixed_header: vec![],
4951            fixed_footer: vec![],
4952            watermarks: vec![],
4953            config: PageConfig::default(),
4954            page_name: None,
4955        }];
4956
4957        let metadata = Metadata::default();
4958        let (bytes, _warnings) = writer
4959            .write(
4960                &pages,
4961                &metadata,
4962                &font_context,
4963                false,
4964                None,
4965                false,
4966                None,
4967                &[],
4968                None,
4969                false,
4970            )
4971            .unwrap();
4972        let text = String::from_utf8_lossy(&bytes);
4973
4974        // Standard fonts should use Type1, not CIDFontType2
4975        assert!(
4976            text.contains("/Type1"),
4977            "Standard font should use Type1 subtype"
4978        );
4979        assert!(
4980            !text.contains("CIDFontType2"),
4981            "Standard font should not use CIDFontType2"
4982        );
4983    }
4984}