Skip to main content

forme/pdf/
mod.rs

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