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(crate) mod tagged;
30pub(crate) mod xmp;
31
32use std::collections::{HashMap, HashSet};
33use std::fmt::Write as FmtWrite; // for write! on String
34use std::io::Write as IoWrite; // for write! on Vec<u8>
35
36use crate::error::FormeError;
37use crate::font::subset::subset_ttf;
38use crate::font::{FontContext, FontData, FontKey};
39use crate::layout::*;
40use crate::model::*;
41use crate::style::{Color, FontStyle, Overflow, TextDecoration};
42use crate::svg::SvgCommand;
43use miniz_oxide::deflate::compress_to_vec_zlib;
44
45/// A link annotation to be added to a page.
46struct LinkAnnotation {
47    x: f64,
48    y: f64,
49    width: f64,
50    height: f64,
51    href: String,
52}
53
54/// A bookmark entry for the PDF outline tree.
55struct PdfBookmark {
56    title: String,
57    page_obj_id: usize,
58    y_pdf: f64,
59}
60
61pub struct PdfWriter;
62
63/// Embedding data for a custom TrueType font.
64#[allow(dead_code)]
65struct CustomFontEmbedData {
66    ttf_data: Vec<u8>,
67    /// Maps original glyph IDs (from shaping) to remapped GIDs in the subset font.
68    gid_remap: HashMap<u16, u16>,
69    /// Maps original glyph IDs to their Unicode character(s) for ToUnicode CMap.
70    glyph_to_char: HashMap<u16, char>,
71    /// Legacy fallback: maps chars to subset GIDs (for page number placeholders).
72    char_to_gid: HashMap<char, u16>,
73    units_per_em: u16,
74    ascender: i16,
75    descender: i16,
76}
77
78/// Font usage data collected from layout elements.
79struct FontUsage {
80    /// Characters used per font (for standard font subsetting fallback).
81    chars: HashSet<char>,
82    /// Glyph IDs used per font (from shaped PositionedGlyphs).
83    glyph_ids: HashSet<u16>,
84    /// Maps glyph ID → first char it represents (for ToUnicode CMap).
85    glyph_to_char: HashMap<u16, char>,
86}
87
88/// Tracks allocated PDF objects during writing.
89struct PdfBuilder {
90    objects: Vec<PdfObject>,
91    /// Maps (family, weight, italic) -> (object_id, index)
92    font_objects: Vec<(FontKey, usize)>,
93    /// Embedding data for custom fonts, keyed by FontKey.
94    custom_font_data: HashMap<FontKey, CustomFontEmbedData>,
95    /// XObject obj IDs for images, indexed as /Im0, /Im1, ...
96    /// Each entry is (main_xobject_id, optional_smask_xobject_id).
97    image_objects: Vec<usize>,
98    /// Maps (page_index, element_position_in_page) to image index in image_objects.
99    /// Used during content stream writing to find the right /ImN reference.
100    image_index_map: HashMap<(usize, usize), usize>,
101    /// ExtGState objects for opacity. Maps opacity value (as ordered bits) to
102    /// (object_id, gs_name) e.g. (42, "GS0").
103    ext_gstate_map: HashMap<u64, (usize, String)>,
104}
105
106pub(crate) struct PdfObject {
107    #[allow(dead_code)]
108    pub(crate) id: usize,
109    pub(crate) data: Vec<u8>,
110}
111
112impl Default for PdfWriter {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl PdfWriter {
119    pub fn new() -> Self {
120        Self
121    }
122
123    /// Write laid-out pages to a PDF byte vector.
124    pub fn write(
125        &self,
126        pages: &[LayoutPage],
127        metadata: &Metadata,
128        font_context: &FontContext,
129        tagged: bool,
130        pdfa: Option<&PdfAConformance>,
131        embedded_data: Option<&str>,
132    ) -> Result<Vec<u8>, FormeError> {
133        let mut builder = PdfBuilder {
134            objects: Vec::new(),
135            font_objects: Vec::new(),
136            custom_font_data: HashMap::new(),
137            image_objects: Vec::new(),
138            image_index_map: HashMap::new(),
139            ext_gstate_map: HashMap::new(),
140        };
141
142        // Reserve object IDs:
143        // 0 = placeholder (PDF objects are 1-indexed)
144        // 1 = Catalog
145        // 2 = Pages (page tree root)
146        // 3+ = fonts, then page objects, then content streams
147        builder.objects.push(PdfObject {
148            id: 0,
149            data: vec![],
150        });
151        builder.objects.push(PdfObject {
152            id: 1,
153            data: vec![],
154        });
155        builder.objects.push(PdfObject {
156            id: 2,
157            data: vec![],
158        });
159
160        // Register the fonts actually used across all pages
161        self.register_fonts(&mut builder, pages, font_context)?;
162
163        // PDF/A: validate that all fonts are embedded (no standard fonts)
164        if pdfa.is_some() {
165            for (key, _) in &builder.font_objects {
166                if !builder.custom_font_data.contains_key(key) {
167                    return Err(FormeError::RenderError(format!(
168                        "PDF/A requires all fonts to be embedded. Register a custom font for \
169                         family '{}' using Font.register().",
170                        key.family
171                    )));
172                }
173            }
174        }
175
176        // Register images as XObject PDF objects
177        self.register_images(&mut builder, pages);
178
179        // Register ExtGState objects for opacity
180        self.register_ext_gstates(&mut builder, pages);
181
182        // Create tag builder for accessibility if requested
183        let mut tag_builder = if tagged {
184            Some(tagged::TagBuilder::new(pages.len()))
185        } else {
186            None
187        };
188
189        // Two-pass page processing:
190        // Pass 1: Build content streams, page objects, collect bookmarks + annotations
191        // Pass 2: Create annotation objects (needs full bookmark list for internal links)
192        let mut page_obj_ids: Vec<usize> = Vec::new();
193        let mut all_bookmarks: Vec<PdfBookmark> = Vec::new();
194        let mut per_page_content_obj_ids: Vec<usize> = Vec::new();
195        let mut per_page_annotations: Vec<Vec<LinkAnnotation>> = Vec::new();
196        let mut per_page_resources: Vec<String> = Vec::new();
197
198        // Pass 1: content streams, page objects (without /Annots), bookmarks
199        for (page_idx, page) in pages.iter().enumerate() {
200            let content = self.build_content_stream_for_page(
201                page,
202                page_idx,
203                &builder,
204                page_idx + 1,
205                pages.len(),
206                tag_builder.as_mut(),
207            );
208            let compressed = compress_to_vec_zlib(content.as_bytes(), 6);
209
210            let content_obj_id = builder.objects.len();
211            let mut content_data: Vec<u8> = Vec::new();
212            let _ = write!(
213                content_data,
214                "<< /Length {} /Filter /FlateDecode >>\nstream\n",
215                compressed.len()
216            );
217            content_data.extend_from_slice(&compressed);
218            content_data.extend_from_slice(b"\nendstream");
219            builder.objects.push(PdfObject {
220                id: content_obj_id,
221                data: content_data,
222            });
223            per_page_content_obj_ids.push(content_obj_id);
224
225            // Collect link annotations (deferred creation until pass 2)
226            let mut annotations: Vec<LinkAnnotation> = Vec::new();
227            Self::collect_link_annotations(&page.elements, page.height, &mut annotations);
228            per_page_annotations.push(annotations);
229
230            // Reserve page object (placeholder — filled in pass 2)
231            let page_obj_id = builder.objects.len();
232            builder.objects.push(PdfObject {
233                id: page_obj_id,
234                data: vec![],
235            });
236
237            // Build resource dict for this page
238            let font_resources = self.build_font_resource_dict(&builder.font_objects);
239            let xobject_resources = self.build_xobject_resource_dict(page_idx, &builder);
240            let ext_gstate_resources = self.build_ext_gstate_resource_dict(&builder);
241            let mut resources = format!("/Font << {} >>", font_resources);
242            if !xobject_resources.is_empty() {
243                let _ = write!(resources, " /XObject << {} >>", xobject_resources);
244            }
245            if !ext_gstate_resources.is_empty() {
246                let _ = write!(resources, " /ExtGState << {} >>", ext_gstate_resources);
247            }
248            per_page_resources.push(resources);
249
250            // Collect bookmarks (needs page_obj_id)
251            Self::collect_bookmarks(&page.elements, page.height, page_obj_id, &mut all_bookmarks);
252
253            page_obj_ids.push(page_obj_id);
254        }
255
256        // Pass 2: create annotation objects and fill in page dicts
257        for (page_idx, annotations) in per_page_annotations.iter().enumerate() {
258            let mut annot_obj_ids: Vec<usize> = Vec::new();
259            for annot in annotations {
260                let rect = format!(
261                    "[{:.2} {:.2} {:.2} {:.2}]",
262                    annot.x,
263                    annot.y,
264                    annot.x + annot.width,
265                    annot.y + annot.height
266                );
267
268                if let Some(anchor) = annot.href.strip_prefix('#') {
269                    // Internal link: find matching bookmark by title
270                    if let Some(bm) = all_bookmarks.iter().find(|b| b.title == anchor) {
271                        let annot_obj_id = builder.objects.len();
272                        let annot_dict = format!(
273                            "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] \
274                             /A << /S /GoTo /D [{} 0 R /XYZ 0 {:.2} null] >> >>",
275                            rect, bm.page_obj_id, bm.y_pdf
276                        );
277                        builder.objects.push(PdfObject {
278                            id: annot_obj_id,
279                            data: annot_dict.into_bytes(),
280                        });
281                        annot_obj_ids.push(annot_obj_id);
282                    }
283                    // No matching bookmark: skip silently
284                } else {
285                    // External link
286                    let annot_obj_id = builder.objects.len();
287                    let annot_dict = format!(
288                        "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] \
289                         /A << /Type /Action /S /URI /URI ({}) >> >>",
290                        rect,
291                        Self::escape_pdf_string(&annot.href)
292                    );
293                    builder.objects.push(PdfObject {
294                        id: annot_obj_id,
295                        data: annot_dict.into_bytes(),
296                    });
297                    annot_obj_ids.push(annot_obj_id);
298                }
299            }
300
301            let annots_str = if annot_obj_ids.is_empty() {
302                String::new()
303            } else {
304                let refs: String = annot_obj_ids
305                    .iter()
306                    .map(|id| format!("{} 0 R", id))
307                    .collect::<Vec<_>>()
308                    .join(" ");
309                format!(" /Annots [{}]", refs)
310            };
311
312            let page_obj_id = page_obj_ids[page_idx];
313            let content_obj_id = per_page_content_obj_ids[page_idx];
314            let struct_parents_str = if tagged {
315                format!(" /StructParents {}", page_idx)
316            } else {
317                String::new()
318            };
319            let page_dict = format!(
320                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] \
321                 /Contents {} 0 R /Resources << {} >>{}{} >>",
322                pages[page_idx].width,
323                pages[page_idx].height,
324                content_obj_id,
325                per_page_resources[page_idx],
326                annots_str,
327                struct_parents_str
328            );
329            builder.objects[page_obj_id].data = page_dict.into_bytes();
330        }
331
332        // Build outline tree if bookmarks exist
333        let outlines_obj_id = if !all_bookmarks.is_empty() {
334            Some(self.write_outline_tree(&mut builder, &all_bookmarks))
335        } else {
336            None
337        };
338
339        // Build structure tree for tagged PDF
340        let struct_tree_root_id = if let Some(ref tb) = tag_builder {
341            let (root_id, _parent_tree_id) = tb.write_objects(&mut builder.objects, &page_obj_ids);
342            Some(root_id)
343        } else {
344            None
345        };
346
347        // PDF/A: write XMP metadata stream and ICC output intent
348        let xmp_metadata_id = if let Some(conf) = pdfa {
349            let xmp_xml = xmp::generate_xmp(metadata, conf);
350            let xmp_bytes = xmp_xml.as_bytes();
351            let xmp_obj_id = builder.objects.len();
352            // XMP metadata stream must NOT be compressed (PDF/A requirement)
353            let xmp_data = format!(
354                "<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n",
355                xmp_bytes.len()
356            );
357            let mut xmp_obj_data: Vec<u8> = xmp_data.into_bytes();
358            xmp_obj_data.extend_from_slice(xmp_bytes);
359            xmp_obj_data.extend_from_slice(b"\nendstream");
360            builder.objects.push(PdfObject {
361                id: xmp_obj_id,
362                data: xmp_obj_data,
363            });
364            Some(xmp_obj_id)
365        } else {
366            None
367        };
368
369        let output_intent_id = if pdfa.is_some() {
370            // Embed sRGB ICC profile
371            static SRGB_ICC: &[u8] = include_bytes!("srgb2014.icc");
372            let compressed_icc = compress_to_vec_zlib(SRGB_ICC, 6);
373
374            let icc_obj_id = builder.objects.len();
375            let mut icc_data: Vec<u8> = Vec::new();
376            let _ = write!(
377                icc_data,
378                "<< /N 3 /Length {} /Filter /FlateDecode >>\nstream\n",
379                compressed_icc.len()
380            );
381            icc_data.extend_from_slice(&compressed_icc);
382            icc_data.extend_from_slice(b"\nendstream");
383            builder.objects.push(PdfObject {
384                id: icc_obj_id,
385                data: icc_data,
386            });
387
388            // OutputIntent dictionary
389            let oi_obj_id = builder.objects.len();
390            let oi_data = format!(
391                "<< /Type /OutputIntent /S /GTS_PDFA1 \
392                 /OutputConditionIdentifier (sRGB IEC61966-2.1) \
393                 /RegistryName (http://www.color.org) \
394                 /DestOutputProfile {} 0 R >>",
395                icc_obj_id
396            );
397            builder.objects.push(PdfObject {
398                id: oi_obj_id,
399                data: oi_data.into_bytes(),
400            });
401            Some(oi_obj_id)
402        } else {
403            None
404        };
405
406        // Embedded data attachment (PDF 1.7 EmbeddedFile)
407        let embedded_names_id = if let Some(data) = embedded_data {
408            let compressed = compress_to_vec_zlib(data.as_bytes(), 6);
409
410            // EmbeddedFile stream
411            let ef_obj_id = builder.objects.len();
412            let ef_data = format!(
413                "<< /Type /EmbeddedFile /Subtype /application#2Fjson /Length {} /Filter /FlateDecode >>\nstream\n",
414                compressed.len()
415            );
416            let mut ef_bytes = ef_data.into_bytes();
417            ef_bytes.extend_from_slice(&compressed);
418            ef_bytes.extend_from_slice(b"\nendstream");
419            builder.objects.push(PdfObject {
420                id: ef_obj_id,
421                data: ef_bytes,
422            });
423
424            // FileSpec dictionary
425            let fs_obj_id = builder.objects.len();
426            let fs_data = format!(
427                "<< /Type /Filespec /F (forme-data.json) /UF (forme-data.json) /EF << /F {} 0 R >> /AFRelationship /Data >>",
428                ef_obj_id
429            );
430            builder.objects.push(PdfObject {
431                id: fs_obj_id,
432                data: fs_data.into_bytes(),
433            });
434
435            // Names tree for EmbeddedFiles
436            let names_obj_id = builder.objects.len();
437            let names_data = format!("<< /Names [(forme-data.json) {} 0 R] >>", fs_obj_id);
438            builder.objects.push(PdfObject {
439                id: names_obj_id,
440                data: names_data.into_bytes(),
441            });
442
443            Some(names_obj_id)
444        } else {
445            None
446        };
447
448        // Write Catalog (object 1)
449        let mut catalog = String::from("<< /Type /Catalog /Pages 2 0 R");
450        if let Some(outlines_id) = outlines_obj_id {
451            write!(
452                catalog,
453                " /Outlines {} 0 R /PageMode /UseOutlines",
454                outlines_id
455            )
456            .unwrap();
457        }
458        if let Some(ref lang) = metadata.lang {
459            write!(catalog, " /Lang ({})", Self::escape_pdf_string(lang)).unwrap();
460        }
461        if let Some(struct_root_id) = struct_tree_root_id {
462            write!(
463                catalog,
464                " /MarkInfo << /Marked true >> /StructTreeRoot {} 0 R",
465                struct_root_id
466            )
467            .unwrap();
468        }
469        if let Some(xmp_id) = xmp_metadata_id {
470            write!(catalog, " /Metadata {} 0 R", xmp_id).unwrap();
471        }
472        if let Some(oi_id) = output_intent_id {
473            write!(catalog, " /OutputIntents [{} 0 R]", oi_id).unwrap();
474        }
475        if let Some(names_id) = embedded_names_id {
476            write!(catalog, " /Names << /EmbeddedFiles {} 0 R >>", names_id).unwrap();
477        }
478        catalog.push_str(" >>");
479        builder.objects[1].data = catalog.into_bytes();
480
481        // Write Pages tree (object 2)
482        let kids: String = page_obj_ids
483            .iter()
484            .map(|id| format!("{} 0 R", id))
485            .collect::<Vec<_>>()
486            .join(" ");
487        builder.objects[2].data = format!(
488            "<< /Type /Pages /Kids [{}] /Count {} >>",
489            kids,
490            page_obj_ids.len()
491        )
492        .into_bytes();
493
494        // Info dictionary (metadata)
495        let info_obj_id = if metadata.title.is_some() || metadata.author.is_some() {
496            let id = builder.objects.len();
497            let mut info = String::from("<< ");
498            if let Some(ref title) = metadata.title {
499                let _ = write!(info, "/Title ({}) ", Self::escape_pdf_string(title));
500            }
501            if let Some(ref author) = metadata.author {
502                let _ = write!(info, "/Author ({}) ", Self::escape_pdf_string(author));
503            }
504            if let Some(ref subject) = metadata.subject {
505                let _ = write!(info, "/Subject ({}) ", Self::escape_pdf_string(subject));
506            }
507            let _ = write!(info, "/Producer (Forme 0.6) /Creator (Forme) >>");
508            builder.objects.push(PdfObject {
509                id,
510                data: info.into_bytes(),
511            });
512            Some(id)
513        } else {
514            None
515        };
516
517        Ok(self.serialize(&builder, info_obj_id))
518    }
519
520    /// Build the PDF content stream for a single page.
521    fn build_content_stream_for_page(
522        &self,
523        page: &LayoutPage,
524        page_idx: usize,
525        builder: &PdfBuilder,
526        page_number: usize,
527        total_pages: usize,
528        mut tag_builder: Option<&mut tagged::TagBuilder>,
529    ) -> String {
530        let mut stream = String::new();
531        let page_height = page.height;
532        let mut element_counter = 0usize;
533
534        for element in &page.elements {
535            self.write_element(
536                &mut stream,
537                element,
538                page_height,
539                builder,
540                page_idx,
541                &mut element_counter,
542                page_number,
543                total_pages,
544                tag_builder.as_deref_mut(),
545            );
546        }
547
548        stream
549    }
550
551    /// Write a single layout element as PDF operators.
552    #[allow(clippy::too_many_arguments)]
553    fn write_element(
554        &self,
555        stream: &mut String,
556        element: &LayoutElement,
557        page_height: f64,
558        builder: &PdfBuilder,
559        page_idx: usize,
560        element_counter: &mut usize,
561        page_number: usize,
562        total_pages: usize,
563        mut tag_builder: Option<&mut tagged::TagBuilder>,
564    ) {
565        // Tagged PDF: emit BDC (begin marked content) for elements with a node_type
566        let tagged_mcid = if let Some(ref mut tb) = tag_builder {
567            if let Some(ref nt) = element.node_type {
568                let is_header = element.is_header_row;
569                // For TableCell, inherit is_header_row from its parent row
570                let mcid = tb.begin_element(nt, is_header, element.alt.as_deref(), page_idx);
571                let role = tb.map_role_public(nt, is_header);
572                let _ = writeln!(stream, "/{} <</MCID {}>> BDC", role, mcid);
573                Some(mcid)
574            } else {
575                None
576            }
577        } else {
578            None
579        };
580
581        match &element.draw {
582            DrawCommand::None => {}
583
584            DrawCommand::Rect {
585                background,
586                border_width,
587                border_color,
588                border_radius,
589                opacity,
590            } => {
591                let x = element.x;
592                let y = page_height - element.y - element.height;
593                let w = element.width;
594                let h = element.height;
595
596                // Apply opacity via ExtGState
597                let needs_opacity = *opacity < 1.0;
598                if needs_opacity {
599                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
600                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
601                    }
602                }
603
604                if let Some(bg) = background {
605                    if bg.a > 0.0 {
606                        let _ = writeln!(stream, "q\n{:.3} {:.3} {:.3} rg", bg.r, bg.g, bg.b);
607
608                        if border_radius.top_left > 0.0 {
609                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
610                        } else {
611                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
612                        }
613
614                        let _ = writeln!(stream, "f\nQ");
615                    }
616                }
617
618                let bw = border_width;
619                if bw.top > 0.0 || bw.right > 0.0 || bw.bottom > 0.0 || bw.left > 0.0 {
620                    if (bw.top - bw.right).abs() < 0.001
621                        && (bw.right - bw.bottom).abs() < 0.001
622                        && (bw.bottom - bw.left).abs() < 0.001
623                    {
624                        let bc = &border_color.top;
625                        let _ = writeln!(
626                            stream,
627                            "q\n{:.3} {:.3} {:.3} RG\n{:.2} w",
628                            bc.r, bc.g, bc.b, bw.top
629                        );
630
631                        if border_radius.top_left > 0.0 {
632                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
633                        } else {
634                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
635                        }
636
637                        let _ = writeln!(stream, "S\nQ");
638                    } else {
639                        self.write_border_sides(stream, x, y, w, h, bw, border_color);
640                    }
641                }
642
643                if needs_opacity {
644                    let _ = writeln!(stream, "Q");
645                }
646            }
647
648            DrawCommand::Text {
649                lines,
650                color,
651                text_decoration,
652                opacity,
653            } => {
654                // Apply opacity via ExtGState
655                let needs_opacity = *opacity < 1.0;
656                if needs_opacity {
657                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
658                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
659                    }
660                }
661
662                for line in lines {
663                    if line.glyphs.is_empty() {
664                        continue;
665                    }
666
667                    // Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
668                    // to support multi-font text runs
669                    let groups = Self::group_glyphs_by_style(&line.glyphs);
670                    let pdf_y = page_height - line.y;
671
672                    let _ = writeln!(stream, "BT");
673
674                    // Set word spacing for justification (PDF Tw operator)
675                    if line.word_spacing.abs() > 0.001 {
676                        let _ = writeln!(stream, "{:.4} Tw", line.word_spacing);
677                    }
678
679                    // Track current text matrix position for relative Td moves
680                    let mut tm_x = 0.0_f64;
681                    let mut tm_y = 0.0_f64;
682                    let mut x_cursor = line.x;
683
684                    // Track group spans for per-group text decoration
685                    let mut group_spans: Vec<(f64, f64, TextDecoration, Color)> = Vec::new();
686
687                    for group in &groups {
688                        let first = &group[0];
689                        let glyph_color = first.color.unwrap_or(*color);
690
691                        let idx = self.font_index(
692                            &first.font_family,
693                            first.font_weight,
694                            first.font_style,
695                            &builder.font_objects,
696                        );
697                        let italic =
698                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
699                        let font_key = FontKey {
700                            family: first.font_family.clone(),
701                            weight: if first.font_weight >= 600 { 700 } else { 400 },
702                            italic,
703                        };
704                        let font_name = format!("F{}", idx);
705
706                        // Td is relative to current text matrix position
707                        let dx = x_cursor - tm_x;
708                        let dy = pdf_y - tm_y;
709                        let _ = writeln!(
710                            stream,
711                            "{:.3} {:.3} {:.3} rg\n/{} {:.1} Tf\n{:.2} Tc\n{:.2} {:.2} Td",
712                            glyph_color.r,
713                            glyph_color.g,
714                            glyph_color.b,
715                            font_name,
716                            first.font_size,
717                            first.letter_spacing,
718                            dx,
719                            dy
720                        );
721                        tm_x = x_cursor;
722                        tm_y = pdf_y;
723
724                        // Check for page number placeholders
725                        let raw_text: String = group.iter().map(|g| g.char_value).collect();
726                        let has_placeholder = raw_text.contains("{{pageNumber}}")
727                            || raw_text.contains("{{totalPages}}");
728
729                        let is_custom = builder.custom_font_data.contains_key(&font_key);
730
731                        if is_custom {
732                            if let Some(embed_data) = builder.custom_font_data.get(&font_key) {
733                                let mut hex = String::new();
734                                if has_placeholder {
735                                    // Placeholder text: replace and use char→gid fallback
736                                    let text_after = raw_text
737                                        .replace("{{pageNumber}}", &page_number.to_string())
738                                        .replace("{{totalPages}}", &total_pages.to_string());
739                                    for ch in text_after.chars() {
740                                        let gid =
741                                            embed_data.char_to_gid.get(&ch).copied().unwrap_or(0);
742                                        let _ = write!(hex, "{:04X}", gid);
743                                    }
744                                } else {
745                                    // Shaped text: use glyph IDs directly (remapped through subset)
746                                    for g in group.iter() {
747                                        let new_gid = embed_data
748                                            .gid_remap
749                                            .get(&g.glyph_id)
750                                            .copied()
751                                            .unwrap_or_else(|| {
752                                                // Fallback: try char→gid
753                                                embed_data
754                                                    .char_to_gid
755                                                    .get(&g.char_value)
756                                                    .copied()
757                                                    .unwrap_or(0)
758                                            });
759                                        let _ = write!(hex, "{:04X}", new_gid);
760                                    }
761                                }
762                                let _ = writeln!(stream, "<{}> Tj", hex);
763                            } else {
764                                let _ = writeln!(stream, "<> Tj");
765                            }
766                        } else {
767                            let text_after = raw_text
768                                .replace("{{pageNumber}}", &page_number.to_string())
769                                .replace("{{totalPages}}", &total_pages.to_string());
770                            let mut text_str = String::new();
771                            for ch in text_after.chars() {
772                                let b = Self::unicode_to_winansi(ch).unwrap_or(b'?');
773                                match b {
774                                    b'\\' => text_str.push_str("\\\\"),
775                                    b'(' => text_str.push_str("\\("),
776                                    b')' => text_str.push_str("\\)"),
777                                    0x20..=0x7E => text_str.push(b as char),
778                                    _ => {
779                                        let _ = write!(text_str, "\\{:03o}", b);
780                                    }
781                                }
782                            }
783                            let _ = writeln!(stream, "({}) Tj", text_str);
784                        }
785
786                        // Record span for per-group text decoration
787                        let group_start_x = x_cursor;
788
789                        // Advance x_cursor past this group using shaped advances
790                        // Account for word_spacing on spaces (Tw adds to each space char)
791                        if let Some(last) = group.last() {
792                            let space_count_in_group =
793                                group.iter().filter(|g| g.char_value == ' ').count();
794                            x_cursor = line.x
795                                + last.x_offset
796                                + last.x_advance
797                                + space_count_in_group as f64 * line.word_spacing;
798                        }
799
800                        // Check if this group has text decoration
801                        let group_dec = first.text_decoration;
802                        if !matches!(group_dec, TextDecoration::None) {
803                            group_spans.push((group_start_x, x_cursor, group_dec, glyph_color));
804                        }
805                    }
806
807                    let _ = writeln!(stream, "ET");
808
809                    // Draw per-group text decorations
810                    for (span_x, span_end_x, dec, dec_color) in &group_spans {
811                        match dec {
812                            TextDecoration::Underline => {
813                                let underline_y = pdf_y - 1.5;
814                                let _ = write!(
815                                    stream,
816                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
817                                    dec_color.r, dec_color.g, dec_color.b,
818                                    span_x, underline_y,
819                                    span_end_x, underline_y
820                                );
821                            }
822                            TextDecoration::LineThrough => {
823                                let first_size =
824                                    line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
825                                let strikethrough_y = pdf_y + first_size * 0.3;
826                                let _ = write!(
827                                    stream,
828                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
829                                    dec_color.r, dec_color.g, dec_color.b,
830                                    span_x, strikethrough_y,
831                                    span_end_x, strikethrough_y
832                                );
833                            }
834                            TextDecoration::None => {}
835                        }
836                    }
837
838                    // Also handle whole-line decoration from parent style
839                    if group_spans.is_empty() {
840                        if matches!(text_decoration, TextDecoration::Underline) {
841                            let underline_y = pdf_y - 1.5;
842                            let _ = write!(
843                                stream,
844                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
845                                color.r, color.g, color.b,
846                                line.x, underline_y,
847                                line.x + line.width, underline_y
848                            );
849                        }
850                        if matches!(text_decoration, TextDecoration::LineThrough) {
851                            let first_size =
852                                line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
853                            let strikethrough_y = pdf_y + first_size * 0.3;
854                            let _ = write!(
855                                stream,
856                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
857                                color.r, color.g, color.b,
858                                line.x, strikethrough_y,
859                                line.x + line.width, strikethrough_y
860                            );
861                        }
862                    }
863                }
864
865                if needs_opacity {
866                    let _ = writeln!(stream, "Q");
867                }
868            }
869
870            DrawCommand::Image { .. } => {
871                let elem_idx = *element_counter;
872                *element_counter += 1;
873                if let Some(&img_idx) = builder.image_index_map.get(&(page_idx, elem_idx)) {
874                    let x = element.x;
875                    let y = page_height - element.y - element.height;
876                    let _ = write!(
877                        stream,
878                        "q\n{:.4} 0 0 {:.4} {:.2} {:.2} cm\n/Im{} Do\nQ\n",
879                        element.width, element.height, x, y, img_idx
880                    );
881                } else {
882                    // Fallback: grey placeholder if image index not found
883                    let x = element.x;
884                    let y = page_height - element.y - element.height;
885                    let _ = write!(
886                        stream,
887                        "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
888                        x, y, element.width, element.height
889                    );
890                }
891                if tagged_mcid.is_some() {
892                    let _ = writeln!(stream, "EMC");
893                    if let Some(ref mut tb) = tag_builder {
894                        tb.end_element();
895                    }
896                }
897                return; // Don't increment counter again for children
898            }
899
900            DrawCommand::ImagePlaceholder => {
901                *element_counter += 1;
902                let x = element.x;
903                let y = page_height - element.y - element.height;
904                let _ = write!(
905                    stream,
906                    "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
907                    x, y, element.width, element.height
908                );
909                if tagged_mcid.is_some() {
910                    let _ = writeln!(stream, "EMC");
911                    if let Some(ref mut tb) = tag_builder {
912                        tb.end_element();
913                    }
914                }
915                return;
916            }
917
918            DrawCommand::Svg {
919                commands,
920                width: svg_w,
921                height: svg_h,
922                clip,
923            } => {
924                let x = element.x;
925                let y = page_height - element.y - element.height;
926
927                // Save state, translate to position, flip Y for SVG coordinate system
928                let _ = writeln!(stream, "q");
929                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", x, y);
930
931                // Scale from viewBox to target size (if viewBox differs from target)
932                if *svg_w > 0.0 && *svg_h > 0.0 {
933                    let sx = element.width / svg_w;
934                    let sy = element.height / svg_h;
935                    let _ = writeln!(stream, "{:.4} 0 0 {:.4} 0 0 cm", sx, sy);
936                }
937
938                // Flip Y: SVG has Y increasing down, we need PDF Y increasing up
939                let _ = writeln!(stream, "1 0 0 -1 0 {:.2} cm", svg_h);
940
941                // Clip to canvas bounds (Canvas always clips, SVG does not)
942                if *clip {
943                    let _ = writeln!(stream, "0 0 {:.2} {:.2} re W n", svg_w, svg_h);
944                }
945
946                Self::write_svg_commands(stream, commands);
947
948                let _ = writeln!(stream, "Q");
949                if tagged_mcid.is_some() {
950                    let _ = writeln!(stream, "EMC");
951                    if let Some(ref mut tb) = tag_builder {
952                        tb.end_element();
953                    }
954                }
955                return;
956            }
957
958            DrawCommand::Barcode {
959                bars,
960                bar_width,
961                height,
962                color,
963            } => {
964                *element_counter += 1;
965                let _ = writeln!(stream, "q");
966                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
967                for (i, &bar) in bars.iter().enumerate() {
968                    if bar == 1 {
969                        let bx = element.x + i as f64 * bar_width;
970                        let by = page_height - element.y - height;
971                        let _ = writeln!(
972                            stream,
973                            "{:.2} {:.2} {:.2} {:.2} re",
974                            bx, by, bar_width, height
975                        );
976                    }
977                }
978                let _ = writeln!(stream, "f\nQ");
979                if tagged_mcid.is_some() {
980                    let _ = writeln!(stream, "EMC");
981                    if let Some(ref mut tb) = tag_builder {
982                        tb.end_element();
983                    }
984                }
985                return;
986            }
987
988            DrawCommand::QrCode {
989                modules,
990                module_size,
991                color,
992            } => {
993                *element_counter += 1;
994                let _ = writeln!(stream, "q");
995                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
996                for (row_idx, row) in modules.iter().enumerate() {
997                    for (col_idx, &dark) in row.iter().enumerate() {
998                        if dark {
999                            let mx = element.x + col_idx as f64 * module_size;
1000                            let my = page_height - element.y - (row_idx as f64 + 1.0) * module_size;
1001                            let _ = writeln!(
1002                                stream,
1003                                "{:.2} {:.2} {:.2} {:.2} re",
1004                                mx, my, module_size, module_size
1005                            );
1006                        }
1007                    }
1008                }
1009                let _ = writeln!(stream, "f\nQ");
1010                if tagged_mcid.is_some() {
1011                    let _ = writeln!(stream, "EMC");
1012                    if let Some(ref mut tb) = tag_builder {
1013                        tb.end_element();
1014                    }
1015                }
1016                return;
1017            }
1018
1019            DrawCommand::Chart { primitives } => {
1020                *element_counter += 1;
1021                let _ = writeln!(stream, "q");
1022                // Set up coordinate transform: Y-flip so chart primitives use top-left origin
1023                let _ = writeln!(
1024                    stream,
1025                    "1 0 0 -1 {:.4} {:.4} cm",
1026                    element.x,
1027                    page_height - element.y
1028                );
1029
1030                for prim in primitives {
1031                    write_chart_primitive(stream, prim, element.height, builder);
1032                }
1033
1034                let _ = writeln!(stream, "Q");
1035                if tagged_mcid.is_some() {
1036                    let _ = writeln!(stream, "EMC");
1037                    if let Some(ref mut tb) = tag_builder {
1038                        tb.end_element();
1039                    }
1040                }
1041                return;
1042            }
1043
1044            DrawCommand::Watermark {
1045                lines,
1046                color,
1047                opacity,
1048                angle_rad,
1049                font_family: _,
1050            } => {
1051                let _ = writeln!(stream, "q");
1052                // Set opacity via ExtGState if not fully opaque
1053                if *opacity < 1.0 {
1054                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1055                        let _ = writeln!(stream, "/{} gs", gs_name);
1056                    }
1057                }
1058                // Translate to center position (element.x, element.y = page center)
1059                let pdf_cx = element.x;
1060                let pdf_cy = page_height - element.y;
1061                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", pdf_cx, pdf_cy);
1062                // Rotate by angle
1063                let cos_a = angle_rad.cos();
1064                let sin_a = angle_rad.sin();
1065                let _ = writeln!(
1066                    stream,
1067                    "{:.6} {:.6} {:.6} {:.6} 0 0 cm",
1068                    cos_a, sin_a, -sin_a, cos_a
1069                );
1070                // Render text centered on origin
1071                let _ = writeln!(stream, "BT");
1072                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
1073                if let Some(line) = lines.first() {
1074                    let groups = Self::group_glyphs_by_style(&line.glyphs);
1075                    let text_width = line.width;
1076                    let cap_height = line.height * 0.7;
1077                    let _ = writeln!(
1078                        stream,
1079                        "{:.2} {:.2} Td",
1080                        -text_width / 2.0,
1081                        -cap_height / 2.0
1082                    );
1083                    for group in &groups {
1084                        let first = &group[0];
1085                        let italic =
1086                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
1087                        let fk = FontKey {
1088                            family: first.font_family.clone(),
1089                            weight: if first.font_weight >= 600 { 700 } else { 400 },
1090                            italic,
1091                        };
1092                        let idx = self.font_index(
1093                            &first.font_family,
1094                            first.font_weight,
1095                            first.font_style,
1096                            &builder.font_objects,
1097                        );
1098                        let font_name = format!("F{}", idx);
1099                        let _ = writeln!(stream, "/{} {:.1} Tf", font_name, first.font_size);
1100                        let is_custom = builder.custom_font_data.contains_key(&fk);
1101                        if is_custom {
1102                            if let Some(embed_data) = builder.custom_font_data.get(&fk) {
1103                                let mut hex = String::new();
1104                                for g in group.iter() {
1105                                    let gid =
1106                                        embed_data.gid_remap.get(&g.glyph_id).copied().unwrap_or(0);
1107                                    let _ = write!(hex, "{:04X}", gid);
1108                                }
1109                                let _ = writeln!(stream, "<{}> Tj", hex);
1110                            }
1111                        } else {
1112                            let hex_str: String = group
1113                                .iter()
1114                                .map(|g| format!("{:02X}", g.glyph_id as u8))
1115                                .collect();
1116                            let _ = writeln!(stream, "<{}> Tj", hex_str);
1117                        }
1118                    }
1119                }
1120                let _ = writeln!(stream, "ET");
1121                let _ = writeln!(stream, "Q");
1122                if tagged_mcid.is_some() {
1123                    let _ = writeln!(stream, "EMC");
1124                    if let Some(ref mut tb) = tag_builder {
1125                        tb.end_element();
1126                    }
1127                }
1128                return;
1129            }
1130        }
1131
1132        // Overflow clipping: wrap children in q/clip/Q when overflow is Hidden
1133        let clip_overflow = matches!(element.overflow, Overflow::Hidden);
1134        if clip_overflow {
1135            let clip_x = element.x;
1136            let clip_y = page_height - element.y - element.height;
1137            let clip_w = element.width;
1138            let clip_h = element.height;
1139            let _ = writeln!(
1140                stream,
1141                "q\n{:.2} {:.2} {:.2} {:.2} re W n",
1142                clip_x, clip_y, clip_w, clip_h
1143            );
1144        }
1145
1146        for child in &element.children {
1147            self.write_element(
1148                stream,
1149                child,
1150                page_height,
1151                builder,
1152                page_idx,
1153                element_counter,
1154                page_number,
1155                total_pages,
1156                tag_builder.as_deref_mut(),
1157            );
1158        }
1159
1160        if clip_overflow {
1161            let _ = writeln!(stream, "Q");
1162        }
1163
1164        // Tagged PDF: emit EMC (end marked content)
1165        if tagged_mcid.is_some() {
1166            let _ = writeln!(stream, "EMC");
1167            if let Some(ref mut tb) = tag_builder {
1168                tb.end_element();
1169            }
1170        }
1171    }
1172
1173    fn write_rounded_rect(
1174        &self,
1175        stream: &mut String,
1176        x: f64,
1177        y: f64,
1178        w: f64,
1179        h: f64,
1180        r: &crate::style::CornerValues,
1181    ) {
1182        let k = 0.5522847498;
1183
1184        let tl = r.top_left.min(w / 2.0).min(h / 2.0);
1185        let tr = r.top_right.min(w / 2.0).min(h / 2.0);
1186        let br = r.bottom_right.min(w / 2.0).min(h / 2.0);
1187        let bl = r.bottom_left.min(w / 2.0).min(h / 2.0);
1188
1189        let _ = writeln!(stream, "{:.2} {:.2} m", x + bl, y);
1190
1191        let _ = writeln!(stream, "{:.2} {:.2} l", x + w - br, y);
1192        if br > 0.0 {
1193            let _ = writeln!(
1194                stream,
1195                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
1196                x + w - br + br * k,
1197                y,
1198                x + w,
1199                y + br - br * k,
1200                x + w,
1201                y + br
1202            );
1203        }
1204
1205        let _ = writeln!(stream, "{:.2} {:.2} l", x + w, y + h - tr);
1206        if tr > 0.0 {
1207            let _ = writeln!(
1208                stream,
1209                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
1210                x + w,
1211                y + h - tr + tr * k,
1212                x + w - tr + tr * k,
1213                y + h,
1214                x + w - tr,
1215                y + h
1216            );
1217        }
1218
1219        let _ = writeln!(stream, "{:.2} {:.2} l", x + tl, y + h);
1220        if tl > 0.0 {
1221            let _ = writeln!(
1222                stream,
1223                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
1224                x + tl - tl * k,
1225                y + h,
1226                x,
1227                y + h - tl + tl * k,
1228                x,
1229                y + h - tl
1230            );
1231        }
1232
1233        let _ = writeln!(stream, "{:.2} {:.2} l", x, y + bl);
1234        if bl > 0.0 {
1235            let _ = writeln!(
1236                stream,
1237                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
1238                x,
1239                y + bl - bl * k,
1240                x + bl - bl * k,
1241                y,
1242                x + bl,
1243                y
1244            );
1245        }
1246
1247        let _ = writeln!(stream, "h");
1248    }
1249
1250    #[allow(clippy::too_many_arguments)]
1251    fn write_border_sides(
1252        &self,
1253        stream: &mut String,
1254        x: f64,
1255        y: f64,
1256        w: f64,
1257        h: f64,
1258        bw: &Edges,
1259        bc: &crate::style::EdgeValues<Color>,
1260    ) {
1261        if bw.top > 0.0 {
1262            let _ = write!(
1263                stream,
1264                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1265                bc.top.r,
1266                bc.top.g,
1267                bc.top.b,
1268                bw.top,
1269                x,
1270                y + h,
1271                x + w,
1272                y + h
1273            );
1274        }
1275        if bw.bottom > 0.0 {
1276            let _ = write!(
1277                stream,
1278                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1279                bc.bottom.r,
1280                bc.bottom.g,
1281                bc.bottom.b,
1282                bw.bottom,
1283                x,
1284                y,
1285                x + w,
1286                y
1287            );
1288        }
1289        if bw.left > 0.0 {
1290            let _ = write!(
1291                stream,
1292                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1293                bc.left.r,
1294                bc.left.g,
1295                bc.left.b,
1296                bw.left,
1297                x,
1298                y,
1299                x,
1300                y + h
1301            );
1302        }
1303        if bw.right > 0.0 {
1304            let _ = write!(
1305                stream,
1306                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
1307                bc.right.r,
1308                bc.right.g,
1309                bc.right.b,
1310                bw.right,
1311                x + w,
1312                y,
1313                x + w,
1314                y + h
1315            );
1316        }
1317    }
1318
1319    /// Register fonts used across all pages — each unique (family, weight, italic)
1320    /// combination gets its own PDF font object.
1321    fn register_fonts(
1322        &self,
1323        builder: &mut PdfBuilder,
1324        pages: &[LayoutPage],
1325        font_context: &FontContext,
1326    ) -> Result<(), FormeError> {
1327        // Collect font usage: glyph IDs, chars, and glyph→char mapping per font
1328        let mut font_usage_map: HashMap<FontKey, FontUsage> = HashMap::new();
1329
1330        for page in pages {
1331            Self::collect_font_usage(&page.elements, &mut font_usage_map);
1332        }
1333
1334        let mut keys: Vec<FontKey> = font_usage_map.keys().cloned().collect();
1335
1336        // Sort for deterministic ordering, then dedup
1337        keys.sort_by(|a, b| {
1338            a.family
1339                .cmp(&b.family)
1340                .then(a.weight.cmp(&b.weight))
1341                .then(a.italic.cmp(&b.italic))
1342        });
1343        keys.dedup();
1344
1345        // Always have at least Helvetica
1346        if keys.is_empty() {
1347            keys.push(FontKey {
1348                family: "Helvetica".to_string(),
1349                weight: 400,
1350                italic: false,
1351            });
1352        }
1353
1354        for key in &keys {
1355            let font_data = font_context.resolve(&key.family, key.weight, key.italic);
1356
1357            match font_data {
1358                FontData::Standard(std_font) => {
1359                    let obj_id = builder.objects.len();
1360                    // Include /Widths so PDF viewers use our exact metrics
1361                    // instead of substituting a system font with different widths
1362                    let metrics = std_font.metrics();
1363                    let widths_str: String = metrics
1364                        .widths
1365                        .iter()
1366                        .map(|w| w.to_string())
1367                        .collect::<Vec<_>>()
1368                        .join(" ");
1369                    let font_dict = format!(
1370                        "<< /Type /Font /Subtype /Type1 /BaseFont /{} \
1371                         /Encoding /WinAnsiEncoding \
1372                         /FirstChar 32 /LastChar 255 /Widths [{}] >>",
1373                        std_font.pdf_name(),
1374                        widths_str,
1375                    );
1376                    builder.objects.push(PdfObject {
1377                        id: obj_id,
1378                        data: font_dict.into_bytes(),
1379                    });
1380                    builder.font_objects.push((key.clone(), obj_id));
1381                }
1382                FontData::Custom { data, .. } => {
1383                    let usage = font_usage_map.get(key);
1384                    let used_glyph_ids = usage.map(|u| &u.glyph_ids);
1385                    let used_chars = usage.map(|u| &u.chars);
1386                    let glyph_to_char = usage.map(|u| &u.glyph_to_char);
1387                    let type0_obj_id = Self::write_custom_font_objects(
1388                        builder,
1389                        key,
1390                        data,
1391                        used_glyph_ids.cloned().unwrap_or_default(),
1392                        used_chars.cloned().unwrap_or_default(),
1393                        glyph_to_char.cloned().unwrap_or_default(),
1394                    )?;
1395                    builder.font_objects.push((key.clone(), type0_obj_id));
1396                }
1397            }
1398        }
1399
1400        Ok(())
1401    }
1402
1403    /// Collect font usage data from layout elements: used chars, glyph IDs, and glyph→char mapping.
1404    fn collect_font_usage(
1405        elements: &[LayoutElement],
1406        font_usage: &mut HashMap<FontKey, FontUsage>,
1407    ) {
1408        for element in elements {
1409            let lines_opt = match &element.draw {
1410                DrawCommand::Text { lines, .. } => Some(lines),
1411                DrawCommand::Watermark { lines, .. } => Some(lines),
1412                _ => None,
1413            };
1414            if let Some(lines) = lines_opt {
1415                for line in lines {
1416                    for glyph in &line.glyphs {
1417                        let italic =
1418                            matches!(glyph.font_style, FontStyle::Italic | FontStyle::Oblique);
1419                        let key = FontKey {
1420                            family: glyph.font_family.clone(),
1421                            weight: if glyph.font_weight >= 600 { 700 } else { 400 },
1422                            italic,
1423                        };
1424                        let usage = font_usage.entry(key).or_insert_with(|| FontUsage {
1425                            chars: HashSet::new(),
1426                            glyph_ids: HashSet::new(),
1427                            glyph_to_char: HashMap::new(),
1428                        });
1429                        usage.chars.insert(glyph.char_value);
1430                        usage.glyph_ids.insert(glyph.glyph_id);
1431                        // For ligatures, use the first char of the cluster
1432                        usage
1433                            .glyph_to_char
1434                            .entry(glyph.glyph_id)
1435                            .or_insert(glyph.char_value);
1436                        // If there's cluster_text, record all chars for this glyph
1437                        if let Some(ref ct) = glyph.cluster_text {
1438                            // First char already recorded above; cluster_text is for ToUnicode
1439                            if let Some(first_char) = ct.chars().next() {
1440                                usage
1441                                    .glyph_to_char
1442                                    .entry(glyph.glyph_id)
1443                                    .or_insert(first_char);
1444                            }
1445                        }
1446                    }
1447                }
1448            }
1449            Self::collect_font_usage(&element.children, font_usage);
1450        }
1451    }
1452
1453    /// Walk all pages, create XObject PDF objects for each image,
1454    /// and populate the image_index_map for content stream reference.
1455    fn register_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
1456        for (page_idx, page) in pages.iter().enumerate() {
1457            let mut element_counter = 0usize;
1458            Self::collect_images_recursive(&page.elements, page_idx, &mut element_counter, builder);
1459        }
1460    }
1461
1462    fn collect_images_recursive(
1463        elements: &[LayoutElement],
1464        page_idx: usize,
1465        element_counter: &mut usize,
1466        builder: &mut PdfBuilder,
1467    ) {
1468        for element in elements {
1469            match &element.draw {
1470                DrawCommand::Image { image_data } => {
1471                    let elem_idx = *element_counter;
1472                    *element_counter += 1;
1473
1474                    let img_idx = builder.image_objects.len();
1475                    let xobj_id = Self::write_image_xobject(builder, image_data);
1476                    builder.image_objects.push(xobj_id);
1477                    builder
1478                        .image_index_map
1479                        .insert((page_idx, elem_idx), img_idx);
1480                }
1481                DrawCommand::ImagePlaceholder => {
1482                    *element_counter += 1;
1483                }
1484                _ => {
1485                    Self::collect_images_recursive(
1486                        &element.children,
1487                        page_idx,
1488                        element_counter,
1489                        builder,
1490                    );
1491                }
1492            }
1493        }
1494    }
1495
1496    /// Collect unique opacity values from all pages and create ExtGState PDF objects.
1497    fn register_ext_gstates(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
1498        let mut unique_opacities: Vec<f64> = Vec::new();
1499        for page in pages {
1500            Self::collect_opacities_recursive(&page.elements, &mut unique_opacities);
1501        }
1502        unique_opacities.sort_by(|a, b| a.partial_cmp(b).unwrap());
1503        unique_opacities.dedup();
1504
1505        for (idx, &opacity) in unique_opacities.iter().enumerate() {
1506            let obj_id = builder.objects.len();
1507            let gs_name = format!("GS{}", idx);
1508            let obj_data = format!(
1509                "<< /Type /ExtGState /ca {:.4} /CA {:.4} >>",
1510                opacity, opacity
1511            );
1512            builder.objects.push(PdfObject {
1513                id: obj_id,
1514                data: obj_data.into_bytes(),
1515            });
1516            let key = opacity.to_bits();
1517            builder.ext_gstate_map.insert(key, (obj_id, gs_name));
1518        }
1519    }
1520
1521    fn collect_opacities_recursive(elements: &[LayoutElement], opacities: &mut Vec<f64>) {
1522        for element in elements {
1523            match &element.draw {
1524                DrawCommand::Rect { opacity, .. }
1525                | DrawCommand::Text { opacity, .. }
1526                | DrawCommand::Watermark { opacity, .. }
1527                    if *opacity < 1.0 =>
1528                {
1529                    opacities.push(*opacity);
1530                }
1531                DrawCommand::Chart { primitives } => {
1532                    for prim in primitives {
1533                        if let crate::chart::ChartPrimitive::FilledPath { opacity, .. } = prim {
1534                            if *opacity < 1.0 {
1535                                opacities.push(*opacity);
1536                            }
1537                        }
1538                    }
1539                }
1540                _ => {}
1541            }
1542            Self::collect_opacities_recursive(&element.children, opacities);
1543        }
1544    }
1545
1546    /// Build the ExtGState resource dict entries for a page.
1547    fn build_ext_gstate_resource_dict(&self, builder: &PdfBuilder) -> String {
1548        if builder.ext_gstate_map.is_empty() {
1549            return String::new();
1550        }
1551        let mut entries: Vec<(&String, usize)> = builder
1552            .ext_gstate_map
1553            .values()
1554            .map(|(obj_id, name)| (name, *obj_id))
1555            .collect();
1556        entries.sort_by_key(|(name, _)| (*name).clone());
1557        entries
1558            .iter()
1559            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
1560            .collect::<Vec<_>>()
1561            .join(" ")
1562    }
1563
1564    /// Write a single image as one or two XObject PDF objects.
1565    /// Returns the main XObject ID.
1566    fn write_image_xobject(
1567        builder: &mut PdfBuilder,
1568        image: &crate::image_loader::LoadedImage,
1569    ) -> usize {
1570        use crate::image_loader::{ImagePixelData, JpegColorSpace};
1571
1572        match &image.pixel_data {
1573            ImagePixelData::Jpeg { data, color_space } => {
1574                let color_space_str = match color_space {
1575                    JpegColorSpace::DeviceRGB => "/DeviceRGB",
1576                    JpegColorSpace::DeviceGray => "/DeviceGray",
1577                };
1578
1579                let obj_id = builder.objects.len();
1580                let mut obj_data: Vec<u8> = Vec::new();
1581                let _ = write!(
1582                    obj_data,
1583                    "<< /Type /XObject /Subtype /Image \
1584                     /Width {} /Height {} \
1585                     /ColorSpace {} \
1586                     /BitsPerComponent 8 \
1587                     /Filter /DCTDecode \
1588                     /Length {} >>\nstream\n",
1589                    image.width_px,
1590                    image.height_px,
1591                    color_space_str,
1592                    data.len()
1593                );
1594                obj_data.extend_from_slice(data);
1595                obj_data.extend_from_slice(b"\nendstream");
1596                builder.objects.push(PdfObject {
1597                    id: obj_id,
1598                    data: obj_data,
1599                });
1600                obj_id
1601            }
1602
1603            ImagePixelData::Decoded { rgb, alpha } => {
1604                // Write SMask first if alpha channel exists
1605                let smask_id = alpha.as_ref().map(|alpha_data| {
1606                    let compressed_alpha = compress_to_vec_zlib(alpha_data, 6);
1607                    let smask_obj_id = builder.objects.len();
1608                    let mut smask_data: Vec<u8> = Vec::new();
1609                    let _ = write!(
1610                        smask_data,
1611                        "<< /Type /XObject /Subtype /Image \
1612                         /Width {} /Height {} \
1613                         /ColorSpace /DeviceGray \
1614                         /BitsPerComponent 8 \
1615                         /Filter /FlateDecode \
1616                         /Length {} >>\nstream\n",
1617                        image.width_px,
1618                        image.height_px,
1619                        compressed_alpha.len()
1620                    );
1621                    smask_data.extend_from_slice(&compressed_alpha);
1622                    smask_data.extend_from_slice(b"\nendstream");
1623                    builder.objects.push(PdfObject {
1624                        id: smask_obj_id,
1625                        data: smask_data,
1626                    });
1627                    smask_obj_id
1628                });
1629
1630                // Write main RGB image XObject
1631                let compressed_rgb = compress_to_vec_zlib(rgb, 6);
1632                let obj_id = builder.objects.len();
1633                let mut obj_data: Vec<u8> = Vec::new();
1634
1635                let smask_ref = smask_id
1636                    .map(|id| format!(" /SMask {} 0 R", id))
1637                    .unwrap_or_default();
1638
1639                let _ = write!(
1640                    obj_data,
1641                    "<< /Type /XObject /Subtype /Image \
1642                     /Width {} /Height {} \
1643                     /ColorSpace /DeviceRGB \
1644                     /BitsPerComponent 8 \
1645                     /Filter /FlateDecode \
1646                     /Length {}{} >>\nstream\n",
1647                    image.width_px,
1648                    image.height_px,
1649                    compressed_rgb.len(),
1650                    smask_ref
1651                );
1652                obj_data.extend_from_slice(&compressed_rgb);
1653                obj_data.extend_from_slice(b"\nendstream");
1654                builder.objects.push(PdfObject {
1655                    id: obj_id,
1656                    data: obj_data,
1657                });
1658                obj_id
1659            }
1660        }
1661    }
1662
1663    /// Build the /XObject resource dict entries for a specific page.
1664    fn build_xobject_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
1665        let mut entries: Vec<(usize, usize)> = Vec::new();
1666        for (&(pidx, _), &img_idx) in &builder.image_index_map {
1667            if pidx == page_idx {
1668                let obj_id = builder.image_objects[img_idx];
1669                entries.push((img_idx, obj_id));
1670            }
1671        }
1672        if entries.is_empty() {
1673            return String::new();
1674        }
1675        entries.sort_by_key(|(idx, _)| *idx);
1676        entries.dedup();
1677        entries
1678            .iter()
1679            .map(|(idx, obj_id)| format!("/Im{} {} 0 R", idx, obj_id))
1680            .collect::<Vec<_>>()
1681            .join(" ")
1682    }
1683
1684    /// Write the 5 CIDFont PDF objects for a custom TrueType font.
1685    /// Returns the object ID of the Type0 root font dictionary.
1686    ///
1687    /// `used_glyph_ids`: original glyph IDs from shaping (from PositionedGlyph.glyph_id).
1688    /// `used_chars`: characters used (for char→gid fallback, e.g., page number placeholders).
1689    /// `glyph_to_char_map`: maps original glyph ID → first Unicode char (for ToUnicode CMap).
1690    fn write_custom_font_objects(
1691        builder: &mut PdfBuilder,
1692        key: &FontKey,
1693        ttf_data: &[u8],
1694        used_glyph_ids: HashSet<u16>,
1695        used_chars: HashSet<char>,
1696        glyph_to_char_map: HashMap<u16, char>,
1697    ) -> Result<usize, FormeError> {
1698        let face = ttf_parser::Face::parse(ttf_data, 0).map_err(|e| {
1699            FormeError::FontError(format!(
1700                "Failed to parse TTF data for font '{}': {}",
1701                key.family, e
1702            ))
1703        })?;
1704
1705        let units_per_em = face.units_per_em();
1706        let ascender = face.ascender();
1707        let descender = face.descender();
1708
1709        // Build char → original glyph ID mapping (for fallback/placeholders)
1710        let mut char_to_orig_gid: HashMap<char, u16> = HashMap::new();
1711        for &ch in &used_chars {
1712            if let Some(gid) = face.glyph_index(ch) {
1713                char_to_orig_gid.insert(ch, gid.0);
1714            }
1715        }
1716
1717        // Combine shaped glyph IDs + char-based glyph IDs for subsetting.
1718        // This ensures ligature glyphs (from shaping) AND individual char glyphs
1719        // (for placeholder fallback) are all included.
1720        let mut all_orig_gids: HashSet<u16> = used_glyph_ids.clone();
1721        for &gid in char_to_orig_gid.values() {
1722            all_orig_gids.insert(gid);
1723        }
1724
1725        // Subset the font to only include used glyphs
1726        let (embed_ttf, gid_remap) = match subset_ttf(ttf_data, &all_orig_gids) {
1727            Ok(subset_result) => (subset_result.ttf_data, subset_result.gid_remap),
1728            Err(_) => {
1729                // Subsetting failed — fall back to embedding the full font (identity remap)
1730                let identity: HashMap<u16, u16> =
1731                    all_orig_gids.iter().map(|&gid| (gid, gid)).collect();
1732                (ttf_data.to_vec(), identity)
1733            }
1734        };
1735
1736        // Build char→new_gid mapping (for placeholder fallback in content stream)
1737        let char_to_gid: HashMap<char, u16> = char_to_orig_gid
1738            .iter()
1739            .filter_map(|(&ch, &orig_gid)| gid_remap.get(&orig_gid).map(|&new_gid| (ch, new_gid)))
1740            .collect();
1741
1742        // Build glyph_id→new_gid mapping (for shaped content stream)
1743        let gid_remap_for_embed = gid_remap.clone();
1744
1745        // Build new_gid→char mapping for ToUnicode CMap
1746        let mut new_gid_to_char: HashMap<u16, char> = HashMap::new();
1747        // From shaped glyph→char mapping
1748        for (&orig_gid, &ch) in &glyph_to_char_map {
1749            if let Some(&new_gid) = gid_remap.get(&orig_gid) {
1750                new_gid_to_char.entry(new_gid).or_insert(ch);
1751            }
1752        }
1753        // Fill in from char→gid mapping too
1754        for (&ch, &new_gid) in &char_to_gid {
1755            new_gid_to_char.entry(new_gid).or_insert(ch);
1756        }
1757
1758        let pdf_font_name = Self::sanitize_font_name(&key.family, key.weight, key.italic);
1759
1760        // 1. FontFile2 stream — compressed subset TTF bytes
1761        let compressed_ttf = compress_to_vec_zlib(&embed_ttf, 6);
1762        let fontfile2_id = builder.objects.len();
1763        let mut fontfile2_data: Vec<u8> = Vec::new();
1764        let _ = write!(
1765            fontfile2_data,
1766            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
1767            compressed_ttf.len(),
1768            embed_ttf.len()
1769        );
1770        fontfile2_data.extend_from_slice(&compressed_ttf);
1771        fontfile2_data.extend_from_slice(b"\nendstream");
1772        builder.objects.push(PdfObject {
1773            id: fontfile2_id,
1774            data: fontfile2_data,
1775        });
1776
1777        // Parse the subset font for metrics (width array uses subset GIDs)
1778        let subset_face = ttf_parser::Face::parse(&embed_ttf, 0).unwrap_or_else(|_| face.clone());
1779        let subset_upem = subset_face.units_per_em();
1780
1781        // 2. FontDescriptor
1782        let font_descriptor_id = builder.objects.len();
1783        let bbox = face.global_bounding_box();
1784        let scale = 1000.0 / units_per_em as f64;
1785        let bbox_str = format!(
1786            "[{} {} {} {}]",
1787            (bbox.x_min as f64 * scale) as i32,
1788            (bbox.y_min as f64 * scale) as i32,
1789            (bbox.x_max as f64 * scale) as i32,
1790            (bbox.y_max as f64 * scale) as i32,
1791        );
1792
1793        let flags = 4u32;
1794        let cap_height = face.capital_height().unwrap_or(ascender) as f64 * scale;
1795        let stem_v = if key.weight >= 700 { 120 } else { 80 };
1796
1797        let font_descriptor_dict = format!(
1798            "<< /Type /FontDescriptor /FontName /{} /Flags {} \
1799             /FontBBox {} /ItalicAngle {} \
1800             /Ascent {} /Descent {} /CapHeight {} /StemV {} \
1801             /FontFile2 {} 0 R >>",
1802            pdf_font_name,
1803            flags,
1804            bbox_str,
1805            if key.italic { -12 } else { 0 },
1806            (ascender as f64 * scale) as i32,
1807            (descender as f64 * scale) as i32,
1808            cap_height as i32,
1809            stem_v,
1810            fontfile2_id,
1811        );
1812        builder.objects.push(PdfObject {
1813            id: font_descriptor_id,
1814            data: font_descriptor_dict.into_bytes(),
1815        });
1816
1817        // 3. CIDFont dictionary (DescendantFont)
1818        let cidfont_id = builder.objects.len();
1819        // Build /W array using new_gid→width from subset face
1820        let w_array = Self::build_w_array_from_gids(&gid_remap, &subset_face, subset_upem);
1821        let default_width = subset_face
1822            .glyph_hor_advance(ttf_parser::GlyphId(0))
1823            .map(|adv| (adv as f64 * 1000.0 / subset_upem as f64) as u32)
1824            .unwrap_or(1000);
1825        let cidfont_dict = format!(
1826            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{} \
1827             /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
1828             /FontDescriptor {} 0 R /DW {} /W {} \
1829             /CIDToGIDMap /Identity >>",
1830            pdf_font_name, font_descriptor_id, default_width, w_array,
1831        );
1832        builder.objects.push(PdfObject {
1833            id: cidfont_id,
1834            data: cidfont_dict.into_bytes(),
1835        });
1836
1837        // 4. ToUnicode CMap
1838        let tounicode_id = builder.objects.len();
1839        let cmap_content = Self::build_tounicode_cmap_from_gids(&new_gid_to_char, &pdf_font_name);
1840        let compressed_cmap = compress_to_vec_zlib(cmap_content.as_bytes(), 6);
1841        let mut tounicode_data: Vec<u8> = Vec::new();
1842        let _ = write!(
1843            tounicode_data,
1844            "<< /Length {} /Filter /FlateDecode >>\nstream\n",
1845            compressed_cmap.len()
1846        );
1847        tounicode_data.extend_from_slice(&compressed_cmap);
1848        tounicode_data.extend_from_slice(b"\nendstream");
1849        builder.objects.push(PdfObject {
1850            id: tounicode_id,
1851            data: tounicode_data,
1852        });
1853
1854        // 5. Type0 font dictionary (the root, referenced by /Resources)
1855        let type0_id = builder.objects.len();
1856        let type0_dict = format!(
1857            "<< /Type /Font /Subtype /Type0 /BaseFont /{} \
1858             /Encoding /Identity-H \
1859             /DescendantFonts [{} 0 R] \
1860             /ToUnicode {} 0 R >>",
1861            pdf_font_name, cidfont_id, tounicode_id,
1862        );
1863        builder.objects.push(PdfObject {
1864            id: type0_id,
1865            data: type0_dict.into_bytes(),
1866        });
1867
1868        // Store embedding data for content stream encoding
1869        builder.custom_font_data.insert(
1870            key.clone(),
1871            CustomFontEmbedData {
1872                ttf_data: embed_ttf,
1873                gid_remap: gid_remap_for_embed,
1874                glyph_to_char: glyph_to_char_map,
1875                char_to_gid,
1876                units_per_em,
1877                ascender,
1878                descender,
1879            },
1880        );
1881
1882        Ok(type0_id)
1883    }
1884
1885    /// Build the /W array from gid_remap (orig_gid→new_gid) using the subset face.
1886    fn build_w_array_from_gids(
1887        gid_remap: &HashMap<u16, u16>,
1888        face: &ttf_parser::Face,
1889        units_per_em: u16,
1890    ) -> String {
1891        let scale = 1000.0 / units_per_em as f64;
1892
1893        let mut entries: Vec<(u16, u32)> = Vec::new();
1894        let mut seen_gids: HashSet<u16> = HashSet::new();
1895
1896        for &new_gid in gid_remap.values() {
1897            if seen_gids.contains(&new_gid) {
1898                continue;
1899            }
1900            seen_gids.insert(new_gid);
1901            let advance = face
1902                .glyph_hor_advance(ttf_parser::GlyphId(new_gid))
1903                .unwrap_or(0);
1904            let width = (advance as f64 * scale) as u32;
1905            entries.push((new_gid, width));
1906        }
1907
1908        entries.sort_by_key(|(gid, _)| *gid);
1909
1910        // Build the W array using individual entries: gid [width]
1911        let mut result = String::from("[");
1912        for (gid, width) in &entries {
1913            let _ = write!(result, " {} [{}]", gid, width);
1914        }
1915        result.push_str(" ]");
1916        result
1917    }
1918
1919    /// Build a ToUnicode CMap from new_gid → char mapping.
1920    fn build_tounicode_cmap_from_gids(gid_to_char: &HashMap<u16, char>, font_name: &str) -> String {
1921        let mut gid_to_unicode: Vec<(u16, u32)> = gid_to_char
1922            .iter()
1923            .map(|(&gid, &ch)| (gid, ch as u32))
1924            .collect();
1925        gid_to_unicode.sort_by_key(|(gid, _)| *gid);
1926
1927        let mut cmap = String::new();
1928        let _ = writeln!(cmap, "/CIDInit /ProcSet findresource begin");
1929        let _ = writeln!(cmap, "12 dict begin");
1930        let _ = writeln!(cmap, "begincmap");
1931        let _ = writeln!(cmap, "/CIDSystemInfo");
1932        let _ = writeln!(
1933            cmap,
1934            "<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def"
1935        );
1936        let _ = writeln!(cmap, "/CMapName /{}-UTF16 def", font_name);
1937        let _ = writeln!(cmap, "/CMapType 2 def");
1938        let _ = writeln!(cmap, "1 begincodespacerange");
1939        let _ = writeln!(cmap, "<0000> <FFFF>");
1940        let _ = writeln!(cmap, "endcodespacerange");
1941
1942        // PDF spec limits beginbfchar to 100 entries per block
1943        for chunk in gid_to_unicode.chunks(100) {
1944            let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
1945            for &(gid, unicode) in chunk {
1946                let _ = writeln!(cmap, "<{:04X}> <{:04X}>", gid, unicode);
1947            }
1948            let _ = writeln!(cmap, "endbfchar");
1949        }
1950
1951        let _ = writeln!(cmap, "endcmap");
1952        let _ = writeln!(cmap, "CMapName currentdict /CMap defineresource pop");
1953        let _ = writeln!(cmap, "end");
1954        let _ = writeln!(cmap, "end");
1955
1956        cmap
1957    }
1958
1959    /// Sanitize a font name for use as a PDF name object.
1960    /// Strips spaces and special characters, appends weight/style suffixes.
1961    fn sanitize_font_name(family: &str, weight: u32, italic: bool) -> String {
1962        let mut name: String = family
1963            .chars()
1964            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
1965            .collect();
1966
1967        if weight >= 700 {
1968            name.push_str("-Bold");
1969        }
1970        if italic {
1971            name.push_str("-Italic");
1972        }
1973
1974        // If name is empty after sanitization, use a fallback
1975        if name.is_empty() {
1976            name = "CustomFont".to_string();
1977        }
1978
1979        name
1980    }
1981
1982    fn build_font_resource_dict(&self, font_objects: &[(FontKey, usize)]) -> String {
1983        font_objects
1984            .iter()
1985            .enumerate()
1986            .map(|(i, (_, obj_id))| format!("/F{} {} 0 R", i, obj_id))
1987            .collect::<Vec<_>>()
1988            .join(" ")
1989    }
1990
1991    /// Look up the font index (/F0, /F1, etc.) for a given family+weight+style.
1992    fn font_index(
1993        &self,
1994        family: &str,
1995        weight: u32,
1996        font_style: FontStyle,
1997        font_objects: &[(FontKey, usize)],
1998    ) -> usize {
1999        let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
2000        let snapped_weight = if weight >= 600 { 700 } else { 400 };
2001
2002        // Exact match
2003        for (i, (key, _)) in font_objects.iter().enumerate() {
2004            if key.family == family && key.weight == snapped_weight && key.italic == italic {
2005                return i;
2006            }
2007        }
2008
2009        // Fallback: try Helvetica with same weight/style
2010        for (i, (key, _)) in font_objects.iter().enumerate() {
2011            if key.family == "Helvetica" && key.weight == snapped_weight && key.italic == italic {
2012                return i;
2013            }
2014        }
2015
2016        // Last resort: first font
2017        0
2018    }
2019
2020    /// Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
2021    /// for multi-font text run rendering.
2022    fn group_glyphs_by_style(glyphs: &[PositionedGlyph]) -> Vec<Vec<&PositionedGlyph>> {
2023        if glyphs.is_empty() {
2024            return vec![];
2025        }
2026
2027        let mut groups: Vec<Vec<&PositionedGlyph>> = Vec::new();
2028        let mut current_group: Vec<&PositionedGlyph> = vec![&glyphs[0]];
2029
2030        for glyph in &glyphs[1..] {
2031            let prev = current_group.last().unwrap();
2032            let same_style = glyph.font_family == prev.font_family
2033                && glyph.font_weight == prev.font_weight
2034                && std::mem::discriminant(&glyph.font_style)
2035                    == std::mem::discriminant(&prev.font_style)
2036                && (glyph.font_size - prev.font_size).abs() < 0.01
2037                && Self::colors_equal(&glyph.color, &prev.color);
2038
2039            if same_style {
2040                current_group.push(glyph);
2041            } else {
2042                groups.push(current_group);
2043                current_group = vec![glyph];
2044            }
2045        }
2046        groups.push(current_group);
2047        groups
2048    }
2049
2050    fn colors_equal(a: &Option<Color>, b: &Option<Color>) -> bool {
2051        match (a, b) {
2052            (None, None) => true,
2053            (Some(ca), Some(cb)) => {
2054                (ca.r - cb.r).abs() < 0.001
2055                    && (ca.g - cb.g).abs() < 0.001
2056                    && (ca.b - cb.b).abs() < 0.001
2057                    && (ca.a - cb.a).abs() < 0.001
2058            }
2059            _ => false,
2060        }
2061    }
2062
2063    /// Collect link annotations from layout elements recursively.
2064    /// When an element has an href, its rect covers all children, so we skip
2065    /// recursing into children to avoid duplicate annotations.
2066    fn collect_link_annotations(
2067        elements: &[LayoutElement],
2068        page_height: f64,
2069        annotations: &mut Vec<LinkAnnotation>,
2070    ) {
2071        for element in elements {
2072            if let Some(ref href) = element.href {
2073                if !href.is_empty() {
2074                    let pdf_y = page_height - element.y - element.height;
2075                    annotations.push(LinkAnnotation {
2076                        x: element.x,
2077                        y: pdf_y,
2078                        width: element.width,
2079                        height: element.height,
2080                        href: href.clone(),
2081                    });
2082                    // Don't recurse — parent annotation covers children
2083                    continue;
2084                }
2085            }
2086            Self::collect_link_annotations(&element.children, page_height, annotations);
2087        }
2088    }
2089
2090    /// Collect bookmarks from layout elements.
2091    fn collect_bookmarks(
2092        elements: &[LayoutElement],
2093        page_height: f64,
2094        page_obj_id: usize,
2095        bookmarks: &mut Vec<PdfBookmark>,
2096    ) {
2097        for element in elements {
2098            if let Some(ref title) = element.bookmark {
2099                let y_pdf = page_height - element.y;
2100                bookmarks.push(PdfBookmark {
2101                    title: title.clone(),
2102                    page_obj_id,
2103                    y_pdf,
2104                });
2105            }
2106            Self::collect_bookmarks(&element.children, page_height, page_obj_id, bookmarks);
2107        }
2108    }
2109
2110    /// Build the PDF outline tree from bookmark entries.
2111    /// Returns the object ID of the /Outlines dictionary.
2112    fn write_outline_tree(&self, builder: &mut PdfBuilder, bookmarks: &[PdfBookmark]) -> usize {
2113        // Reserve the Outlines dictionary object
2114        let outlines_id = builder.objects.len();
2115        builder.objects.push(PdfObject {
2116            id: outlines_id,
2117            data: vec![],
2118        });
2119
2120        // Create outline item objects
2121        let mut item_ids: Vec<usize> = Vec::new();
2122        for _bm in bookmarks {
2123            let item_id = builder.objects.len();
2124            builder.objects.push(PdfObject {
2125                id: item_id,
2126                data: vec![],
2127            });
2128            item_ids.push(item_id);
2129        }
2130
2131        // Fill in outline items with /Prev, /Next, /Parent, /Dest
2132        for (i, (bm, &item_id)) in bookmarks.iter().zip(item_ids.iter()).enumerate() {
2133            let mut dict = format!(
2134                "<< /Title ({}) /Parent {} 0 R /Dest [{} 0 R /XYZ 0 {:.2} null]",
2135                Self::escape_pdf_string(&bm.title),
2136                outlines_id,
2137                bm.page_obj_id,
2138                bm.y_pdf,
2139            );
2140            if i > 0 {
2141                let _ = write!(dict, " /Prev {} 0 R", item_ids[i - 1]);
2142            }
2143            if i + 1 < item_ids.len() {
2144                let _ = write!(dict, " /Next {} 0 R", item_ids[i + 1]);
2145            }
2146            dict.push_str(" >>");
2147            builder.objects[item_id].data = dict.into_bytes();
2148        }
2149
2150        // Fill in Outlines dictionary
2151        let first_id = item_ids.first().copied().unwrap_or(0);
2152        let last_id = item_ids.last().copied().unwrap_or(0);
2153        let outlines_dict = format!(
2154            "<< /Type /Outlines /First {} 0 R /Last {} 0 R /Count {} >>",
2155            first_id,
2156            last_id,
2157            bookmarks.len()
2158        );
2159        builder.objects[outlines_id].data = outlines_dict.into_bytes();
2160
2161        outlines_id
2162    }
2163
2164    /// Write SVG drawing commands to a PDF content stream.
2165    fn write_svg_commands(stream: &mut String, commands: &[SvgCommand]) {
2166        for cmd in commands {
2167            match cmd {
2168                SvgCommand::MoveTo(x, y) => {
2169                    let _ = writeln!(stream, "{:.2} {:.2} m", x, y);
2170                }
2171                SvgCommand::LineTo(x, y) => {
2172                    let _ = writeln!(stream, "{:.2} {:.2} l", x, y);
2173                }
2174                SvgCommand::CurveTo(x1, y1, x2, y2, x3, y3) => {
2175                    let _ = writeln!(
2176                        stream,
2177                        "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2178                        x1, y1, x2, y2, x3, y3
2179                    );
2180                }
2181                SvgCommand::ClosePath => {
2182                    let _ = writeln!(stream, "h");
2183                }
2184                SvgCommand::SetFill(r, g, b) => {
2185                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", r, g, b);
2186                }
2187                SvgCommand::SetFillNone => {
2188                    // No-op in PDF; handled by fill/stroke selection
2189                }
2190                SvgCommand::SetStroke(r, g, b) => {
2191                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", r, g, b);
2192                }
2193                SvgCommand::SetStrokeNone => {
2194                    // No-op in PDF
2195                }
2196                SvgCommand::SetStrokeWidth(w) => {
2197                    let _ = writeln!(stream, "{:.2} w", w);
2198                }
2199                SvgCommand::Fill => {
2200                    let _ = writeln!(stream, "f");
2201                }
2202                SvgCommand::Stroke => {
2203                    let _ = writeln!(stream, "S");
2204                }
2205                SvgCommand::FillAndStroke => {
2206                    let _ = writeln!(stream, "B");
2207                }
2208                SvgCommand::SetLineCap(cap) => {
2209                    let _ = writeln!(stream, "{} J", cap);
2210                }
2211                SvgCommand::SetLineJoin(join) => {
2212                    let _ = writeln!(stream, "{} j", join);
2213                }
2214                SvgCommand::SaveState => {
2215                    let _ = writeln!(stream, "q");
2216                }
2217                SvgCommand::RestoreState => {
2218                    let _ = writeln!(stream, "Q");
2219                }
2220            }
2221        }
2222    }
2223
2224    /// Escape special characters in a PDF string.
2225    pub(crate) fn escape_pdf_string(s: &str) -> String {
2226        s.replace('\\', "\\\\")
2227            .replace('(', "\\(")
2228            .replace(')', "\\)")
2229    }
2230
2231    /// Map a Unicode codepoint to a WinAnsiEncoding byte value.
2232    fn unicode_to_winansi(ch: char) -> Option<u8> {
2233        crate::font::unicode_to_winansi(ch)
2234    }
2235
2236    /// Serialize all objects into the final PDF byte stream.
2237    fn serialize(&self, builder: &PdfBuilder, info_obj_id: Option<usize>) -> Vec<u8> {
2238        let mut output: Vec<u8> = Vec::new();
2239        let mut offsets: Vec<usize> = vec![0; builder.objects.len()];
2240
2241        // Header
2242        output.extend_from_slice(b"%PDF-1.7\n");
2243        output.extend_from_slice(b"%\xe2\xe3\xcf\xd3\n");
2244
2245        for (i, obj) in builder.objects.iter().enumerate().skip(1) {
2246            offsets[i] = output.len();
2247            let header = format!("{} 0 obj\n", i);
2248            output.extend_from_slice(header.as_bytes());
2249            output.extend_from_slice(&obj.data);
2250            output.extend_from_slice(b"\nendobj\n\n");
2251        }
2252
2253        let xref_offset = output.len();
2254        let _ = writeln!(output, "xref\n0 {}", builder.objects.len());
2255        let _ = writeln!(output, "0000000000 65535 f ");
2256        for offset in offsets.iter().skip(1) {
2257            let _ = writeln!(output, "{:010} 00000 n ", offset);
2258        }
2259
2260        let _ = write!(
2261            output,
2262            "trailer\n<< /Size {} /Root 1 0 R",
2263            builder.objects.len()
2264        );
2265        if let Some(info_id) = info_obj_id {
2266            let _ = write!(output, " /Info {} 0 R", info_id);
2267        }
2268        let _ = writeln!(output, " >>\nstartxref\n{}\n%%EOF", xref_offset);
2269
2270        output
2271    }
2272}
2273
2274/// Write a single chart drawing primitive to the PDF content stream.
2275///
2276/// Called within a Y-flipped coordinate system (1 0 0 -1 x page_h-y cm),
2277/// so chart primitives use top-left origin (Y increases downward).
2278fn write_chart_primitive(
2279    stream: &mut String,
2280    prim: &crate::chart::ChartPrimitive,
2281    _chart_height: f64,
2282    builder: &PdfBuilder,
2283) {
2284    use crate::chart::{ChartPrimitive, TextAnchor};
2285    use crate::font::metrics::unicode_to_winansi;
2286
2287    match prim {
2288        ChartPrimitive::Rect { x, y, w, h, fill } => {
2289            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
2290            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re f", x, y, w, h);
2291        }
2292
2293        ChartPrimitive::Line {
2294            x1,
2295            y1,
2296            x2,
2297            y2,
2298            stroke,
2299            width,
2300        } => {
2301            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
2302            let _ = writeln!(stream, "{:.2} w", width);
2303            let _ = writeln!(stream, "{:.2} {:.2} m {:.2} {:.2} l S", x1, y1, x2, y2);
2304        }
2305
2306        ChartPrimitive::Polyline {
2307            points,
2308            stroke,
2309            width,
2310        } => {
2311            if points.len() < 2 {
2312                return;
2313            }
2314            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
2315            let _ = writeln!(stream, "{:.2} w", width);
2316            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
2317            for &(px, py) in &points[1..] {
2318                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
2319            }
2320            let _ = writeln!(stream, "S");
2321        }
2322
2323        ChartPrimitive::FilledPath {
2324            points,
2325            fill,
2326            opacity,
2327        } => {
2328            if points.len() < 3 {
2329                return;
2330            }
2331            let _ = writeln!(stream, "q");
2332            // Set opacity via ExtGState if available
2333            if *opacity < 1.0 {
2334                if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
2335                    let _ = writeln!(stream, "/{} gs", gs_name);
2336                }
2337            }
2338            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
2339            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
2340            for &(px, py) in &points[1..] {
2341                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
2342            }
2343            let _ = writeln!(stream, "h f");
2344            let _ = writeln!(stream, "Q");
2345        }
2346
2347        ChartPrimitive::Circle { cx, cy, r, fill } => {
2348            // Approximate circle with 4 cubic bezier curves
2349            let kappa: f64 = 0.5523;
2350            let kr = kappa * r;
2351            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
2352            let _ = writeln!(stream, "{:.2} {:.2} m", cx + r, cy);
2353            let _ = writeln!(
2354                stream,
2355                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2356                cx + r,
2357                cy + kr,
2358                cx + kr,
2359                cy + r,
2360                cx,
2361                cy + r
2362            );
2363            let _ = writeln!(
2364                stream,
2365                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2366                cx - kr,
2367                cy + r,
2368                cx - r,
2369                cy + kr,
2370                cx - r,
2371                cy
2372            );
2373            let _ = writeln!(
2374                stream,
2375                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2376                cx - r,
2377                cy - kr,
2378                cx - kr,
2379                cy - r,
2380                cx,
2381                cy - r
2382            );
2383            let _ = writeln!(
2384                stream,
2385                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2386                cx + kr,
2387                cy - r,
2388                cx + r,
2389                cy - kr,
2390                cx + r,
2391                cy
2392            );
2393            let _ = writeln!(stream, "f");
2394        }
2395
2396        ChartPrimitive::ArcSector {
2397            cx,
2398            cy,
2399            r,
2400            start_angle,
2401            end_angle,
2402            fill,
2403        } => {
2404            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
2405            // Move to center
2406            let _ = writeln!(stream, "{:.2} {:.2} m", cx, cy);
2407            // Line to arc start
2408            let sx = cx + r * start_angle.cos();
2409            let sy = cy + r * start_angle.sin();
2410            let _ = writeln!(stream, "{:.2} {:.2} l", sx, sy);
2411
2412            // Approximate arc with cubic bezier segments (max 90° per segment)
2413            let mut angle = *start_angle;
2414            let total = end_angle - start_angle;
2415            let segments = ((total.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
2416            let step = total / segments as f64;
2417
2418            for _ in 0..segments {
2419                let a1 = angle;
2420                let a2 = angle + step;
2421                let alpha = 4.0 / 3.0 * ((a2 - a1) / 4.0).tan();
2422
2423                let p1x = cx + r * a1.cos();
2424                let p1y = cy + r * a1.sin();
2425                let p2x = cx + r * a2.cos();
2426                let p2y = cy + r * a2.sin();
2427
2428                let cp1x = p1x - alpha * r * a1.sin();
2429                let cp1y = p1y + alpha * r * a1.cos();
2430                let cp2x = p2x + alpha * r * a2.sin();
2431                let cp2y = p2y - alpha * r * a2.cos();
2432
2433                let _ = writeln!(
2434                    stream,
2435                    "{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c",
2436                    cp1x, cp1y, cp2x, cp2y, p2x, p2y
2437                );
2438                angle = a2;
2439            }
2440
2441            // Close path back to center and fill
2442            let _ = writeln!(stream, "h f");
2443        }
2444
2445        ChartPrimitive::Label {
2446            text,
2447            x,
2448            y,
2449            font_size,
2450            color,
2451            anchor,
2452        } => {
2453            // Measure text width for anchor alignment
2454            let metrics = crate::font::StandardFont::Helvetica.metrics();
2455            let text_width = metrics.measure_string(text, *font_size, 0.0);
2456            let x_offset = match anchor {
2457                TextAnchor::Left => 0.0,
2458                TextAnchor::Center => -text_width / 2.0,
2459                TextAnchor::Right => -text_width,
2460            };
2461
2462            // Find Helvetica font index in font_objects
2463            let font_idx = builder
2464                .font_objects
2465                .iter()
2466                .enumerate()
2467                .find(|(_, (key, _))| key.family == "Helvetica" && key.weight == 400 && !key.italic)
2468                .map(|(i, _)| i)
2469                .unwrap_or(0);
2470
2471            // Encode text to WinAnsi
2472            let encoded: String = text
2473                .chars()
2474                .map(|ch| {
2475                    if let Some(code) = unicode_to_winansi(ch) {
2476                        code as char
2477                    } else if (ch as u32) >= 32 && (ch as u32) <= 255 {
2478                        ch
2479                    } else {
2480                        '?'
2481                    }
2482                })
2483                .collect();
2484            let escaped = pdf_escape_string(&encoded);
2485
2486            // Undo Y-flip for text rendering, then position
2487            let _ = writeln!(stream, "q");
2488            let _ = writeln!(stream, "1 0 0 -1 {:.4} {:.4} cm", x + x_offset, *y);
2489            let _ = writeln!(
2490                stream,
2491                "BT /F{} {:.1} Tf {:.3} {:.3} {:.3} rg 0 0 Td ({}) Tj ET",
2492                font_idx, font_size, color.r, color.g, color.b, escaped
2493            );
2494            let _ = writeln!(stream, "Q");
2495        }
2496    }
2497}
2498
2499/// Escape a string for use in a PDF text string (parentheses and backslash).
2500fn pdf_escape_string(s: &str) -> String {
2501    let mut out = String::with_capacity(s.len());
2502    for ch in s.chars() {
2503        match ch {
2504            '(' => out.push_str("\\("),
2505            ')' => out.push_str("\\)"),
2506            '\\' => out.push_str("\\\\"),
2507            _ => out.push(ch),
2508        }
2509    }
2510    out
2511}
2512
2513#[cfg(test)]
2514mod tests {
2515    use super::*;
2516    use crate::font::FontContext;
2517
2518    #[test]
2519    fn test_escape_pdf_string() {
2520        assert_eq!(
2521            PdfWriter::escape_pdf_string("Hello (World)"),
2522            "Hello \\(World\\)"
2523        );
2524        assert_eq!(PdfWriter::escape_pdf_string("back\\slash"), "back\\\\slash");
2525    }
2526
2527    #[test]
2528    fn test_empty_document_produces_valid_pdf() {
2529        let writer = PdfWriter::new();
2530        let font_context = FontContext::new();
2531        let pages = vec![LayoutPage {
2532            width: 595.28,
2533            height: 841.89,
2534            elements: vec![],
2535            fixed_header: vec![],
2536            fixed_footer: vec![],
2537            watermarks: vec![],
2538            config: PageConfig::default(),
2539        }];
2540        let metadata = Metadata::default();
2541        let bytes = writer
2542            .write(&pages, &metadata, &font_context, false, None, None)
2543            .unwrap();
2544
2545        assert!(bytes.starts_with(b"%PDF-1.7"));
2546        assert!(bytes.windows(5).any(|w| w == b"%%EOF"));
2547        assert!(bytes.windows(4).any(|w| w == b"xref"));
2548        assert!(bytes.windows(7).any(|w| w == b"trailer"));
2549    }
2550
2551    #[test]
2552    fn test_metadata_in_pdf() {
2553        let writer = PdfWriter::new();
2554        let font_context = FontContext::new();
2555        let pages = vec![LayoutPage {
2556            width: 595.28,
2557            height: 841.89,
2558            elements: vec![],
2559            fixed_header: vec![],
2560            fixed_footer: vec![],
2561            watermarks: vec![],
2562            config: PageConfig::default(),
2563        }];
2564        let metadata = Metadata {
2565            title: Some("Test Document".to_string()),
2566            author: Some("Forme".to_string()),
2567            subject: None,
2568            creator: None,
2569            lang: None,
2570        };
2571        let bytes = writer
2572            .write(&pages, &metadata, &font_context, false, None, None)
2573            .unwrap();
2574        let text = String::from_utf8_lossy(&bytes);
2575
2576        assert!(text.contains("/Title (Test Document)"));
2577        assert!(text.contains("/Author (Forme)"));
2578    }
2579
2580    #[test]
2581    fn test_bold_font_registered_separately() {
2582        let writer = PdfWriter::new();
2583        let font_context = FontContext::new();
2584
2585        // Create pages with both regular and bold text
2586        let pages = vec![LayoutPage {
2587            width: 595.28,
2588            height: 841.89,
2589            elements: vec![
2590                LayoutElement {
2591                    x: 54.0,
2592                    y: 54.0,
2593                    width: 100.0,
2594                    height: 16.8,
2595                    draw: DrawCommand::Text {
2596                        lines: vec![TextLine {
2597                            x: 54.0,
2598                            y: 66.0,
2599                            width: 50.0,
2600                            height: 16.8,
2601                            glyphs: vec![PositionedGlyph {
2602                                glyph_id: 65,
2603                                x_offset: 0.0,
2604                                y_offset: 0.0,
2605                                x_advance: 8.0,
2606                                font_size: 12.0,
2607                                font_family: "Helvetica".to_string(),
2608                                font_weight: 400,
2609                                font_style: FontStyle::Normal,
2610                                char_value: 'A',
2611                                color: None,
2612                                href: None,
2613                                text_decoration: TextDecoration::None,
2614                                letter_spacing: 0.0,
2615                                cluster_text: None,
2616                            }],
2617                            word_spacing: 0.0,
2618                        }],
2619                        color: Color::BLACK,
2620                        text_decoration: TextDecoration::None,
2621                        opacity: 1.0,
2622                    },
2623                    children: vec![],
2624                    node_type: None,
2625                    resolved_style: None,
2626                    source_location: None,
2627                    href: None,
2628                    bookmark: None,
2629                    alt: None,
2630                    is_header_row: false,
2631                    overflow: Overflow::default(),
2632                },
2633                LayoutElement {
2634                    x: 54.0,
2635                    y: 74.0,
2636                    width: 100.0,
2637                    height: 16.8,
2638                    draw: DrawCommand::Text {
2639                        lines: vec![TextLine {
2640                            x: 54.0,
2641                            y: 86.0,
2642                            width: 50.0,
2643                            height: 16.8,
2644                            glyphs: vec![PositionedGlyph {
2645                                glyph_id: 65,
2646                                x_offset: 0.0,
2647                                y_offset: 0.0,
2648                                x_advance: 8.0,
2649                                font_size: 12.0,
2650                                font_family: "Helvetica".to_string(),
2651                                font_weight: 700,
2652                                font_style: FontStyle::Normal,
2653                                char_value: 'A',
2654                                color: None,
2655                                href: None,
2656                                text_decoration: TextDecoration::None,
2657                                letter_spacing: 0.0,
2658                                cluster_text: None,
2659                            }],
2660                            word_spacing: 0.0,
2661                        }],
2662                        color: Color::BLACK,
2663                        text_decoration: TextDecoration::None,
2664                        opacity: 1.0,
2665                    },
2666                    children: vec![],
2667                    node_type: None,
2668                    resolved_style: None,
2669                    source_location: None,
2670                    href: None,
2671                    bookmark: None,
2672                    alt: None,
2673                    is_header_row: false,
2674                    overflow: Overflow::default(),
2675                },
2676            ],
2677            fixed_header: vec![],
2678            fixed_footer: vec![],
2679            watermarks: vec![],
2680            config: PageConfig::default(),
2681        }];
2682
2683        let metadata = Metadata::default();
2684        let bytes = writer
2685            .write(&pages, &metadata, &font_context, false, None, None)
2686            .unwrap();
2687        let text = String::from_utf8_lossy(&bytes);
2688
2689        // Should have both Helvetica and Helvetica-Bold registered
2690        assert!(
2691            text.contains("Helvetica"),
2692            "Should contain regular Helvetica"
2693        );
2694        assert!(
2695            text.contains("Helvetica-Bold"),
2696            "Should contain Helvetica-Bold"
2697        );
2698    }
2699
2700    #[test]
2701    fn test_sanitize_font_name() {
2702        assert_eq!(PdfWriter::sanitize_font_name("Inter", 400, false), "Inter");
2703        assert_eq!(
2704            PdfWriter::sanitize_font_name("Inter", 700, false),
2705            "Inter-Bold"
2706        );
2707        assert_eq!(
2708            PdfWriter::sanitize_font_name("Inter", 400, true),
2709            "Inter-Italic"
2710        );
2711        assert_eq!(
2712            PdfWriter::sanitize_font_name("Inter", 700, true),
2713            "Inter-Bold-Italic"
2714        );
2715        assert_eq!(
2716            PdfWriter::sanitize_font_name("Noto Sans", 400, false),
2717            "NotoSans"
2718        );
2719        assert_eq!(
2720            PdfWriter::sanitize_font_name("Font (Display)", 400, false),
2721            "FontDisplay"
2722        );
2723    }
2724
2725    #[test]
2726    fn test_tounicode_cmap_format() {
2727        // glyph_to_char: maps subset glyph IDs → Unicode chars
2728        let mut glyph_to_char = HashMap::new();
2729        glyph_to_char.insert(36u16, 'A');
2730        glyph_to_char.insert(37u16, 'B');
2731
2732        let cmap = PdfWriter::build_tounicode_cmap_from_gids(&glyph_to_char, "TestFont");
2733
2734        assert!(cmap.contains("begincmap"), "CMap should contain begincmap");
2735        assert!(cmap.contains("endcmap"), "CMap should contain endcmap");
2736        assert!(
2737            cmap.contains("beginbfchar"),
2738            "CMap should contain beginbfchar"
2739        );
2740        assert!(cmap.contains("endbfchar"), "CMap should contain endbfchar");
2741        assert!(
2742            cmap.contains("<0024> <0041>"),
2743            "Should map gid 0x0024 to Unicode 'A' 0x0041"
2744        );
2745        assert!(
2746            cmap.contains("<0025> <0042>"),
2747            "Should map gid 0x0025 to Unicode 'B' 0x0042"
2748        );
2749        assert!(
2750            cmap.contains("begincodespacerange"),
2751            "Should define codespace range"
2752        );
2753        assert!(
2754            cmap.contains("<0000> <FFFF>"),
2755            "Codespace should be 0000-FFFF"
2756        );
2757    }
2758
2759    #[test]
2760    fn test_w_array_format() {
2761        let mut char_to_gid = HashMap::new();
2762        char_to_gid.insert('A', 36u16);
2763
2764        // We need actual font data to test this properly, so just verify format
2765        // with a minimal check that the function produces valid output
2766        let w_array_str = "[ 36 [600] ]";
2767        assert!(w_array_str.starts_with('['));
2768        assert!(w_array_str.ends_with(']'));
2769    }
2770
2771    #[test]
2772    fn test_hex_glyph_encoding() {
2773        // Verify the hex format used for custom font text encoding
2774        let gid: u16 = 0x0041;
2775        let hex = format!("{:04X}", gid);
2776        assert_eq!(hex, "0041");
2777
2778        let gids = [0x0041u16, 0x0042, 0x0043];
2779        let hex_str: String = gids.iter().map(|g| format!("{:04X}", g)).collect();
2780        assert_eq!(hex_str, "004100420043");
2781    }
2782
2783    #[test]
2784    fn test_standard_font_still_uses_text_string() {
2785        let writer = PdfWriter::new();
2786        let font_context = FontContext::new();
2787
2788        let pages = vec![LayoutPage {
2789            width: 595.28,
2790            height: 841.89,
2791            elements: vec![LayoutElement {
2792                x: 54.0,
2793                y: 54.0,
2794                width: 100.0,
2795                height: 16.8,
2796                draw: DrawCommand::Text {
2797                    lines: vec![TextLine {
2798                        x: 54.0,
2799                        y: 66.0,
2800                        width: 50.0,
2801                        height: 16.8,
2802                        glyphs: vec![PositionedGlyph {
2803                            glyph_id: 65,
2804                            x_offset: 0.0,
2805                            y_offset: 0.0,
2806                            x_advance: 8.0,
2807                            font_size: 12.0,
2808                            font_family: "Helvetica".to_string(),
2809                            font_weight: 400,
2810                            font_style: FontStyle::Normal,
2811                            char_value: 'H',
2812                            color: None,
2813                            href: None,
2814                            text_decoration: TextDecoration::None,
2815                            letter_spacing: 0.0,
2816                            cluster_text: None,
2817                        }],
2818                        word_spacing: 0.0,
2819                    }],
2820                    color: Color::BLACK,
2821                    text_decoration: TextDecoration::None,
2822                    opacity: 1.0,
2823                },
2824                children: vec![],
2825                node_type: None,
2826                resolved_style: None,
2827                source_location: None,
2828                href: None,
2829                bookmark: None,
2830                alt: None,
2831                is_header_row: false,
2832                overflow: Overflow::default(),
2833            }],
2834            fixed_header: vec![],
2835            fixed_footer: vec![],
2836            watermarks: vec![],
2837            config: PageConfig::default(),
2838        }];
2839
2840        let metadata = Metadata::default();
2841        let bytes = writer
2842            .write(&pages, &metadata, &font_context, false, None, None)
2843            .unwrap();
2844        let text = String::from_utf8_lossy(&bytes);
2845
2846        // Standard fonts should use Type1, not CIDFontType2
2847        assert!(
2848            text.contains("/Type1"),
2849            "Standard font should use Type1 subtype"
2850        );
2851        assert!(
2852            !text.contains("CIDFontType2"),
2853            "Standard font should not use CIDFontType2"
2854        );
2855    }
2856}