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