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