Skip to main content

forme/pdf/
mod.rs

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