Skip to main content

forme/pdf/
mod.rs

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