Skip to main content

forme/pdf/
mod.rs

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