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/// Default `/Params /ModDate` for attachments. A fixed constant, never
49/// wall-clock: byte-determinism is a hard guarantee (native/WASM parity is
50/// gated on it in CI). Callers wanting a real date pass `modDate`.
51const DEFAULT_ATTACHMENT_MOD_DATE: &str = "D:20000101000000Z";
52
53/// The producer name, written to BOTH DocInfo `/Producer` and XMP
54/// `pdf:Producer` (and the redaction rewrite of each). One definition,
55/// because PDF/A requires the two to agree and veraPDF does not check this
56/// pair (issue #158: DocInfo said "Forme 0.6" while XMP said "Forme").
57///
58/// Deliberately carries no version. A version here would change every
59/// output byte on every release, breaking byte-identity comparisons across
60/// versions for no rendering change, and a hardcoded one goes stale (which
61/// is how "0.6" outlived 0.6 by nineteen releases).
62pub(crate) const PRODUCER: &str = "Forme";
63
64/// The creating tool, written to DocInfo `/Creator` and XMP
65/// `xmp:CreatorTool`, which PDF/A pairs the same way.
66pub(crate) const CREATOR_TOOL: &str = "Forme";
67
68/// A link annotation to be added to a page.
69struct LinkAnnotation {
70    x: f64,
71    y: f64,
72    width: f64,
73    height: f64,
74    href: String,
75}
76
77/// A linked span inside one text line: the glyphs of an inline link run
78/// (`<Text>See <Link href>docs</Link></Text>`) that share an href, with the
79/// absolute x extent they are drawn at.
80struct InlineLinkSpan {
81    href: String,
82    x0: f64,
83    x1: f64,
84}
85
86/// A bookmark entry for the PDF outline tree.
87struct PdfBookmark {
88    title: String,
89    page_obj_id: usize,
90    y_pdf: f64,
91}
92
93/// A form field annotation collected during layout traversal.
94struct FormFieldData {
95    field_type: FormFieldType,
96    name: String,
97    x: f64,
98    y: f64,
99    width: f64,
100    height: f64,
101    page_idx: usize,
102}
103
104pub struct PdfWriter;
105
106/// Record the text `glyph` stands for, for the ToUnicode CMap.
107///
108/// A ligature glyph (one glyph for several chars, `PositionedGlyph::ligature`)
109/// stands for its whole cluster, so "ffi" extracts as "ffi" and not "f"
110/// (issue #156). Every other glyph stands for its own char, which for a glyph
111/// sharing a multi-glyph cluster is the cluster's first char, as before.
112///
113/// One glyph ID has ONE CMap entry, so when the same glyph is seen standing
114/// for different text the choice must be deterministic and must not corrupt
115/// ordinary text. Rule: a single-char mapping beats a multi-char one, and
116/// otherwise the first one seen (document order) wins. A glyph the font maps
117/// from a single char is that char everywhere; a multi-char mapping only
118/// survives for glyphs that are never seen alone, which is what a real
119/// ligature glyph is. Without the rule, a glyph seen once in a cluster such as
120/// "e" + U+FE0F would turn every later plain "e" into "e\u{FE0F}".
121fn record_glyph_text(glyph_to_text: &mut HashMap<u16, String>, glyph: &PositionedGlyph) {
122    let text = match &glyph.cluster_text {
123        Some(cluster) if glyph.ligature && !cluster.is_empty() => cluster.clone(),
124        _ => glyph.char_value.to_string(),
125    };
126    match glyph_to_text.entry(glyph.glyph_id) {
127        std::collections::hash_map::Entry::Vacant(slot) => {
128            slot.insert(text);
129        }
130        std::collections::hash_map::Entry::Occupied(mut slot) => {
131            let existing_is_multi = slot.get().chars().nth(1).is_some();
132            let new_is_single = text.chars().nth(1).is_none();
133            if existing_is_multi && new_is_single {
134                slot.insert(text);
135            }
136        }
137    }
138}
139
140/// Embedding data for a custom TrueType font.
141#[allow(dead_code)]
142struct CustomFontEmbedData {
143    ttf_data: Vec<u8>,
144    /// Maps original glyph IDs (from shaping) to remapped GIDs in the subset font.
145    gid_remap: HashMap<u16, u16>,
146    /// Maps original glyph IDs to the text each stands for (ToUnicode CMap).
147    glyph_to_text: HashMap<u16, String>,
148    /// Legacy fallback: maps chars to subset GIDs (for page number placeholders).
149    char_to_gid: HashMap<char, u16>,
150    /// The /W widths written for each subset GID (thousandths of an em,
151    /// exactly as written), and /DW. A viewer advances by these, so the
152    /// text writer's TJ adjustments are computed against them.
153    pdf_widths: HashMap<u16, f64>,
154    default_width: u32,
155    units_per_em: u16,
156    ascender: i16,
157    descender: i16,
158}
159
160/// Font usage data collected from layout elements.
161struct FontUsage {
162    /// Characters used per font (for standard font subsetting fallback).
163    chars: HashSet<char>,
164    /// Glyph IDs used per font (from shaped PositionedGlyphs).
165    glyph_ids: HashSet<u16>,
166    /// Maps glyph ID → the text it stands for (for ToUnicode CMap): one char
167    /// for an ordinary glyph, the whole cluster for a ligature ("ffi").
168    glyph_to_text: HashMap<u16, String>,
169}
170
171/// Tracks allocated PDF objects during writing.
172struct PdfBuilder {
173    objects: Vec<PdfObject>,
174    /// Maps (family, weight, italic) -> (object_id, index)
175    font_objects: Vec<(FontKey, usize)>,
176    /// Embedding data for custom fonts, keyed by FontKey.
177    custom_font_data: HashMap<FontKey, CustomFontEmbedData>,
178    /// Base-14 fonts that were embedded via the pdfUa metric-compatible
179    /// substitution (Liberation). They aren't in `custom_font_data` — the
180    /// caller registered no custom bytes for them — but they ARE embedded, so
181    /// the PDF/A "all fonts embedded" check must treat them as satisfied.
182    embedded_standard_fonts: std::collections::HashSet<FontKey>,
183    /// XObject obj IDs for images, indexed as /Im0, /Im1, ...
184    /// Each entry is (main_xobject_id, optional_smask_xobject_id).
185    image_objects: Vec<usize>,
186    /// Maps (page_index, element_position_in_page) to image index in image_objects.
187    /// Used during content stream writing to find the right /ImN reference.
188    image_index_map: HashMap<(usize, usize), usize>,
189    /// Maps page_index to (image_index, intrinsic_width_px, intrinsic_height_px)
190    /// for the page's optional `background_image`. Identical URLs across
191    /// pages share a single XObject; the dims are needed for
192    /// `cover` / `contain` sizing math at content-stream time.
193    page_background_image_map: HashMap<usize, (usize, u32, u32)>,
194    /// Caches `backgroundImage URL → (image index, w_px, h_px)` so
195    /// identical background images across different pages collapse to a
196    /// single XObject.
197    page_background_url_cache: HashMap<String, (usize, u32, u32)>,
198    /// ExtGState objects for opacity. Maps opacity value (as ordered bits) to
199    /// (object_id, gs_name) e.g. (42, "GS0").
200    ext_gstate_map: HashMap<u64, (usize, String)>,
201    /// Shading dictionaries for gradients. One entry per (page, element)
202    /// gradient instance. Resolves to (object_id, sh_name e.g. "Sh0").
203    /// Maps `(page_idx, elem_idx) -> (obj_id, name)`.
204    shading_map: HashMap<(usize, usize), (usize, String)>,
205    /// Non-fatal notices collected during the write (e.g. pdfUa without an
206    /// embeddable font). Returned to the caller so every render surface can
207    /// show them, never silently dropped.
208    warnings: Vec<String>,
209    /// Characters replaced by "?" because no available font covers them —
210    /// not WinAnsi, not the bundled Noto, not a registered font. RefCell
211    /// because the substitution sites run under `&self` (write_element is
212    /// recursive; chart labels render through a free fn holding `&PdfBuilder`).
213    /// Drained into one warning per distinct character at the end of the
214    /// write: a silently wrong glyph is a render defect (decision 2026-09-08 —
215    /// keep the bundled font, make the register-a-font path discoverable).
216    missing_glyphs: std::cell::RefCell<std::collections::BTreeSet<char>>,
217    /// Output version — read by serialize() for the header; every other
218    /// 2.0 behavior is decided in write() before objects are built.
219    pdf_version: crate::model::PdfVersion,
220}
221
222pub(crate) struct PdfObject {
223    #[allow(dead_code)]
224    pub(crate) id: usize,
225    pub(crate) data: Vec<u8>,
226}
227
228impl Default for PdfWriter {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl PdfWriter {
235    pub fn new() -> Self {
236        Self
237    }
238
239    /// Write laid-out pages to a PDF byte vector.
240    ///
241    /// MEMORY NOTE (streaming-serialize investigation, 2026-09 — set aside): the
242    /// large-document peak (~1GB for a 500-page doc) is NOT here. It is the
243    /// `Vec<LayoutPage>` the caller retains (~2MB/page) while this fn borrows it
244    /// as a slice. This writer is already ~90% streaming-ready: Pass 1 (below)
245    /// consumes and zlib-compresses everything heavy per page; Pass 2 touches
246    /// only scalars (width/height) and the lightweight collected lists
247    /// (annotations, bookmarks). So making `write` take pages by value and drop
248    /// each page's `elements` after Pass 1 saves nothing on its own — `layout()`
249    /// has already materialized the whole tree before `write` is called. A real
250    /// peak reduction needs a restartable STREAMING LAYOUT producer (yield page
251    /// N, serialize, drop), which collides with the sentinel count pass (total
252    /// page count is needed before page 1 can emit) and touches pagination.
253    /// Crucially, PDF/A + PDF/UA are NOT a blocker: the structure tree
254    /// (`tagged::TagBuilder`), `link_slots`, and disjoint page/annotation
255    /// StructParent numbering are a few MB of lightweight metadata that stay
256    /// whole-document and assemble unchanged at finalize — so streaming frees
257    /// layout memory earlier without moving a single output byte, and veraPDF
258    /// stays 9/9 by construction. See `scripts/parity/benchmarks.mjs`
259    /// `trackedFixes` for the full write-up.
260    #[allow(clippy::too_many_arguments)]
261    pub fn write(
262        &self,
263        pages: &[LayoutPage],
264        metadata: &Metadata,
265        font_context: &FontContext,
266        tagged: bool,
267        pdfa: Option<&PdfAConformance>,
268        pdf_ua: bool,
269        embedded_data: Option<&str>,
270        attachments: &[Attachment],
271        zugferd: Option<&ZugferdMeta>,
272        flatten_forms: bool,
273        pdf_version: crate::model::PdfVersion,
274        pdf_ua2: bool,
275    ) -> Result<(Vec<u8>, Vec<String>), FormeError> {
276        // ── Attachment / e-invoice validation (before any emission) ──
277        //
278        // PDF/A-1/-2 allow only PDF/A files as attachments (veraPDF rule
279        // 6.8-5) — which the engine cannot verify, so a 2x level with any
280        // attachment refuses rather than emitting a file that lies about
281        // conformance. PDF/A-3 exists precisely to permit arbitrary
282        // embedded files.
283        if let Some(level) = pdfa {
284            if !level.allows_attachments() && (embedded_data.is_some() || !attachments.is_empty()) {
285                use crate::model::PdfAConformance as L;
286                let (family, clause, remedy) = match level {
287                    L::A4 => ("PDF/A-4", "ISO 19005-4", "pdfa: \"4f\""),
288                    _ => (
289                        "PDF/A-2",
290                        "ISO 19005-2, 6.8",
291                        "a PDF/A-3 level — e.g. pdfa: \"3b\"",
292                    ),
293                };
294                return Err(FormeError::RenderError(format!(
295                    "{family} forbids embedded files that are not themselves PDF/A \
296                     ({clause}), which the engine cannot verify. Use {remedy}, which \
297                     permits arbitrary attachments, or remove the attachment / embedData."
298                )));
299            }
300            // The inverse rule, from veraPDF's PDFA-4F profile verbatim
301            // (6.9-t5): "A PDF/A-4f conforming file shall contain an
302            // EmbeddedFiles key" — an A-4f claim with NOTHING embedded is
303            // itself non-conformant.
304            if matches!(level, crate::model::PdfAConformance::A4f)
305                && embedded_data.is_none()
306                && attachments.is_empty()
307            {
308                return Err(FormeError::RenderError(
309                    "pdfa: \"4f\" requires at least one embedded file (ISO 19005-4, \
310                     Annex A; veraPDF 6.9-t5 — the EmbeddedFiles name tree must exist). \
311                     Add an attachment or embedData, or claim pdfa: \"4\" instead."
312                        .to_string(),
313                ));
314            }
315        }
316        // Factur-X/ZUGFeRD identification is container metadata pointing
317        // at an attached XML: it needs PDF/A-3 and a matching attachment,
318        // or the XMP would name a profile/file that isn't there.
319        let zugferd_filename: Option<String> = if let Some(z) = zugferd {
320            const LEVELS: [&str; 6] = [
321                "MINIMUM",
322                "BASIC WL",
323                "BASIC",
324                "EN 16931",
325                "EXTENDED",
326                "XRECHNUNG",
327            ];
328            if !LEVELS.contains(&z.conformance_level.as_str()) {
329                return Err(FormeError::RenderError(format!(
330                    "zugferd.conformanceLevel {:?} is not a Factur-X profile — expected one of \
331                     MINIMUM, BASIC WL, BASIC, EN 16931, EXTENDED, XRECHNUNG (exact spelling, \
332                     spaces included).",
333                    z.conformance_level
334                )));
335            }
336            if !pdfa.is_some_and(|l| l.allows_attachments()) {
337                return Err(FormeError::RenderError(
338                    "Factur-X/ZUGFeRD (zugferd) requires a PDF/A-3 conformance level — set \
339                     pdfa: \"3b\" (or \"3a\"/\"3u\"). The e-invoice XML is an embedded file, \
340                     which only PDF/A-3 permits."
341                        .to_string(),
342                ));
343            }
344            let filename = z.document_file_name.clone().unwrap_or_else(|| {
345                if z.conformance_level == "XRECHNUNG" {
346                    "xrechnung.xml".to_string()
347                } else {
348                    "factur-x.xml".to_string()
349                }
350            });
351            if !attachments.iter().any(|a| a.name == filename) {
352                return Err(FormeError::RenderError(format!(
353                    "zugferd is set but no attachment is named {filename:?} — the XMP would \
354                     point at a file that isn't embedded. Attach the invoice XML with name: \
355                     {filename:?}, or set zugferd.documentFileName to the attachment's name."
356                )));
357            }
358            Some(filename)
359        } else {
360            None
361        };
362        let mut builder = PdfBuilder {
363            objects: Vec::new(),
364            font_objects: Vec::new(),
365            custom_font_data: HashMap::new(),
366            embedded_standard_fonts: std::collections::HashSet::new(),
367            image_objects: Vec::new(),
368            image_index_map: HashMap::new(),
369            page_background_image_map: HashMap::new(),
370            page_background_url_cache: HashMap::new(),
371            ext_gstate_map: HashMap::new(),
372            shading_map: HashMap::new(),
373            warnings: Vec::new(),
374            missing_glyphs: Default::default(),
375            pdf_version: Default::default(),
376        };
377
378        // Reserve object IDs:
379        // 0 = placeholder (PDF objects are 1-indexed)
380        // 1 = Catalog
381        // 2 = Pages (page tree root)
382        // 3+ = fonts, then page objects, then content streams
383        builder.objects.push(PdfObject {
384            id: 0,
385            data: vec![],
386        });
387        builder.objects.push(PdfObject {
388            id: 1,
389            data: vec![],
390        });
391        builder.objects.push(PdfObject {
392            id: 2,
393            data: vec![],
394        });
395
396        // Register the fonts actually used across all pages
397        builder.pdf_version = pdf_version;
398        self.register_fonts(
399            &mut builder,
400            pages,
401            font_context,
402            pdf_ua,
403            pdfa.is_some(),
404            pdf_version,
405        )?;
406
407        // PDF/A: validate that all fonts are embedded. A font counts as
408        // embedded if the caller registered custom bytes for it OR it's a
409        // base-14 family embedded via the pdfUa Liberation substitution
410        // (`embedded_standard_fonts`) — so PDF/A composes with PDF/UA when
411        // @formepdf/fonts-standard is registered.
412        if pdfa.is_some() {
413            for (key, _) in &builder.font_objects {
414                if !builder.custom_font_data.contains_key(key)
415                    && !builder.embedded_standard_fonts.contains(key)
416                {
417                    return Err(FormeError::RenderError(format!(
418                        "PDF/A requires all fonts to be embedded, but '{}' is not. Register a \
419                         metric-compatible font — install @formepdf/fonts-standard and register \
420                         its fonts (`for (const f of standardFonts()) Font.register(f)`), or supply \
421                         your own via Font.register().",
422                        key.family
423                    )));
424                }
425            }
426        }
427
428        // Register images as XObject PDF objects
429        self.register_images(&mut builder, pages);
430
431        // Register page background images (if any) — distinct from
432        // element-level Image XObjects since they're addressed per-page
433        // and can be shared across pages with the same source URL.
434        self.register_page_background_images(&mut builder, pages);
435
436        // Register ExtGState objects for opacity
437        self.register_ext_gstates(&mut builder, pages);
438
439        // Register Shading dictionaries for gradient backgrounds.
440        self.register_shadings(&mut builder, pages);
441
442        // Create tag builder for accessibility if requested. PDF/UA-2 mode
443        // selects the ISO 32005 structure shape (see TagBuilder::new).
444        let mut tag_builder = if tagged {
445            Some(tagged::TagBuilder::new(pages.len(), pdf_ua2))
446        } else {
447            None
448        };
449
450        // Two-pass page processing:
451        // Pass 1: Build content streams, page objects, collect bookmarks + annotations
452        // Pass 2: Create annotation objects (needs full bookmark list for internal links)
453        let mut page_obj_ids: Vec<usize> = Vec::new();
454        let mut all_bookmarks: Vec<PdfBookmark> = Vec::new();
455        let mut per_page_content_obj_ids: Vec<usize> = Vec::new();
456        let mut per_page_annotations: Vec<Vec<LinkAnnotation>> = Vec::new();
457        let mut per_page_resources: Vec<String> = Vec::new();
458        let mut all_form_fields: Vec<FormFieldData> = Vec::new();
459
460        // Pass 1: content streams, page objects (without /Annots), bookmarks
461        for (page_idx, page) in pages.iter().enumerate() {
462            let content = self.build_content_stream_for_page(
463                page,
464                page_idx,
465                &builder,
466                page_idx + 1,
467                pages.len(),
468                tag_builder.as_mut(),
469                flatten_forms,
470            );
471            let compressed = compress_to_vec_zlib(content.as_bytes(), 6);
472
473            let content_obj_id = builder.objects.len();
474            let mut content_data: Vec<u8> = Vec::new();
475            let _ = write!(
476                content_data,
477                "<< /Length {} /Filter /FlateDecode >>\nstream\n",
478                compressed.len()
479            );
480            content_data.extend_from_slice(&compressed);
481            content_data.extend_from_slice(b"\nendstream");
482            builder.objects.push(PdfObject {
483                id: content_obj_id,
484                data: content_data,
485            });
486            per_page_content_obj_ids.push(content_obj_id);
487
488            // Collect link annotations (deferred creation until pass 2)
489            let mut annotations: Vec<LinkAnnotation> = Vec::new();
490            Self::collect_link_annotations(&page.elements, page.height, &mut annotations);
491            Self::warn_nested_links(&page.elements, None, &mut builder.warnings);
492            per_page_annotations.push(annotations);
493
494            // Collect form field annotations
495            Self::collect_form_fields(&page.elements, page.height, page_idx, &mut all_form_fields);
496
497            // Reserve page object (placeholder — filled in pass 2)
498            let page_obj_id = builder.objects.len();
499            builder.objects.push(PdfObject {
500                id: page_obj_id,
501                data: vec![],
502            });
503
504            // Build resource dict for this page
505            let font_resources = self.build_font_resource_dict(&builder.font_objects);
506            let xobject_resources = self.build_xobject_resource_dict(page_idx, &builder);
507            let ext_gstate_resources = self.build_ext_gstate_resource_dict(&builder);
508            let shading_resources = self.build_shading_resource_dict(page_idx, &builder);
509            let mut resources = format!("/Font << {} >>", font_resources);
510            if !xobject_resources.is_empty() {
511                let _ = write!(resources, " /XObject << {} >>", xobject_resources);
512            }
513            if !ext_gstate_resources.is_empty() {
514                let _ = write!(resources, " /ExtGState << {} >>", ext_gstate_resources);
515            }
516            if !shading_resources.is_empty() {
517                let _ = write!(resources, " /Shading << {} >>", shading_resources);
518            }
519            per_page_resources.push(resources);
520
521            // Collect bookmarks (needs page_obj_id)
522            Self::collect_bookmarks(&page.elements, page.height, page_obj_id, &mut all_bookmarks);
523
524            page_obj_ids.push(page_obj_id);
525        }
526
527        // Pass 2: create annotation objects and fill in page dicts
528        for (page_idx, annotations) in per_page_annotations.iter().enumerate() {
529            let mut annot_obj_ids: Vec<usize> = Vec::new();
530            for annot in annotations {
531                let rect = format!(
532                    "[{:.2} {:.2} {:.2} {:.2}]",
533                    annot.x,
534                    annot.y,
535                    annot.x + annot.width,
536                    annot.y + annot.height
537                );
538
539                if let Some(anchor) = annot.href.strip_prefix('#') {
540                    // Internal link: find matching bookmark by title
541                    if let Some(bm) = all_bookmarks.iter().find(|b| b.title == anchor) {
542                        let annot_obj_id = builder.objects.len();
543                        // Tagged: attach this annotation to its /Link structure
544                        // element (OBJR + /StructParent) so links are tagged
545                        // (PDF/UA 7.18.5-1).
546                        let sp_str = tag_builder
547                            .as_mut()
548                            .and_then(|tb| {
549                                tb.connect_link_annotation(page_idx, &annot.href, annot_obj_id)
550                            })
551                            .map(|sp| format!(" /StructParent {}", sp))
552                            .unwrap_or_default();
553                        // PDF/UA 7.18.1-2 / 7.18.5-2: a link annotation must
554                        // carry an alternate description in its /Contents key.
555                        let contents = Self::encode_text_string(&format!("Link to {anchor}"));
556                        // ISO 14289-2 8.8: "All destinations whose target
557                        // lies within the current document shall be
558                        // structure destinations." Under UA-2 the GoTo also
559                        // carries /SD targeting the bookmark's structure
560                        // element; the placeholder object number is patched
561                        // with the real id after write_objects assigns it.
562                        let wants_sd = tag_builder
563                            .as_mut()
564                            .map(|tb| tb.request_struct_destination(anchor, annot_obj_id))
565                            .unwrap_or(false);
566                        let sd_str = if wants_sd {
567                            format!(" /SD [999999999 0 R /XYZ 0 {:.2} null]", bm.y_pdf)
568                        } else {
569                            String::new()
570                        };
571                        let annot_dict = format!(
572                            "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] \
573                             /F 4 /Contents {}{} \
574                             /A << /S /GoTo /D [{} 0 R /XYZ 0 {:.2} null]{} >> >>",
575                            rect, contents, sp_str, bm.page_obj_id, bm.y_pdf, sd_str
576                        );
577                        builder.objects.push(PdfObject {
578                            id: annot_obj_id,
579                            data: annot_dict.into_bytes(),
580                        });
581                        annot_obj_ids.push(annot_obj_id);
582                    }
583                    // No matching bookmark: skip silently
584                } else {
585                    // External link
586                    let annot_obj_id = builder.objects.len();
587                    let sp_str = tag_builder
588                        .as_mut()
589                        .and_then(|tb| {
590                            tb.connect_link_annotation(page_idx, &annot.href, annot_obj_id)
591                        })
592                        .map(|sp| format!(" /StructParent {}", sp))
593                        .unwrap_or_default();
594                    // /Contents is a text string; /URI is a 7-bit ASCII
595                    // byte string (ISO 32000-1 Table 206), so it keeps the
596                    // plain literal.
597                    let contents = Self::encode_text_string(&annot.href);
598                    let href_esc = Self::escape_pdf_string(&annot.href);
599                    let annot_dict = format!(
600                        "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] \
601                         /F 4 /Contents {}{} \
602                         /A << /Type /Action /S /URI /URI ({}) >> >>",
603                        rect, contents, sp_str, href_esc
604                    );
605                    builder.objects.push(PdfObject {
606                        id: annot_obj_id,
607                        data: annot_dict.into_bytes(),
608                    });
609                    annot_obj_ids.push(annot_obj_id);
610                }
611            }
612
613            let annots_str = if annot_obj_ids.is_empty() {
614                String::new()
615            } else {
616                let refs: String = annot_obj_ids
617                    .iter()
618                    .map(|id| format!("{} 0 R", id))
619                    .collect::<Vec<_>>()
620                    .join(" ");
621                format!(" /Annots [{}]", refs)
622            };
623
624            let page_obj_id = page_obj_ids[page_idx];
625            let content_obj_id = per_page_content_obj_ids[page_idx];
626            let struct_parents_str = if tagged {
627                format!(" /StructParents {} /Tabs /S", page_idx)
628            } else {
629                String::new()
630            };
631            let page_dict = format!(
632                "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {:.2} {:.2}] \
633                 /Contents {} 0 R /Resources << {} >>{}{} >>",
634                pages[page_idx].width,
635                pages[page_idx].height,
636                content_obj_id,
637                per_page_resources[page_idx],
638                annots_str,
639                struct_parents_str
640            );
641            builder.objects[page_obj_id].data = page_dict.into_bytes();
642        }
643
644        // Build outline tree if bookmarks exist
645        let outlines_obj_id = if !all_bookmarks.is_empty() {
646            Some(self.write_outline_tree(&mut builder, &all_bookmarks, tag_builder.as_mut()))
647        } else {
648            None
649        };
650
651        // Build structure tree for tagged PDF
652        let struct_tree_root_id = if let Some(ref tb) = tag_builder {
653            let (root_id, _parent_tree_id, sd_patches) = tb.write_objects(
654                &mut builder.objects,
655                &page_obj_ids,
656                metadata.lang.as_deref(),
657                pdf_version == crate::model::PdfVersion::V2_0,
658            );
659            // Resolve pending structure destinations: the annotation dicts
660            // carry a placeholder object number for their /SD target, since
661            // structure-element ids aren't assigned until write_objects.
662            for (annot_obj_id, elem_obj_id) in sd_patches {
663                let data = std::mem::take(&mut builder.objects[annot_obj_id].data);
664                let patched = String::from_utf8(data)
665                    .expect("annotation dicts are ASCII")
666                    .replacen("999999999 0 R", &format!("{} 0 R", elem_obj_id), 1);
667                builder.objects[annot_obj_id].data = patched.into_bytes();
668            }
669            Some(root_id)
670        } else {
671            None
672        };
673
674        // PDF/A and/or PDF/UA — and ALWAYS under PDF 2.0, where document
675        // metadata lives in XMP (the trailer /Info entries are deprecated
676        // in ISO 32000-2 and the key itself is forbidden by veraPDF's
677        // PDF/A-4 profile): write the XMP metadata stream.
678        let xmp_metadata_id =
679            if pdfa.is_some() || pdf_ua || pdf_version == crate::model::PdfVersion::V2_0 {
680                let xmp_xml = xmp::generate_xmp(metadata, pdfa, pdf_ua, pdf_ua2, zugferd);
681                let xmp_bytes = xmp_xml.as_bytes();
682                let xmp_obj_id = builder.objects.len();
683                // XMP metadata stream must NOT be compressed (PDF/A requirement)
684                let xmp_data = format!(
685                    "<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n",
686                    xmp_bytes.len()
687                );
688                let mut xmp_obj_data: Vec<u8> = xmp_data.into_bytes();
689                xmp_obj_data.extend_from_slice(xmp_bytes);
690                xmp_obj_data.extend_from_slice(b"\nendstream");
691                builder.objects.push(PdfObject {
692                    id: xmp_obj_id,
693                    data: xmp_obj_data,
694                });
695                Some(xmp_obj_id)
696            } else {
697                None
698            };
699
700        let output_intent_id = if pdfa.is_some() {
701            // Embed sRGB ICC profile
702            static SRGB_ICC: &[u8] = include_bytes!("sRGB.icc");
703            let compressed_icc = compress_to_vec_zlib(SRGB_ICC, 6);
704
705            let icc_obj_id = builder.objects.len();
706            let mut icc_data: Vec<u8> = Vec::new();
707            let _ = write!(
708                icc_data,
709                "<< /N 3 /Length {} /Filter /FlateDecode >>\nstream\n",
710                compressed_icc.len()
711            );
712            icc_data.extend_from_slice(&compressed_icc);
713            icc_data.extend_from_slice(b"\nendstream");
714            builder.objects.push(PdfObject {
715                id: icc_obj_id,
716                data: icc_data,
717            });
718
719            // OutputIntent dictionary
720            let oi_obj_id = builder.objects.len();
721            let oi_data = format!(
722                "<< /Type /OutputIntent /S /GTS_PDFA1 \
723                 /OutputConditionIdentifier (sRGB IEC61966-2.1) \
724                 /RegistryName (http://www.color.org) \
725                 /DestOutputProfile {} 0 R >>",
726                icc_obj_id
727            );
728            builder.objects.push(PdfObject {
729                id: oi_obj_id,
730                data: oi_data.into_bytes(),
731            });
732            Some(oi_obj_id)
733        } else {
734            None
735        };
736
737        // Embedded files: the legacy embeddedData JSON plus caller
738        // attachments (associated files). The legacy-only path must stay
739        // byte-identical to what it always emitted; attachments add the
740        // PDF/A-3 requirements — MIME /Subtype (6.8-1), /F + /UF (6.8-2),
741        // /AFRelationship (6.8-3) — and everything joins the catalog /AF
742        // array (6.8-4) as needed.
743        let mut name_tree_entries: Vec<(String, usize)> = Vec::new();
744        let mut af_filespec_ids: Vec<usize> = Vec::new();
745        if let Some(data) = embedded_data {
746            let compressed = compress_to_vec_zlib(data.as_bytes(), 6);
747
748            // EmbeddedFile stream
749            let ef_obj_id = builder.objects.len();
750            let ef_data = format!(
751                "<< /Type /EmbeddedFile /Subtype /application#2Fjson /Length {} /Filter /FlateDecode >>\nstream\n",
752                compressed.len()
753            );
754            let mut ef_bytes = ef_data.into_bytes();
755            ef_bytes.extend_from_slice(&compressed);
756            ef_bytes.extend_from_slice(b"\nendstream");
757            builder.objects.push(PdfObject {
758                id: ef_obj_id,
759                data: ef_bytes,
760            });
761
762            // FileSpec dictionary
763            let fs_obj_id = builder.objects.len();
764            let desc = if builder.pdf_version == crate::model::PdfVersion::V2_0 {
765                // ISO 14289-2 8.14.1 (see the attachments path below).
766                " /Desc (forme-data.json)"
767            } else {
768                ""
769            };
770            let fs_data = format!(
771                "<< /Type /Filespec /F (forme-data.json) /UF (forme-data.json) /EF << /F {} 0 R >> /AFRelationship /Data{} >>",
772                ef_obj_id, desc
773            );
774            builder.objects.push(PdfObject {
775                id: fs_obj_id,
776                data: fs_data.into_bytes(),
777            });
778            name_tree_entries.push(("forme-data.json".to_string(), fs_obj_id));
779            // Association is a PDF/A-3 requirement; the plain path keeps
780            // its historical byte-identical shape (no /AF).
781            if pdfa.is_some_and(|l| l.allows_attachments()) {
782                af_filespec_ids.push(fs_obj_id);
783            }
784        }
785        for att in attachments {
786            let bytes = Self::decode_attachment_src(&att.src)?;
787            let compressed = compress_to_vec_zlib(&bytes, 6);
788            let mime = att
789                .mime_type
790                .as_deref()
791                .unwrap_or("application/octet-stream");
792            let mod_date = att
793                .mod_date
794                .as_deref()
795                .unwrap_or(DEFAULT_ATTACHMENT_MOD_DATE);
796
797            let ef_obj_id = builder.objects.len();
798            let ef_head = format!(
799                "<< /Type /EmbeddedFile /Subtype /{} /Length {} /Filter /FlateDecode \
800                 /Params << /Size {} /ModDate ({}) >> >>\nstream\n",
801                Self::mime_to_pdf_name(mime),
802                compressed.len(),
803                bytes.len(),
804                Self::escape_pdf_string(mod_date),
805            );
806            let mut ef_bytes = ef_head.into_bytes();
807            ef_bytes.extend_from_slice(&compressed);
808            ef_bytes.extend_from_slice(b"\nendstream");
809            builder.objects.push(PdfObject {
810                id: ef_obj_id,
811                data: ef_bytes,
812            });
813
814            // The invoice XML named by zugferd gets its relationship from
815            // the profile when the caller didn't set one: MINIMUM and
816            // BASIC WL are not full invoices (spec mandates /Data); the
817            // conformant profiles use /Alternative (mandatory in DE).
818            let relationship = att.relationship.unwrap_or_else(|| {
819                if zugferd_filename.as_deref() == Some(att.name.as_str()) {
820                    match zugferd.map(|z| z.conformance_level.as_str()) {
821                        Some("MINIMUM") | Some("BASIC WL") => AfRelationship::Data,
822                        _ => AfRelationship::Alternative,
823                    }
824                } else {
825                    AfRelationship::Unspecified
826                }
827            });
828
829            let fs_obj_id = builder.objects.len();
830            let mut fs_data = format!(
831                "<< /Type /Filespec /F ({name}) /UF {uf} /EF << /F {ef} 0 R >> /AFRelationship /{rel}",
832                // /F is a byte string, /UF the text-string form of the
833                // same name (ISO 32000-1 Table 44).
834                name = Self::escape_pdf_string(&att.name),
835                uf = Self::encode_text_string(&att.name),
836                ef = ef_obj_id,
837                rel = relationship.pdf_name(),
838            );
839            if let Some(desc) = &att.description {
840                let _ = write!(fs_data, " /Desc {}", Self::encode_text_string(desc));
841            } else if builder.pdf_version == crate::model::PdfVersion::V2_0 {
842                // ISO 14289-2 8.14.1: "The Desc entry shall be present on
843                // all file specification dictionaries present in the
844                // EmbeddedFiles name tree." The file name is the honest
845                // default when the author gave no description.
846                let _ = write!(fs_data, " /Desc {}", Self::encode_text_string(&att.name));
847            }
848            fs_data.push_str(" >>");
849            builder.objects.push(PdfObject {
850                id: fs_obj_id,
851                data: fs_data.into_bytes(),
852            });
853            name_tree_entries.push((att.name.clone(), fs_obj_id));
854            af_filespec_ids.push(fs_obj_id);
855        }
856        let embedded_names_id = if name_tree_entries.is_empty() {
857            None
858        } else {
859            // Name-tree keys must be lexically sorted (PDF 32000 §7.9.6).
860            name_tree_entries.sort_by(|a, b| a.0.cmp(&b.0));
861            let names_obj_id = builder.objects.len();
862            let pairs = name_tree_entries
863                .iter()
864                .map(|(name, id)| format!("({}) {} 0 R", Self::escape_pdf_string(name), id))
865                .collect::<Vec<_>>()
866                .join(" ");
867            let names_data = format!("<< /Names [{}] >>", pairs);
868            builder.objects.push(PdfObject {
869                id: names_obj_id,
870                data: names_data.into_bytes(),
871            });
872            Some(names_obj_id)
873        };
874
875        // Build AcroForm for interactive form fields
876        let acroform_obj_id = if !all_form_fields.is_empty() && !flatten_forms {
877            // Find the Helvetica font object ID for AcroForm /DR
878            let helv_obj_id = builder
879                .font_objects
880                .iter()
881                .find(|(key, _)| key.family == "Helvetica" && key.weight == 400 && !key.italic)
882                .map(|(_, id)| *id);
883
884            // Separate radio buttons from other fields
885            let mut radio_groups: HashMap<String, Vec<usize>> = HashMap::new(); // name -> indices
886            let mut non_radio_indices: Vec<usize> = Vec::new();
887            for (i, field) in all_form_fields.iter().enumerate() {
888                if matches!(field.field_type, FormFieldType::RadioButton { .. }) {
889                    radio_groups.entry(field.name.clone()).or_default().push(i);
890                } else {
891                    non_radio_indices.push(i);
892                }
893            }
894
895            // Pre-allocate parent field objects for radio groups
896            let mut radio_parent_ids: HashMap<String, usize> = HashMap::new();
897            for group_name in radio_groups.keys() {
898                let parent_id = builder.objects.len();
899                builder.objects.push(PdfObject {
900                    id: parent_id,
901                    data: vec![], // placeholder — filled after kids are created
902                });
903                radio_parent_ids.insert(group_name.clone(), parent_id);
904            }
905
906            // Create appearance streams for checkboxes and radio buttons
907            // Checkbox checked: checkmark
908            let checkbox_yes_stream_id = builder.objects.len();
909            {
910                let stream_content =
911                    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";
912                let mut data: Vec<u8> = Vec::new();
913                let _ = write!(
914                    data,
915                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
916                    stream_content.len()
917                );
918                data.extend_from_slice(stream_content);
919                data.extend_from_slice(b"\nendstream");
920                builder.objects.push(PdfObject {
921                    id: checkbox_yes_stream_id,
922                    data,
923                });
924            }
925            // Checkbox unchecked: empty
926            let checkbox_off_stream_id = builder.objects.len();
927            {
928                let stream_content = b"";
929                let mut data: Vec<u8> = Vec::new();
930                let _ = write!(
931                    data,
932                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
933                    stream_content.len()
934                );
935                data.extend_from_slice(stream_content);
936                data.extend_from_slice(b"\nendstream");
937                builder.objects.push(PdfObject {
938                    id: checkbox_off_stream_id,
939                    data,
940                });
941            }
942            // Radio selected: filled circle (bezier approximation)
943            let radio_on_stream_id = builder.objects.len();
944            {
945                // Circle centered at (7,7) radius 5 using 4-segment bezier
946                let k = 2.761; // 5 * 0.5523 (magic number for circle approximation)
947                let stream_content = format!(
948                    "0.2 0.2 0.2 rg\n\
949                     7 12 m {:.2} 12 12 {:.2} 12 7 c\n\
950                     12 {:.2} {:.2} 2 7 2 c\n\
951                     {:.2} 2 2 {:.2} 2 7 c\n\
952                     2 {:.2} {:.2} 12 7 12 c f\n",
953                    7.0 + k,
954                    7.0 + k, // top-right
955                    7.0 - k,
956                    7.0 - k, // bottom-right
957                    7.0 - k,
958                    7.0 - k, // bottom-left
959                    7.0 + k,
960                    7.0 + k, // top-left
961                );
962                let stream_bytes = stream_content.as_bytes();
963                let mut data: Vec<u8> = Vec::new();
964                let _ = write!(
965                    data,
966                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
967                    stream_bytes.len()
968                );
969                data.extend_from_slice(stream_bytes);
970                data.extend_from_slice(b"\nendstream");
971                builder.objects.push(PdfObject {
972                    id: radio_on_stream_id,
973                    data,
974                });
975            }
976            // Radio unselected: empty
977            let radio_off_stream_id = builder.objects.len();
978            {
979                let stream_content = b"";
980                let mut data: Vec<u8> = Vec::new();
981                let _ = write!(
982                    data,
983                    "<< /Type /XObject /Subtype /Form /BBox [0 0 14 14] /Length {} >>\nstream\n",
984                    stream_content.len()
985                );
986                data.extend_from_slice(stream_content);
987                data.extend_from_slice(b"\nendstream");
988                builder.objects.push(PdfObject {
989                    id: radio_off_stream_id,
990                    data,
991                });
992            }
993
994            // Create widget annotation objects per page
995            let mut acroform_field_ids: Vec<usize> = Vec::new();
996            let mut per_page_widget_ids: Vec<Vec<usize>> = vec![Vec::new(); pages.len()];
997            let mut radio_kid_ids: HashMap<String, Vec<usize>> = HashMap::new();
998
999            for field in all_form_fields.iter() {
1000                let rect = format!(
1001                    "[{:.2} {:.2} {:.2} {:.2}]",
1002                    field.x,
1003                    field.y,
1004                    field.x + field.width,
1005                    field.y + field.height
1006                );
1007                let page_ref = format!("{} 0 R", page_obj_ids[field.page_idx]);
1008
1009                match &field.field_type {
1010                    FormFieldType::TextField {
1011                        value,
1012                        multiline,
1013                        password,
1014                        read_only,
1015                        max_length,
1016                        font_size,
1017                        ..
1018                    } => {
1019                        let mut flags: u32 = 0;
1020                        if *multiline {
1021                            flags |= 1 << 12; // bit 13 (0-indexed bit 12)
1022                        }
1023                        if *password {
1024                            flags |= 1 << 13; // bit 14
1025                        }
1026                        if *read_only {
1027                            flags |= 1; // bit 1
1028                        }
1029                        let da = if let Some(helv_id) = helv_obj_id {
1030                            let _ = helv_id; // used in /DR, not /DA
1031                            format!("/Helv {} Tf 0 g", font_size)
1032                        } else {
1033                            format!("/Helv {} Tf 0 g", font_size)
1034                        };
1035                        let v_str = if let Some(ref v) = value {
1036                            format!(
1037                                " /V {} /DV {}",
1038                                Self::encode_text_string(v),
1039                                Self::encode_text_string(v)
1040                            )
1041                        } else {
1042                            String::new()
1043                        };
1044                        let max_len_str = if let Some(ml) = max_length {
1045                            format!(" /MaxLen {}", ml)
1046                        } else {
1047                            String::new()
1048                        };
1049                        // Build appearance stream for the text field
1050                        let ap_w = field.width;
1051                        let ap_h = field.height;
1052                        let text_y = if *multiline {
1053                            ap_h - *font_size - 2.0
1054                        } else {
1055                            (ap_h - *font_size) / 2.0
1056                        };
1057                        let ap_content = if let Some(ref v) = value {
1058                            format!(
1059                                "1 1 1 rg 0 0 {} {} re f \
1060                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S \
1061                                 BT /Helv {} Tf 0 g 2 {} Td ({}) Tj ET",
1062                                ap_w,
1063                                ap_h,
1064                                ap_w,
1065                                ap_h,
1066                                font_size,
1067                                text_y,
1068                                Self::escape_pdf_string(v)
1069                            )
1070                        } else {
1071                            format!(
1072                                "1 1 1 rg 0 0 {} {} re f \
1073                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S",
1074                                ap_w, ap_h, ap_w, ap_h
1075                            )
1076                        };
1077                        let ap_stream_id = builder.objects.len();
1078                        let ap_stream = format!(
1079                            "<< /Type /XObject /Subtype /Form /BBox [0 0 {} {}] \
1080                             /Resources << /Font << /Helv {} 0 R >> >> /Length {} >>\nstream\n{}\nendstream",
1081                            ap_w, ap_h,
1082                            helv_obj_id.unwrap_or(0),
1083                            ap_content.len(),
1084                            ap_content
1085                        );
1086                        builder.objects.push(PdfObject {
1087                            id: ap_stream_id,
1088                            data: ap_stream.into_bytes(),
1089                        });
1090
1091                        let widget_obj_id = builder.objects.len();
1092                        let widget_dict = format!(
1093                            "<< /Type /Annot /Subtype /Widget /FT /Tx \
1094                             /T {} /Rect {} /P {}\
1095                             {} /DA ({}) /Ff {}{} \
1096                             /MK << /BC [0.6 0.6 0.6] /BG [1 1 1] >> \
1097                             /AP << /N {} 0 R >> >>",
1098                            Self::encode_text_string(&field.name),
1099                            rect,
1100                            page_ref,
1101                            v_str,
1102                            da,
1103                            flags,
1104                            max_len_str,
1105                            ap_stream_id
1106                        );
1107                        builder.objects.push(PdfObject {
1108                            id: widget_obj_id,
1109                            data: widget_dict.into_bytes(),
1110                        });
1111                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1112                        acroform_field_ids.push(widget_obj_id);
1113                    }
1114
1115                    FormFieldType::Checkbox {
1116                        checked, read_only, ..
1117                    } => {
1118                        let state = if *checked { "Yes" } else { "Off" };
1119                        let mut flags: u32 = 0;
1120                        if *read_only {
1121                            flags |= 1;
1122                        }
1123                        let ff_str = if flags > 0 {
1124                            format!(" /Ff {}", flags)
1125                        } else {
1126                            String::new()
1127                        };
1128                        let widget_obj_id = builder.objects.len();
1129                        let widget_dict = format!(
1130                            "<< /Type /Annot /Subtype /Widget /FT /Btn \
1131                             /T {} /Rect {} /P {} \
1132                             /V /{} /AS /{}{} \
1133                             /MK << /BC [0.6 0.6 0.6] /CA (4) >> \
1134                             /AP << /N << /Yes {} 0 R /Off {} 0 R >> >> >>",
1135                            Self::encode_text_string(&field.name),
1136                            rect,
1137                            page_ref,
1138                            state,
1139                            state,
1140                            ff_str,
1141                            checkbox_yes_stream_id,
1142                            checkbox_off_stream_id,
1143                        );
1144                        builder.objects.push(PdfObject {
1145                            id: widget_obj_id,
1146                            data: widget_dict.into_bytes(),
1147                        });
1148                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1149                        acroform_field_ids.push(widget_obj_id);
1150                    }
1151
1152                    FormFieldType::Dropdown {
1153                        options,
1154                        value,
1155                        read_only,
1156                        font_size,
1157                        ..
1158                    } => {
1159                        let mut flags: u32 = 1 << 17; // bit 18 = combo box
1160                        if *read_only {
1161                            flags |= 1;
1162                        }
1163                        let opts_str: String = options
1164                            .iter()
1165                            .map(|o| Self::encode_text_string(o))
1166                            .collect::<Vec<_>>()
1167                            .join(" ");
1168                        let v_str = if let Some(ref v) = value {
1169                            format!(" /V {}", Self::encode_text_string(v))
1170                        } else {
1171                            String::new()
1172                        };
1173                        // Build appearance stream for the dropdown
1174                        let ap_w = field.width;
1175                        let ap_h = field.height;
1176                        let text_y = (ap_h - *font_size) / 2.0;
1177                        let ap_content = if let Some(ref v) = value {
1178                            format!(
1179                                "1 1 1 rg 0 0 {} {} re f \
1180                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S \
1181                                 BT /Helv {} Tf 0 g 2 {} Td ({}) Tj ET",
1182                                ap_w,
1183                                ap_h,
1184                                ap_w,
1185                                ap_h,
1186                                font_size,
1187                                text_y,
1188                                Self::escape_pdf_string(v)
1189                            )
1190                        } else {
1191                            format!(
1192                                "1 1 1 rg 0 0 {} {} re f \
1193                                 0.6 0.6 0.6 RG 0.5 w 0 0 {} {} re S",
1194                                ap_w, ap_h, ap_w, ap_h
1195                            )
1196                        };
1197                        let ap_stream_id = builder.objects.len();
1198                        let ap_stream = format!(
1199                            "<< /Type /XObject /Subtype /Form /BBox [0 0 {} {}] \
1200                             /Resources << /Font << /Helv {} 0 R >> >> /Length {} >>\nstream\n{}\nendstream",
1201                            ap_w, ap_h,
1202                            helv_obj_id.unwrap_or(0),
1203                            ap_content.len(),
1204                            ap_content
1205                        );
1206                        builder.objects.push(PdfObject {
1207                            id: ap_stream_id,
1208                            data: ap_stream.into_bytes(),
1209                        });
1210
1211                        let widget_obj_id = builder.objects.len();
1212                        let widget_dict = format!(
1213                            "<< /Type /Annot /Subtype /Widget /FT /Ch \
1214                             /T {} /Rect {} /P {} \
1215                             /Opt [{}]{} \
1216                             /DA (/Helv {} Tf 0 g) /Ff {} \
1217                             /MK << /BC [0.6 0.6 0.6] /BG [1 1 1] >> \
1218                             /AP << /N {} 0 R >> >>",
1219                            Self::encode_text_string(&field.name),
1220                            rect,
1221                            page_ref,
1222                            opts_str,
1223                            v_str,
1224                            font_size,
1225                            flags,
1226                            ap_stream_id
1227                        );
1228                        builder.objects.push(PdfObject {
1229                            id: widget_obj_id,
1230                            data: widget_dict.into_bytes(),
1231                        });
1232                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1233                        acroform_field_ids.push(widget_obj_id);
1234                    }
1235
1236                    FormFieldType::RadioButton {
1237                        value,
1238                        checked,
1239                        read_only: _,
1240                    } => {
1241                        // Radio kid widget — parent reference is critical
1242                        let parent_id = radio_parent_ids[&field.name];
1243                        let as_value = if *checked { value.as_str() } else { "Off" };
1244                        let widget_obj_id = builder.objects.len();
1245                        let widget_dict = format!(
1246                            "<< /Type /Annot /Subtype /Widget \
1247                             /Parent {} 0 R \
1248                             /Rect {} /P {} \
1249                             /AS /{} \
1250                             /AP << /N << /{} {} 0 R /Off {} 0 R >> >> \
1251                             /MK << /BC [0.6 0.6 0.6] >> >>",
1252                            parent_id,
1253                            rect,
1254                            page_ref,
1255                            Self::escape_pdf_string(as_value),
1256                            Self::escape_pdf_string(value),
1257                            radio_on_stream_id,
1258                            radio_off_stream_id,
1259                        );
1260                        builder.objects.push(PdfObject {
1261                            id: widget_obj_id,
1262                            data: widget_dict.into_bytes(),
1263                        });
1264                        per_page_widget_ids[field.page_idx].push(widget_obj_id);
1265                        // Kids go in page /Annots, NOT in /AcroForm /Fields
1266                        radio_kid_ids
1267                            .entry(field.name.clone())
1268                            .or_default()
1269                            .push(widget_obj_id);
1270                    }
1271                }
1272            }
1273
1274            // Fill in radio parent field objects
1275            for (group_name, kid_indices) in &radio_kid_ids {
1276                let parent_id = radio_parent_ids[group_name];
1277                // Find the checked value in this group
1278                let checked_value = all_form_fields
1279                    .iter()
1280                    .filter(|f| f.name == *group_name)
1281                    .find_map(|f| {
1282                        if let FormFieldType::RadioButton {
1283                            ref value, checked, ..
1284                        } = f.field_type
1285                        {
1286                            if checked {
1287                                Some(value.clone())
1288                            } else {
1289                                None
1290                            }
1291                        } else {
1292                            None
1293                        }
1294                    })
1295                    .unwrap_or_else(|| "Off".to_string());
1296
1297                let kids_refs: String = kid_indices
1298                    .iter()
1299                    .map(|id| format!("{} 0 R", id))
1300                    .collect::<Vec<_>>()
1301                    .join(" ");
1302
1303                let mut flags: u32 = (1 << 14) | (1 << 15); // radio + noToggleToOff
1304                                                            // Check if read_only on any button in group
1305                let is_read_only = all_form_fields
1306                    .iter()
1307                    .filter(|f| f.name == *group_name)
1308                    .any(|f| {
1309                        matches!(
1310                            f.field_type,
1311                            FormFieldType::RadioButton {
1312                                read_only: true,
1313                                ..
1314                            }
1315                        )
1316                    });
1317                if is_read_only {
1318                    flags |= 1;
1319                }
1320
1321                let parent_dict = format!(
1322                    "<< /FT /Btn /T {} /Ff {} /Kids [{}] /V /{} >>",
1323                    Self::encode_text_string(group_name),
1324                    flags,
1325                    kids_refs,
1326                    Self::escape_pdf_string(&checked_value),
1327                );
1328                builder.objects[parent_id].data = parent_dict.into_bytes();
1329                acroform_field_ids.push(parent_id);
1330            }
1331
1332            // Now add form widget IDs to the existing page annotation arrays
1333            // We need to update the already-written page dicts to include form widgets
1334            // Rebuild page dicts with form widget annotations included
1335            for (page_idx, widget_ids) in per_page_widget_ids.iter().enumerate() {
1336                if widget_ids.is_empty() {
1337                    continue;
1338                }
1339                let page_obj_id = page_obj_ids[page_idx];
1340                let existing_page_data =
1341                    String::from_utf8_lossy(&builder.objects[page_obj_id].data).to_string();
1342
1343                // If the page already has /Annots, append to it; otherwise add it
1344                let new_refs: String = widget_ids
1345                    .iter()
1346                    .map(|id| format!("{} 0 R", id))
1347                    .collect::<Vec<_>>()
1348                    .join(" ");
1349
1350                let updated = if let Some(pos) = existing_page_data.find("/Annots [") {
1351                    // Insert before the closing ]
1352                    let bracket_end = existing_page_data[pos..].find(']').unwrap() + pos;
1353                    format!(
1354                        "{} {}{}",
1355                        &existing_page_data[..bracket_end],
1356                        new_refs,
1357                        &existing_page_data[bracket_end..]
1358                    )
1359                } else {
1360                    // Add /Annots before the final >>
1361                    let end = existing_page_data.rfind(">>").unwrap();
1362                    format!(
1363                        "{} /Annots [{}]{}",
1364                        &existing_page_data[..end],
1365                        new_refs,
1366                        &existing_page_data[end..]
1367                    )
1368                };
1369                builder.objects[page_obj_id].data = updated.into_bytes();
1370            }
1371
1372            // Create AcroForm dictionary
1373            let acroform_id = builder.objects.len();
1374            let fields_refs: String = acroform_field_ids
1375                .iter()
1376                .map(|id| format!("{} 0 R", id))
1377                .collect::<Vec<_>>()
1378                .join(" ");
1379            let dr_str = if let Some(helv_id) = helv_obj_id {
1380                format!(" /DR << /Font << /Helv {} 0 R >> >>", helv_id)
1381            } else {
1382                String::new()
1383            };
1384            // No /NeedAppearances: we build a full appearance stream for
1385            // every widget (/AP /N on each), and the flag — deprecated in
1386            // PDF 2.0 — told viewers to DISCARD them and regenerate. With
1387            // it gone, viewers render the appearances we authored, which
1388            // is what every headless renderer (poppler, pdfium, pdfjs)
1389            // did anyway.
1390            let acroform_dict = format!(
1391                "<< /Fields [{}]{} /DA (/Helv 0 Tf 0 g) >>",
1392                fields_refs, dr_str
1393            );
1394            builder.objects.push(PdfObject {
1395                id: acroform_id,
1396                data: acroform_dict.into_bytes(),
1397            });
1398            Some(acroform_id)
1399        } else {
1400            None
1401        };
1402
1403        // Write Catalog (object 1)
1404        let mut catalog = String::from("<< /Type /Catalog /Pages 2 0 R");
1405        if let Some(acroform_id) = acroform_obj_id {
1406            write!(catalog, " /AcroForm {} 0 R", acroform_id).unwrap();
1407        }
1408        if let Some(outlines_id) = outlines_obj_id {
1409            write!(
1410                catalog,
1411                " /Outlines {} 0 R /PageMode /UseOutlines",
1412                outlines_id
1413            )
1414            .unwrap();
1415        }
1416        if let Some(ref lang) = metadata.lang {
1417            write!(catalog, " /Lang ({})", Self::escape_pdf_string(lang)).unwrap();
1418        }
1419        if let Some(struct_root_id) = struct_tree_root_id {
1420            write!(
1421                catalog,
1422                " /MarkInfo << /Marked true >> /StructTreeRoot {} 0 R",
1423                struct_root_id
1424            )
1425            .unwrap();
1426        }
1427        if let Some(xmp_id) = xmp_metadata_id {
1428            write!(catalog, " /Metadata {} 0 R", xmp_id).unwrap();
1429        }
1430        if let Some(oi_id) = output_intent_id {
1431            write!(catalog, " /OutputIntents [{} 0 R]", oi_id).unwrap();
1432        }
1433        if let Some(names_id) = embedded_names_id {
1434            write!(catalog, " /Names << /EmbeddedFiles {} 0 R >>", names_id).unwrap();
1435        }
1436        if !af_filespec_ids.is_empty() {
1437            // Document-level association (PDF/A-3 6.8-4; Factur-X requires
1438            // the invoice XML to be associated at the catalog).
1439            let refs = af_filespec_ids
1440                .iter()
1441                .map(|id| format!("{} 0 R", id))
1442                .collect::<Vec<_>>()
1443                .join(" ");
1444            write!(catalog, " /AF [{}]", refs).unwrap();
1445        }
1446        if pdf_ua || pdf_ua2 {
1447            catalog.push_str(" /ViewerPreferences << /DisplayDocTitle true >>");
1448        }
1449        catalog.push_str(" >>");
1450        builder.objects[1].data = catalog.into_bytes();
1451
1452        // Write Pages tree (object 2)
1453        let kids: String = page_obj_ids
1454            .iter()
1455            .map(|id| format!("{} 0 R", id))
1456            .collect::<Vec<_>>()
1457            .join(" ");
1458        builder.objects[2].data = format!(
1459            "<< /Type /Pages /Kids [{}] /Count {} >>",
1460            kids,
1461            page_obj_ids.len()
1462        )
1463        .into_bytes();
1464
1465        // Info dictionary (metadata)
1466        // No trailer /Info under PDF 2.0: its entries are deprecated in
1467        // ISO 32000-2 and veraPDF's PDF/A-4 profile forbids the key
1468        // ("The Info key shall not be present in the trailer dictionary …
1469        // unless there exists a PieceInfo entry", which we never emit).
1470        // Document metadata lives in the XMP stream, emitted above
1471        // unconditionally for 2.0.
1472        let info_obj_id = if pdf_version == crate::model::PdfVersion::V1_7
1473            && (metadata.title.is_some() || metadata.author.is_some() || metadata.subject.is_some())
1474        {
1475            let id = builder.objects.len();
1476            let mut info = String::from("<< ");
1477            if let Some(ref title) = metadata.title {
1478                let _ = write!(info, "/Title {} ", Self::encode_text_string(title));
1479            }
1480            if let Some(ref author) = metadata.author {
1481                let _ = write!(info, "/Author {} ", Self::encode_text_string(author));
1482            }
1483            if let Some(ref subject) = metadata.subject {
1484                let _ = write!(info, "/Subject {} ", Self::encode_text_string(subject));
1485            }
1486            let _ = write!(
1487                info,
1488                "/Producer {} /Creator {} >>",
1489                Self::encode_text_string(PRODUCER),
1490                Self::encode_text_string(CREATOR_TOOL)
1491            );
1492            builder.objects.push(PdfObject {
1493                id,
1494                data: info.into_bytes(),
1495            });
1496            Some(id)
1497        } else {
1498            None
1499        };
1500
1501        let pdf = self.serialize(&builder, info_obj_id);
1502        // One warning per distinct substituted character (BTreeSet order is
1503        // deterministic). Page sentinels never reach the encoders, and a
1504        // literal "?" maps through WinAnsi — only genuinely uncovered
1505        // characters land here.
1506        let mut warnings = builder.warnings;
1507        for ch in builder.missing_glyphs.into_inner() {
1508            warnings.push(format!(
1509                "render defect: \"{ch}\" (U+{:04X}) is not covered by any available font and was rendered as \"?\" — register a font containing it (Font.register, the Document fonts prop, or @font-face on the HTML path)",
1510                ch as u32
1511            ));
1512        }
1513        Ok((pdf, warnings))
1514    }
1515
1516    /// Build the PDF content stream for a single page.
1517    #[allow(clippy::too_many_arguments)]
1518    fn build_content_stream_for_page(
1519        &self,
1520        page: &LayoutPage,
1521        page_idx: usize,
1522        builder: &PdfBuilder,
1523        page_number: usize,
1524        total_pages: usize,
1525        mut tag_builder: Option<&mut tagged::TagBuilder>,
1526        flatten_forms: bool,
1527    ) -> String {
1528        let mut stream = String::new();
1529        let page_height = page.height;
1530        let mut element_counter = 0usize;
1531        let mut gradient_counter = 0usize;
1532
1533        // Page background image: paint it before any element content so
1534        // it sits behind everything. Wrapped in q/Q + ExtGState for
1535        // backgroundOpacity, with the cm matrix sized & positioned via
1536        // backgroundSize / backgroundPosition. Same XObject can be reused
1537        // across multiple pages with the same source URL.
1538        if let Some(&img_idx) = builder.page_background_image_map.get(&page_idx) {
1539            self.write_page_background(&mut stream, page, img_idx, builder);
1540        }
1541
1542        // Horizontal content clip (`PageConfig.clip_content_x`): the paged
1543        // equivalent of `body { overflow-x: hidden }`. X is clipped to the
1544        // content box; Y spans the full page so nothing vertical is lost.
1545        let clip_x = page.config.clip_content_x;
1546        if clip_x {
1547            let x = page.config.margin.left;
1548            let w = page.width - page.config.margin.left - page.config.margin.right;
1549            stream.push_str(&format!(
1550                "q\n{:.2} 0 {:.2} {:.2} re W n\n",
1551                x, w, page.height
1552            ));
1553        }
1554
1555        for element in &page.elements {
1556            self.write_element(
1557                &mut stream,
1558                element,
1559                page_height,
1560                builder,
1561                page_idx,
1562                &mut element_counter,
1563                &mut gradient_counter,
1564                page_number,
1565                total_pages,
1566                tag_builder.as_deref_mut(),
1567                flatten_forms,
1568            );
1569        }
1570
1571        if clip_x {
1572            stream.push_str("Q\n");
1573        }
1574
1575        stream
1576    }
1577
1578    /// Write a single layout element as PDF operators.
1579    #[allow(clippy::too_many_arguments)]
1580    #[allow(clippy::too_many_arguments)]
1581    fn write_element(
1582        &self,
1583        stream: &mut String,
1584        element: &LayoutElement,
1585        page_height: f64,
1586        builder: &PdfBuilder,
1587        page_idx: usize,
1588        element_counter: &mut usize,
1589        gradient_counter: &mut usize,
1590        page_number: usize,
1591        total_pages: usize,
1592        mut tag_builder: Option<&mut tagged::TagBuilder>,
1593        flatten_forms: bool,
1594    ) {
1595        // Tagged PDF: emit BDC (begin marked content) for elements with a node_type,
1596        // or /Artifact BMC for decorative elements (watermarks, untagged drawing).
1597        let mut is_artifact = false;
1598        // PDF/UA-2: a structure element was opened but got no MCID — its
1599        // role forbids content items (ISO 32005 containment matrix), so its
1600        // own ink (borders, row backgrounds) must be marked /Artifact and
1601        // only its children carry tagged content.
1602        let mut artifact_own_draw = false;
1603        // Inline /Link elements created for this element's text, in drawing
1604        // order, and the role its marked content opened with: the text writer
1605        // closes that content around each link's words and reopens it after.
1606        let mut inline_links: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
1607        let mut bdc_role: Option<&'static str> = None;
1608        let tagged_mcid = if let Some(ref mut tb) = tag_builder {
1609            if let Some(ref nt) = element.node_type {
1610                if nt == "Watermark" {
1611                    // Watermarks are decorative — mark as artifact, not structure
1612                    let _ = writeln!(stream, "/Artifact BMC");
1613                    is_artifact = true;
1614                    None
1615                } else {
1616                    let is_header = element.is_header_row;
1617                    let href = element.href.as_deref();
1618                    // A wrapper's content is all in its children: its own draw
1619                    // is nothing, or a box whose ink is marked /Artifact below.
1620                    // It gets no MCID, so no child's sequence nests in its.
1621                    let wrapper = !element.children.is_empty()
1622                        && matches!(element.draw, DrawCommand::None | DrawCommand::Rect { .. });
1623                    let mcid = tb.begin_element_as(
1624                        nt,
1625                        is_header,
1626                        element.alt.as_deref(),
1627                        page_idx,
1628                        href,
1629                        element.col_span,
1630                        element.list_numbering,
1631                        element.actual_text.as_deref(),
1632                        wrapper,
1633                    );
1634                    // Register bookmark anchors against the element just
1635                    // opened, so internal links can target it with a
1636                    // structure destination under UA-2 (ISO 14289-2 8.8).
1637                    if let Some(ref bm) = element.bookmark {
1638                        tb.note_bookmark(bm);
1639                    }
1640                    // Inline links (a linked run inside a paragraph) get a
1641                    // /Link structure element each, under this line's
1642                    // element, so their annotations can attach to it like
1643                    // element-level links do. Same gate as
1644                    // `collect_link_annotations`: only when neither this
1645                    // element nor an ancestor carries an href, since those
1646                    // annotations already cover the whole box.
1647                    if href.is_none() && !tb.inside_link() {
1648                        if let DrawCommand::Text { ref lines, .. } = element.draw {
1649                            for line in lines {
1650                                for span in Self::inline_link_spans(line) {
1651                                    inline_links
1652                                        .push_back(tb.add_inline_link(page_idx, &span.href));
1653                                }
1654                            }
1655                        }
1656                    }
1657                    match mcid {
1658                        Some(mcid) => {
1659                            // An href'd element tags as /Link (see begin_element); the
1660                            // BDC role must match the structure role, so key on href too.
1661                            let role = if href.is_some() {
1662                                "Link"
1663                            } else {
1664                                tb.map_role_public(nt, is_header)
1665                            };
1666                            let _ = writeln!(stream, "/{} <</MCID {}>> BDC", role, mcid);
1667                            bdc_role = Some(role);
1668                            Some(mcid)
1669                        }
1670                        None => {
1671                            artifact_own_draw = true;
1672                            None
1673                        }
1674                    }
1675                }
1676            } else if !matches!(element.draw, DrawCommand::None) {
1677                // No node_type but has drawing — wrap as artifact
1678                let _ = writeln!(stream, "/Artifact BMC");
1679                is_artifact = true;
1680                None
1681            } else {
1682                None
1683            }
1684        } else {
1685            None
1686        };
1687
1688        // Element-level opacity wrap. Open `q\n/GS{n} gs` AFTER the BMC/BDC
1689        // marker block (so opacity affects content, not the marker), and
1690        // close the matching `Q` BEFORE the EMC. The wrap encompasses both
1691        // the element's own DrawCommand emission AND the recursion into
1692        // `element.children`, so descendants render at the cumulative
1693        // alpha (PDF graphics state stack multiplies naturally — a 0.5
1694        // child of a 0.5 parent renders at effective 0.25).
1695        let needs_element_opacity = element.opacity < 1.0;
1696        if needs_element_opacity {
1697            if let Some((_, gs_name)) = builder.ext_gstate_map.get(&element.opacity.to_bits()) {
1698                let _ = writeln!(stream, "q\n/{} gs", gs_name);
1699            }
1700        }
1701
1702        // CSS-style `transform` wrap. Sits INSIDE the opacity wrap so the
1703        // opacity applies to the transformed output. Layout flow is NOT
1704        // affected by the transform (matches CSS) — element.x/y/width/height
1705        // are still the axis-aligned box; the transform is paint-only and
1706        // also propagates to children via the graphics state stack.
1707        let transform_ops: &[TransformOp] = element
1708            .resolved_style
1709            .as_ref()
1710            .map(|s| s.transform.as_slice())
1711            .unwrap_or(&[]);
1712        let has_transform = !transform_ops.is_empty();
1713        if has_transform {
1714            let rs = element.resolved_style.as_ref().unwrap();
1715            let pdf_x = element.x;
1716            let pdf_y_bottom = page_height - element.y - element.height;
1717            let (ox_frac, oy_frac) = rs.transform_origin;
1718            let origin_x = pdf_x + element.width * ox_frac;
1719            // transform_origin's y is 0=top / 1=bottom in layout (CSS) space.
1720            // Flip for PDF (1=top / 0=bottom).
1721            let origin_y = pdf_y_bottom + (1.0 - oy_frac) * element.height;
1722
1723            let _ = writeln!(stream, "q");
1724            // Shift origin point to PDF (0,0) so subsequent transforms pivot there.
1725            let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", -origin_x, -origin_y);
1726            // User transforms: emit in REVERSE of the CSS list order. CSS lists
1727            // transforms left-to-right with the LAST one applied first
1728            // (closest to the point being drawn). PDF `cm` left-multiplies the
1729            // CTM, so the FIRST emitted cm becomes the innermost. Reversing
1730            // makes the leftmost CSS transform the last cm emitted = outermost
1731            // multiplication = applied last to a point — which matches "first
1732            // listed wraps everything inside it" semantics.
1733            for op in transform_ops.iter().rev() {
1734                match op {
1735                    TransformOp::Rotate { deg } => {
1736                        // CSS rotates clockwise in screen space. With PDF's
1737                        // flipped y-axis, the same matrix would rotate
1738                        // counter-clockwise visually. Negate the angle so a
1739                        // CSS `rotate(45deg)` looks identical in the PDF.
1740                        let theta = (-deg).to_radians();
1741                        let c = theta.cos();
1742                        let s = theta.sin();
1743                        let _ = writeln!(stream, "{:.6} {:.6} {:.6} {:.6} 0 0 cm", c, s, -s, c);
1744                    }
1745                    TransformOp::Scale { x, y } => {
1746                        let _ = writeln!(stream, "{:.6} 0 0 {:.6} 0 0 cm", x, y);
1747                    }
1748                    TransformOp::Translate { x, y } => {
1749                        // CSS y is down, PDF y is up — negate the y component.
1750                        let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", x, -y);
1751                    }
1752                }
1753            }
1754            // Shift origin back to its real position.
1755            let _ = writeln!(stream, "1 0 0 1 {:.4} {:.4} cm", origin_x, origin_y);
1756        }
1757
1758        // PDF/UA-2: the element's own ink (a grouping element's borders or
1759        // background) is decoration under ISO 32005 — mark it /Artifact.
1760        // Children recurse OUTSIDE this bracket (below), so their tagged
1761        // content is never nested inside the artifact. Only the Rect and
1762        // None arms are reachable with the flag set: every graphics arm
1763        // maps to /Figure under UA-2 and takes the MCID path instead.
1764        let wrap_own_draw_as_artifact =
1765            artifact_own_draw && !matches!(element.draw, DrawCommand::None);
1766        if wrap_own_draw_as_artifact {
1767            let _ = writeln!(stream, "/Artifact BMC");
1768        }
1769
1770        match &element.draw {
1771            DrawCommand::None => {}
1772
1773            DrawCommand::Rect {
1774                background,
1775                border_width,
1776                border_color,
1777                border_style,
1778                border_radius,
1779                opacity,
1780                box_shadow,
1781                background_gradient,
1782            } => {
1783                let x = element.x;
1784                let y = page_height - element.y - element.height;
1785                let w = element.width;
1786                let h = element.height;
1787
1788                // Apply opacity via ExtGState
1789                let needs_opacity = *opacity < 1.0;
1790                if needs_opacity {
1791                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1792                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
1793                    }
1794                }
1795
1796                // Box shadow: paint a filled rect offset by (offsetX, offsetY)
1797                // BEFORE the background so the shadow sits behind. Shadow
1798                // color alpha goes through the per-shadow ExtGState. Shadow
1799                // path uses the same border_radius as the element so rounded
1800                // boxes get rounded shadows.
1801                if let Some(shadow) = box_shadow {
1802                    if shadow.color.a > 0.0 {
1803                        // PDF y-axis is flipped vs CSS, so a positive
1804                        // offsetY (CSS: shadow goes down) → subtract from
1805                        // pdf_y to move the shadow rect downward in
1806                        // visual terms.
1807                        let sx = x + shadow.offset_x;
1808                        let sy = y - shadow.offset_y;
1809                        let needs_shadow_alpha = shadow.color.a < 1.0;
1810                        if needs_shadow_alpha {
1811                            if let Some((_, gs_name)) =
1812                                builder.ext_gstate_map.get(&shadow.color.a.to_bits())
1813                            {
1814                                let _ = writeln!(stream, "q\n/{} gs", gs_name);
1815                            } else {
1816                                let _ = writeln!(stream, "q");
1817                            }
1818                        } else {
1819                            let _ = writeln!(stream, "q");
1820                        }
1821                        let _ = writeln!(
1822                            stream,
1823                            "{:.3} {:.3} {:.3} rg",
1824                            shadow.color.r, shadow.color.g, shadow.color.b
1825                        );
1826                        if border_radius.top_left > 0.0
1827                            || border_radius.top_right > 0.0
1828                            || border_radius.bottom_right > 0.0
1829                            || border_radius.bottom_left > 0.0
1830                        {
1831                            self.write_rounded_rect(stream, sx, sy, w, h, border_radius);
1832                        } else {
1833                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", sx, sy, w, h);
1834                        }
1835                        let _ = writeln!(stream, "f\nQ");
1836                    }
1837                }
1838
1839                // Background paint: gradient takes precedence over the
1840                // solid color when both are set. Gradient emission uses
1841                // `q + clip path + cm + sh + Q`; the cm translate moves
1842                // the shading's local 0,0 to the rect's bottom-left so
1843                // the Coords (computed during register_shadings) line up.
1844                if background_gradient.is_some() {
1845                    let key = (page_idx, *gradient_counter);
1846                    *gradient_counter += 1;
1847                    if let Some((_, sh_name)) = builder.shading_map.get(&key) {
1848                        let _ = writeln!(stream, "q");
1849                        // Clip to the rect (rounded if borderRadius set).
1850                        if border_radius.top_left > 0.0
1851                            || border_radius.top_right > 0.0
1852                            || border_radius.bottom_right > 0.0
1853                            || border_radius.bottom_left > 0.0
1854                        {
1855                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1856                            let _ = writeln!(stream, "W n");
1857                        } else {
1858                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re W n", x, y, w, h);
1859                        }
1860                        // Translate so the shading's local 0,0 sits at
1861                        // the rect's bottom-left.
1862                        let _ =
1863                            writeln!(stream, "1 0 0 1 {:.3} {:.3} cm\n/{} sh\nQ", x, y, sh_name);
1864                    }
1865                } else if let Some(bg) = background {
1866                    if bg.a > 0.0 {
1867                        let _ = writeln!(stream, "q\n{:.3} {:.3} {:.3} rg", bg.r, bg.g, bg.b);
1868
1869                        if border_radius.top_left > 0.0 {
1870                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1871                        } else {
1872                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
1873                        }
1874
1875                        let _ = writeln!(stream, "f\nQ");
1876                    }
1877                }
1878
1879                let bw = border_width;
1880                if bw.top > 0.0 || bw.right > 0.0 || bw.bottom > 0.0 || bw.left > 0.0 {
1881                    use crate::style::BorderStyle::Solid;
1882                    let all_solid = border_style.top == Solid
1883                        && border_style.right == Solid
1884                        && border_style.bottom == Solid
1885                        && border_style.left == Solid;
1886                    // The uniform fast path draws one rounded/plain rect stroke;
1887                    // it only applies to a solid, equal-width border. Any
1888                    // dashed/dotted or mixed-style border goes per-side (which
1889                    // also emits the dash pattern; radius is dropped there, per
1890                    // Chrome's own dashed-with-radius handling).
1891                    if all_solid
1892                        && (bw.top - bw.right).abs() < 0.001
1893                        && (bw.right - bw.bottom).abs() < 0.001
1894                        && (bw.bottom - bw.left).abs() < 0.001
1895                    {
1896                        let bc = &border_color.top;
1897                        let _ = writeln!(
1898                            stream,
1899                            "q\n{:.3} {:.3} {:.3} RG\n{:.2} w",
1900                            bc.r, bc.g, bc.b, bw.top
1901                        );
1902
1903                        if border_radius.top_left > 0.0 {
1904                            self.write_rounded_rect(stream, x, y, w, h, border_radius);
1905                        } else {
1906                            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re", x, y, w, h);
1907                        }
1908
1909                        let _ = writeln!(stream, "S\nQ");
1910                    } else {
1911                        self.write_border_sides(stream, x, y, w, h, bw, border_color, border_style);
1912                    }
1913                }
1914
1915                if needs_opacity {
1916                    let _ = writeln!(stream, "Q");
1917                }
1918            }
1919
1920            DrawCommand::Text {
1921                lines,
1922                color,
1923                text_decoration,
1924                opacity,
1925            } => {
1926                // Apply opacity via ExtGState
1927                let needs_opacity = *opacity < 1.0;
1928                if needs_opacity {
1929                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
1930                        let _ = writeln!(stream, "q\n/{} gs", gs_name);
1931                    }
1932                }
1933
1934                // `Tw` is text state: it survives ET and only Q restores it.
1935                // A line that sets none (a justified paragraph's last line)
1936                // was drawn with the previous line's spacing (#162), so once
1937                // a line sets it, the next line without it resets to 0.
1938                // Text that never sets it emits nothing, as before.
1939                let mut tw_set = false;
1940                // Tagged text with inline links: each link's words are drawn
1941                // inside its own /Link marked content (#157). Groups then also
1942                // split where a link starts or ends, and at each boundary the
1943                // text object and the marked content are closed and reopened
1944                // (marked content may not cross BT/ET). Everything else takes
1945                // the untouched path below.
1946                let tag_links = bdc_role.is_some() && !inline_links.is_empty();
1947                for line in lines {
1948                    if line.glyphs.is_empty() {
1949                        continue;
1950                    }
1951
1952                    // Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
1953                    // to support multi-font text runs
1954                    let groups = Self::group_glyphs(&line.glyphs, tag_links);
1955                    let group_links = if tag_links {
1956                        Self::group_link_runs(&groups)
1957                    } else {
1958                        vec![None; groups.len()]
1959                    };
1960                    let mut open_link: Option<&str> = None;
1961                    let pdf_y = page_height - line.y;
1962
1963                    let _ = writeln!(stream, "BT");
1964
1965                    // Set word spacing for justification (PDF Tw operator)
1966                    if line.word_spacing.abs() > 0.001 {
1967                        let _ = writeln!(stream, "{:.4} Tw", line.word_spacing);
1968                        tw_set = true;
1969                    } else if tw_set {
1970                        let _ = writeln!(stream, "0 Tw");
1971                        tw_set = false;
1972                    }
1973
1974                    // Track current text matrix position for relative Td moves
1975                    let mut tm_x = 0.0_f64;
1976                    let mut tm_y = 0.0_f64;
1977                    let mut x_cursor = line.x;
1978
1979                    // Track group spans for per-group text decoration
1980                    let mut group_spans: Vec<(f64, f64, TextDecoration, Color)> = Vec::new();
1981
1982                    for (gi, group) in groups.iter().enumerate() {
1983                        let want = group_links[gi];
1984                        if want != open_link {
1985                            let _ = writeln!(stream, "ET\nEMC");
1986                            let role = bdc_role.unwrap_or("Span");
1987                            let tb = tag_builder.as_mut().expect("tag_links implies tagging");
1988                            let link = want.and_then(|_| inline_links.pop_front());
1989                            match link {
1990                                Some(idx) => {
1991                                    let mcid = tb.attach_inline_link(idx, page_idx);
1992                                    let _ = writeln!(stream, "/Link <</MCID {}>> BDC", mcid);
1993                                    open_link = want;
1994                                }
1995                                None => {
1996                                    let mcid = tb.continue_current(page_idx);
1997                                    let _ = writeln!(stream, "/{} <</MCID {}>> BDC", role, mcid);
1998                                    open_link = None;
1999                                }
2000                            }
2001                            // A new text object starts at the identity matrix.
2002                            let _ = writeln!(stream, "BT");
2003                            tm_x = 0.0;
2004                            tm_y = 0.0;
2005                        }
2006                        let first = &group[0];
2007                        let glyph_color = first.color.unwrap_or(*color);
2008
2009                        let idx = self.font_index(
2010                            &first.font_family,
2011                            first.font_weight,
2012                            first.font_style,
2013                            &builder.font_objects,
2014                        );
2015                        let italic =
2016                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
2017                        let font_key = FontKey {
2018                            family: first.font_family.to_string(),
2019                            weight: first.font_weight,
2020                            italic,
2021                        };
2022                        let font_name = format!("F{}", idx);
2023
2024                        // A registered-font group starts at its first glyph's own
2025                        // position (its TJ places the rest); the running cursor
2026                        // can differ from it by a kerning or justification step.
2027                        if builder.custom_font_data.contains_key(&font_key)
2028                            && !has_placeholder_group(group)
2029                        {
2030                            x_cursor = line.x + first.x_offset;
2031                        }
2032                        // Td is relative to current text matrix position
2033                        let dx = x_cursor - tm_x;
2034                        let dy = pdf_y - tm_y;
2035                        let _ = writeln!(
2036                            stream,
2037                            "{:.3} {:.3} {:.3} rg\n/{} {:.1} Tf\n{:.2} Tc\n{:.2} {:.2} Td",
2038                            glyph_color.r,
2039                            glyph_color.g,
2040                            glyph_color.b,
2041                            font_name,
2042                            first.font_size,
2043                            first.letter_spacing,
2044                            dx,
2045                            dy
2046                        );
2047                        tm_x = x_cursor;
2048                        tm_y = pdf_y;
2049
2050                        // Check for page number sentinel characters
2051                        let raw_text: String = group.iter().map(|g| g.char_value).collect();
2052                        let has_placeholder = raw_text.contains(PAGE_NUMBER_SENTINEL)
2053                            || raw_text.contains(TOTAL_PAGES_SENTINEL);
2054
2055                        let is_custom = builder.custom_font_data.contains_key(&font_key);
2056
2057                        if is_custom {
2058                            if let Some(embed_data) = builder.custom_font_data.get(&font_key) {
2059                                let mut hex = String::new();
2060                                // Set when the group was written as a TJ array.
2061                                let mut continue_after_show = false;
2062                                if has_placeholder {
2063                                    // Sentinel text: replace with actual values and use char→gid fallback
2064                                    let pn = PAGE_NUMBER_SENTINEL.to_string();
2065                                    let tp = TOTAL_PAGES_SENTINEL.to_string();
2066                                    let text_after = raw_text
2067                                        .replace(&pn, &page_number.to_string())
2068                                        .replace(&tp, &total_pages.to_string());
2069                                    for ch in text_after.chars() {
2070                                        let gid =
2071                                            embed_data.char_to_gid.get(&ch).copied().unwrap_or(0);
2072                                        let _ = write!(hex, "{:04X}", gid);
2073                                    }
2074                                } else {
2075                                    // Shaped text: use glyph IDs directly (remapped through subset)
2076                                    let gids: Vec<u16> = group
2077                                        .iter()
2078                                        .map(|g| {
2079                                            embed_data
2080                                                .gid_remap
2081                                                .get(&g.glyph_id)
2082                                                .copied()
2083                                                .unwrap_or_else(|| {
2084                                                    // Fallback: try char→gid
2085                                                    embed_data
2086                                                        .char_to_gid
2087                                                        .get(&g.char_value)
2088                                                        .copied()
2089                                                        .unwrap_or(0)
2090                                                })
2091                                        })
2092                                        .collect();
2093                                    if let Some(tj) = Self::positioned_tj(
2094                                        group,
2095                                        &gids,
2096                                        embed_data,
2097                                        first.letter_spacing,
2098                                    ) {
2099                                        let _ = writeln!(stream, "{}", tj);
2100                                        continue_after_show = true;
2101                                    } else {
2102                                        for gid in &gids {
2103                                            let _ = write!(hex, "{:04X}", gid);
2104                                        }
2105                                    }
2106                                }
2107                                if !continue_after_show {
2108                                    let _ = writeln!(stream, "<{}> Tj", hex);
2109                                }
2110                            } else {
2111                                let _ = writeln!(stream, "<> Tj");
2112                            }
2113                        } else {
2114                            let pn = PAGE_NUMBER_SENTINEL.to_string();
2115                            let tp = TOTAL_PAGES_SENTINEL.to_string();
2116                            let text_after = raw_text
2117                                .replace(&pn, &page_number.to_string())
2118                                .replace(&tp, &total_pages.to_string());
2119                            let mut text_str = String::new();
2120                            for ch in text_after.chars() {
2121                                let b = Self::unicode_to_winansi(ch).unwrap_or_else(|| {
2122                                    builder.missing_glyphs.borrow_mut().insert(ch);
2123                                    b'?'
2124                                });
2125                                match b {
2126                                    b'\\' => text_str.push_str("\\\\"),
2127                                    b'(' => text_str.push_str("\\("),
2128                                    b')' => text_str.push_str("\\)"),
2129                                    0x20..=0x7E => text_str.push(b as char),
2130                                    _ => {
2131                                        let _ = write!(text_str, "\\{:03o}", b);
2132                                    }
2133                                }
2134                            }
2135                            let _ = writeln!(stream, "({}) Tj", text_str);
2136                        }
2137
2138                        // Record span for per-group text decoration
2139                        let group_start_x = x_cursor;
2140
2141                        // Advance x_cursor past this group. Glyph offsets already
2142                        // include the justification: Tw only makes the drawn
2143                        // text match them. Adding word_spacing per space here
2144                        // again counted it twice, so the next group and every
2145                        // underline were off by it (286pt on a line ending at
2146                        // 274, #162).
2147                        if let Some(last) = group.last() {
2148                            x_cursor = line.x + last.x_offset + last.x_advance;
2149                        }
2150
2151                        // Check if this group has text decoration
2152                        let group_dec = first.text_decoration;
2153                        if !matches!(group_dec, TextDecoration::None) {
2154                            group_spans.push((group_start_x, x_cursor, group_dec, glyph_color));
2155                        }
2156                    }
2157
2158                    let _ = writeln!(stream, "ET");
2159                    // A line that ends inside a link hands the rest of the
2160                    // element (its decorations, the next line) back to the
2161                    // element's own marked content.
2162                    if open_link.is_some() {
2163                        let tb = tag_builder.as_mut().expect("tag_links implies tagging");
2164                        let mcid = tb.continue_current(page_idx);
2165                        let _ = writeln!(
2166                            stream,
2167                            "EMC\n/{} <</MCID {}>> BDC",
2168                            bdc_role.unwrap_or("Span"),
2169                            mcid
2170                        );
2171                    }
2172
2173                    // Draw per-group text decorations
2174                    for (span_x, span_end_x, dec, dec_color) in &group_spans {
2175                        match dec {
2176                            TextDecoration::Underline => {
2177                                let underline_y = pdf_y - 1.5;
2178                                let _ = write!(
2179                                    stream,
2180                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2181                                    dec_color.r, dec_color.g, dec_color.b,
2182                                    span_x, underline_y,
2183                                    span_end_x, underline_y
2184                                );
2185                            }
2186                            TextDecoration::LineThrough => {
2187                                let first_size =
2188                                    line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
2189                                let strikethrough_y = pdf_y + first_size * 0.3;
2190                                let _ = write!(
2191                                    stream,
2192                                    "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2193                                    dec_color.r, dec_color.g, dec_color.b,
2194                                    span_x, strikethrough_y,
2195                                    span_end_x, strikethrough_y
2196                                );
2197                            }
2198                            TextDecoration::None => {}
2199                        }
2200                    }
2201
2202                    // Also handle whole-line decoration from parent style
2203                    if group_spans.is_empty() {
2204                        if matches!(text_decoration, TextDecoration::Underline) {
2205                            let underline_y = pdf_y - 1.5;
2206                            let _ = write!(
2207                                stream,
2208                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2209                                color.r, color.g, color.b,
2210                                line.x, underline_y,
2211                                line.x + line.width, underline_y
2212                            );
2213                        }
2214                        if matches!(text_decoration, TextDecoration::LineThrough) {
2215                            let first_size =
2216                                line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
2217                            let strikethrough_y = pdf_y + first_size * 0.3;
2218                            let _ = write!(
2219                                stream,
2220                                "q\n{:.3} {:.3} {:.3} RG\n0.5 w\n{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
2221                                color.r, color.g, color.b,
2222                                line.x, strikethrough_y,
2223                                line.x + line.width, strikethrough_y
2224                            );
2225                        }
2226                    }
2227                }
2228
2229                // A paragraph whose last line here is stretched (it goes on
2230                // to the next page) must not hand its spacing to whatever
2231                // text is drawn next on this page.
2232                if tw_set {
2233                    let _ = writeln!(stream, "0 Tw");
2234                }
2235                // Every created /Link must be in the tree: its annotation
2236                // points at it. One whose words were never drawn is placed
2237                // without content.
2238                if let Some(tb) = tag_builder.as_mut() {
2239                    while let Some(idx) = inline_links.pop_front() {
2240                        tb.attach_inline_link_without_content(idx);
2241                    }
2242                }
2243
2244                if needs_opacity {
2245                    let _ = writeln!(stream, "Q");
2246                }
2247            }
2248
2249            DrawCommand::Image { .. } => {
2250                let elem_idx = *element_counter;
2251                *element_counter += 1;
2252                if let Some(&img_idx) = builder.image_index_map.get(&(page_idx, elem_idx)) {
2253                    let x = element.x;
2254                    let y = page_height - element.y - element.height;
2255                    let _ = write!(
2256                        stream,
2257                        "q\n{:.4} 0 0 {:.4} {:.2} {:.2} cm\n/Im{} Do\nQ\n",
2258                        element.width, element.height, x, y, img_idx
2259                    );
2260                } else {
2261                    // Fallback: grey placeholder if image index not found
2262                    let x = element.x;
2263                    let y = page_height - element.y - element.height;
2264                    let _ = write!(
2265                        stream,
2266                        "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
2267                        x, y, element.width, element.height
2268                    );
2269                }
2270                if tagged_mcid.is_some() {
2271                    let _ = writeln!(stream, "EMC");
2272                    if let Some(ref mut tb) = tag_builder {
2273                        tb.end_element();
2274                    }
2275                } else if is_artifact {
2276                    let _ = writeln!(stream, "EMC");
2277                } else if wrap_own_draw_as_artifact {
2278                    // Unreachable today (graphics arms map to /Figure under
2279                    // UA-2), but if a forbidden-content role ever gained a
2280                    // graphics draw, close its /Artifact bracket and element.
2281                    let _ = writeln!(stream, "EMC");
2282                    if let Some(ref mut tb) = tag_builder {
2283                        tb.end_element();
2284                    }
2285                }
2286                return; // Don't increment counter again for children
2287            }
2288
2289            DrawCommand::ImagePlaceholder => {
2290                *element_counter += 1;
2291                let x = element.x;
2292                let y = page_height - element.y - element.height;
2293                let _ = write!(
2294                    stream,
2295                    "q\n0.9 0.9 0.9 rg\n{:.2} {:.2} {:.2} {:.2} re\nf\nQ\n",
2296                    x, y, element.width, element.height
2297                );
2298                if tagged_mcid.is_some() {
2299                    let _ = writeln!(stream, "EMC");
2300                    if let Some(ref mut tb) = tag_builder {
2301                        tb.end_element();
2302                    }
2303                } else if is_artifact {
2304                    let _ = writeln!(stream, "EMC");
2305                } else if wrap_own_draw_as_artifact {
2306                    // Unreachable today (graphics arms map to /Figure under
2307                    // UA-2), but if a forbidden-content role ever gained a
2308                    // graphics draw, close its /Artifact bracket and element.
2309                    let _ = writeln!(stream, "EMC");
2310                    if let Some(ref mut tb) = tag_builder {
2311                        tb.end_element();
2312                    }
2313                }
2314                return;
2315            }
2316
2317            DrawCommand::Svg {
2318                commands,
2319                width: _svg_w,
2320                height: _svg_h,
2321                viewbox_min_x,
2322                viewbox_min_y,
2323                viewbox_width,
2324                viewbox_height,
2325                clip,
2326            } => {
2327                let x = element.x;
2328                let y = page_height - element.y - element.height;
2329
2330                // Save state, translate to position
2331                let _ = writeln!(stream, "q");
2332                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", x, y);
2333
2334                // SVG viewport algorithm with `xMidYMid meet` as the default
2335                // preserveAspectRatio: uniform scale to fit, center the
2336                // remainder. When viewBox matches the display box (the
2337                // no-viewBox case, populated as 0/0/w/h in layout) the scale
2338                // is 1 and the translate is 0 — behavior unchanged.
2339                if *viewbox_width > 0.0 && *viewbox_height > 0.0 {
2340                    let raw_sx = element.width / *viewbox_width;
2341                    let raw_sy = element.height / *viewbox_height;
2342                    let s = raw_sx.min(raw_sy);
2343                    let tx = (element.width - s * *viewbox_width) / 2.0;
2344                    let ty = (element.height - s * *viewbox_height) / 2.0;
2345                    let _ = writeln!(stream, "{:.4} 0 0 {:.4} {:.2} {:.2} cm", s, s, tx, ty);
2346                }
2347
2348                // Flip Y so SVG-coord Y-down becomes PDF Y-up. The flip
2349                // height is the viewBox height (we're now in viewBox space).
2350                let _ = writeln!(stream, "1 0 0 -1 0 {:.2} cm", *viewbox_height);
2351
2352                // Shift origin so the viewBox's (min_x, min_y) lands at (0, 0).
2353                if *viewbox_min_x != 0.0 || *viewbox_min_y != 0.0 {
2354                    let _ = writeln!(
2355                        stream,
2356                        "1 0 0 1 {:.2} {:.2} cm",
2357                        -*viewbox_min_x, -*viewbox_min_y
2358                    );
2359                }
2360
2361                // Clip to viewBox bounds (Canvas always clips, SVG does not).
2362                if *clip {
2363                    let _ = writeln!(
2364                        stream,
2365                        "{:.2} {:.2} {:.2} {:.2} re W n",
2366                        *viewbox_min_x, *viewbox_min_y, *viewbox_width, *viewbox_height
2367                    );
2368                }
2369
2370                Self::write_svg_commands(stream, commands, &builder.ext_gstate_map);
2371
2372                let _ = writeln!(stream, "Q");
2373                if tagged_mcid.is_some() {
2374                    let _ = writeln!(stream, "EMC");
2375                    if let Some(ref mut tb) = tag_builder {
2376                        tb.end_element();
2377                    }
2378                } else if is_artifact {
2379                    let _ = writeln!(stream, "EMC");
2380                } else if wrap_own_draw_as_artifact {
2381                    // Unreachable today (graphics arms map to /Figure under
2382                    // UA-2), but if a forbidden-content role ever gained a
2383                    // graphics draw, close its /Artifact bracket and element.
2384                    let _ = writeln!(stream, "EMC");
2385                    if let Some(ref mut tb) = tag_builder {
2386                        tb.end_element();
2387                    }
2388                }
2389                return;
2390            }
2391
2392            DrawCommand::Barcode {
2393                bars,
2394                bar_width,
2395                height,
2396                color,
2397            } => {
2398                *element_counter += 1;
2399                let _ = writeln!(stream, "q");
2400                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2401                for (i, &bar) in bars.iter().enumerate() {
2402                    if bar == 1 {
2403                        let bx = element.x + i as f64 * bar_width;
2404                        let by = page_height - element.y - height;
2405                        let _ = writeln!(
2406                            stream,
2407                            "{:.2} {:.2} {:.2} {:.2} re",
2408                            bx, by, bar_width, height
2409                        );
2410                    }
2411                }
2412                let _ = writeln!(stream, "f\nQ");
2413                if tagged_mcid.is_some() {
2414                    let _ = writeln!(stream, "EMC");
2415                    if let Some(ref mut tb) = tag_builder {
2416                        tb.end_element();
2417                    }
2418                } else if is_artifact {
2419                    let _ = writeln!(stream, "EMC");
2420                } else if wrap_own_draw_as_artifact {
2421                    // Unreachable today (graphics arms map to /Figure under
2422                    // UA-2), but if a forbidden-content role ever gained a
2423                    // graphics draw, close its /Artifact bracket and element.
2424                    let _ = writeln!(stream, "EMC");
2425                    if let Some(ref mut tb) = tag_builder {
2426                        tb.end_element();
2427                    }
2428                }
2429                return;
2430            }
2431
2432            DrawCommand::QrCode {
2433                modules,
2434                module_size,
2435                color,
2436            } => {
2437                *element_counter += 1;
2438                let _ = writeln!(stream, "q");
2439                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2440                for (row_idx, row) in modules.iter().enumerate() {
2441                    for (col_idx, &dark) in row.iter().enumerate() {
2442                        if dark {
2443                            let mx = element.x + col_idx as f64 * module_size;
2444                            let my = page_height - element.y - (row_idx as f64 + 1.0) * module_size;
2445                            let _ = writeln!(
2446                                stream,
2447                                "{:.2} {:.2} {:.2} {:.2} re",
2448                                mx, my, module_size, module_size
2449                            );
2450                        }
2451                    }
2452                }
2453                let _ = writeln!(stream, "f\nQ");
2454                if tagged_mcid.is_some() {
2455                    let _ = writeln!(stream, "EMC");
2456                    if let Some(ref mut tb) = tag_builder {
2457                        tb.end_element();
2458                    }
2459                } else if is_artifact {
2460                    let _ = writeln!(stream, "EMC");
2461                } else if wrap_own_draw_as_artifact {
2462                    // Unreachable today (graphics arms map to /Figure under
2463                    // UA-2), but if a forbidden-content role ever gained a
2464                    // graphics draw, close its /Artifact bracket and element.
2465                    let _ = writeln!(stream, "EMC");
2466                    if let Some(ref mut tb) = tag_builder {
2467                        tb.end_element();
2468                    }
2469                }
2470                return;
2471            }
2472
2473            DrawCommand::Chart { primitives } => {
2474                *element_counter += 1;
2475                let _ = writeln!(stream, "q");
2476                // Set up coordinate transform: Y-flip so chart primitives use top-left origin
2477                let _ = writeln!(
2478                    stream,
2479                    "1 0 0 -1 {:.4} {:.4} cm",
2480                    element.x,
2481                    page_height - element.y
2482                );
2483
2484                for prim in primitives {
2485                    write_chart_primitive(stream, prim, element.height, builder);
2486                }
2487
2488                let _ = writeln!(stream, "Q");
2489                if tagged_mcid.is_some() {
2490                    let _ = writeln!(stream, "EMC");
2491                    if let Some(ref mut tb) = tag_builder {
2492                        tb.end_element();
2493                    }
2494                } else if is_artifact {
2495                    let _ = writeln!(stream, "EMC");
2496                } else if wrap_own_draw_as_artifact {
2497                    // Unreachable today (graphics arms map to /Figure under
2498                    // UA-2), but if a forbidden-content role ever gained a
2499                    // graphics draw, close its /Artifact bracket and element.
2500                    let _ = writeln!(stream, "EMC");
2501                    if let Some(ref mut tb) = tag_builder {
2502                        tb.end_element();
2503                    }
2504                }
2505                return;
2506            }
2507
2508            DrawCommand::Watermark {
2509                lines,
2510                color,
2511                opacity,
2512                angle_rad,
2513                font_family: _,
2514            } => {
2515                let _ = writeln!(stream, "q");
2516                // Set opacity via ExtGState if not fully opaque
2517                if *opacity < 1.0 {
2518                    if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
2519                        let _ = writeln!(stream, "/{} gs", gs_name);
2520                    }
2521                }
2522                // Translate to center position (element.x, element.y = page center)
2523                let pdf_cx = element.x;
2524                let pdf_cy = page_height - element.y;
2525                let _ = writeln!(stream, "1 0 0 1 {:.2} {:.2} cm", pdf_cx, pdf_cy);
2526                // Rotate by angle
2527                let cos_a = angle_rad.cos();
2528                let sin_a = angle_rad.sin();
2529                let _ = writeln!(
2530                    stream,
2531                    "{:.6} {:.6} {:.6} {:.6} 0 0 cm",
2532                    cos_a, sin_a, -sin_a, cos_a
2533                );
2534                // Render text centered on origin
2535                let _ = writeln!(stream, "BT");
2536                let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", color.r, color.g, color.b);
2537                if let Some(line) = lines.first() {
2538                    let groups = Self::group_glyphs_by_style(&line.glyphs);
2539                    let text_width = line.width;
2540                    let cap_height = line.height * 0.7;
2541                    let _ = writeln!(
2542                        stream,
2543                        "{:.2} {:.2} Td",
2544                        -text_width / 2.0,
2545                        -cap_height / 2.0
2546                    );
2547                    for group in &groups {
2548                        let first = &group[0];
2549                        let italic =
2550                            matches!(first.font_style, FontStyle::Italic | FontStyle::Oblique);
2551                        let fk = FontKey {
2552                            family: first.font_family.to_string(),
2553                            weight: first.font_weight,
2554                            italic,
2555                        };
2556                        let idx = self.font_index(
2557                            &first.font_family,
2558                            first.font_weight,
2559                            first.font_style,
2560                            &builder.font_objects,
2561                        );
2562                        let font_name = format!("F{}", idx);
2563                        let _ = writeln!(stream, "/{} {:.1} Tf", font_name, first.font_size);
2564                        let is_custom = builder.custom_font_data.contains_key(&fk);
2565                        if is_custom {
2566                            if let Some(embed_data) = builder.custom_font_data.get(&fk) {
2567                                let mut hex = String::new();
2568                                for g in group.iter() {
2569                                    let gid =
2570                                        embed_data.gid_remap.get(&g.glyph_id).copied().unwrap_or(0);
2571                                    let _ = write!(hex, "{:04X}", gid);
2572                                }
2573                                let _ = writeln!(stream, "<{}> Tj", hex);
2574                            }
2575                        } else {
2576                            let hex_str: String = group
2577                                .iter()
2578                                .map(|g| format!("{:02X}", g.glyph_id as u8))
2579                                .collect();
2580                            let _ = writeln!(stream, "<{}> Tj", hex_str);
2581                        }
2582                    }
2583                }
2584                let _ = writeln!(stream, "ET");
2585                let _ = writeln!(stream, "Q");
2586                if tagged_mcid.is_some() {
2587                    let _ = writeln!(stream, "EMC");
2588                    if let Some(ref mut tb) = tag_builder {
2589                        tb.end_element();
2590                    }
2591                } else if is_artifact {
2592                    let _ = writeln!(stream, "EMC");
2593                } else if wrap_own_draw_as_artifact {
2594                    // Unreachable today (graphics arms map to /Figure under
2595                    // UA-2), but if a forbidden-content role ever gained a
2596                    // graphics draw, close its /Artifact bracket and element.
2597                    let _ = writeln!(stream, "EMC");
2598                    if let Some(ref mut tb) = tag_builder {
2599                        tb.end_element();
2600                    }
2601                }
2602                return;
2603            }
2604
2605            DrawCommand::FormField { field_type, .. } => {
2606                // Draw a visual placeholder so form fields are visible in previews
2607                // and non-form-aware viewers. When flatten_forms is true, also render
2608                // the field value as static text and skip interactive widgets.
2609                let pdf_x = element.x;
2610                let pdf_y = page_height - element.y - element.height;
2611                let w = element.width;
2612                let h = element.height;
2613                let _ = writeln!(stream, "q");
2614                match field_type {
2615                    FormFieldType::Checkbox { checked, .. } => {
2616                        // Draw a border square
2617                        let _ = writeln!(stream, "0.6 0.6 0.6 RG"); // grey stroke
2618                        let _ = writeln!(stream, "0.5 w");
2619                        let _ =
2620                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re S", pdf_x, pdf_y, w, h);
2621                        if *checked {
2622                            // Draw a checkmark scaled to field dimensions
2623                            let _ = writeln!(stream, "0.2 0.2 0.2 rg");
2624                            let sx = w / 14.0;
2625                            let sy = h / 14.0;
2626                            let _ = writeln!(
2627                                stream,
2628                                "{:.2} {:.2} m {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l {:.2} {:.2} l f",
2629                                pdf_x + 2.0 * sx, pdf_y + 6.0 * sy,
2630                                pdf_x + 5.5 * sx, pdf_y + 2.0 * sy,
2631                                pdf_x + 12.0 * sx, pdf_y + 11.0 * sy,
2632                                pdf_x + 11.0 * sx, pdf_y + 12.0 * sy,
2633                                pdf_x + 5.5 * sx, pdf_y + 4.5 * sy,
2634                                pdf_x + 3.0 * sx, pdf_y + 7.0 * sy,
2635                                pdf_x + 2.0 * sx, pdf_y + 6.0 * sy,
2636                            );
2637                        }
2638                    }
2639                    FormFieldType::RadioButton { checked, .. } => {
2640                        // Draw a border square
2641                        let _ = writeln!(stream, "0.6 0.6 0.6 RG"); // grey stroke
2642                        let _ = writeln!(stream, "0.5 w");
2643                        let _ =
2644                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re S", pdf_x, pdf_y, w, h);
2645                        if *checked {
2646                            // Draw a filled circle
2647                            let cx = pdf_x + w / 2.0;
2648                            let cy = pdf_y + h / 2.0;
2649                            let r = (w.min(h) / 2.0) * 0.6;
2650                            let k = r * 0.5523;
2651                            let _ = writeln!(stream, "0.2 0.2 0.2 rg");
2652                            let _ = writeln!(
2653                                stream,
2654                                "{:.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",
2655                                cx, cy + r,
2656                                cx + k, cy + r, cx + r, cy + k, cx + r, cy,
2657                                cx + r, cy - k, cx + k, cy - r, cx, cy - r,
2658                                cx - k, cy - r, cx - r, cy - k, cx - r, cy,
2659                                cx - r, cy + k, cx - k, cy + r, cx, cy + r,
2660                            );
2661                        }
2662                    }
2663                    FormFieldType::TextField {
2664                        value,
2665                        placeholder,
2666                        font_size,
2667                        multiline,
2668                        password,
2669                        ..
2670                    } => {
2671                        // White fill + grey border
2672                        let _ = writeln!(stream, "1 1 1 rg");
2673                        let _ = writeln!(stream, "0.6 0.6 0.6 RG");
2674                        let _ = writeln!(stream, "0.5 w");
2675                        let _ =
2676                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re B", pdf_x, pdf_y, w, h);
2677                        // Render value text when flattening
2678                        if flatten_forms {
2679                            let has_value = value.as_ref().is_some_and(|v| !v.is_empty());
2680                            if has_value {
2681                                let val = value.as_ref().unwrap();
2682                                let display_text = if *password {
2683                                    "\u{2022}".repeat(val.len())
2684                                } else {
2685                                    val.clone()
2686                                };
2687                                let font_idx = builder
2688                                    .font_objects
2689                                    .iter()
2690                                    .enumerate()
2691                                    .find(|(_, (key, _))| {
2692                                        key.family == "Helvetica"
2693                                            && key.weight == 400
2694                                            && !key.italic
2695                                    })
2696                                    .map(|(i, _)| i)
2697                                    .unwrap_or(0);
2698                                if *multiline {
2699                                    // Simple word-wrap for multiline
2700                                    let metrics = crate::font::StandardFont::Helvetica.metrics();
2701                                    let max_w = w - 4.0;
2702                                    let mut lines: Vec<String> = Vec::new();
2703                                    for paragraph in display_text.split('\n') {
2704                                        let mut line = String::new();
2705                                        let mut line_w = 0.0;
2706                                        for word in paragraph.split_whitespace() {
2707                                            let word_w =
2708                                                metrics.measure_string(word, *font_size, 0.0);
2709                                            let space_w = if line.is_empty() {
2710                                                0.0
2711                                            } else {
2712                                                metrics.measure_string(" ", *font_size, 0.0)
2713                                            };
2714                                            // Word wider than field — break at character boundary
2715                                            if word_w > max_w {
2716                                                let mut char_line = String::new();
2717                                                let mut char_w = 0.0;
2718                                                for ch in word.chars() {
2719                                                    let cw = metrics.char_width(ch, *font_size);
2720                                                    if !char_line.is_empty() && char_w + cw > max_w
2721                                                    {
2722                                                        if !line.is_empty() {
2723                                                            lines.push(line.clone());
2724                                                            line.clear();
2725                                                            line_w = 0.0;
2726                                                        }
2727                                                        lines.push(char_line.clone());
2728                                                        char_line.clear();
2729                                                        char_w = 0.0;
2730                                                    }
2731                                                    char_line.push(ch);
2732                                                    char_w += cw;
2733                                                }
2734                                                // Remaining chars join the current line
2735                                                if !char_line.is_empty() {
2736                                                    if !line.is_empty() {
2737                                                        line.push(' ');
2738                                                        line_w += metrics
2739                                                            .measure_string(" ", *font_size, 0.0);
2740                                                    }
2741                                                    line.push_str(&char_line);
2742                                                    line_w += char_w;
2743                                                }
2744                                                continue;
2745                                            }
2746                                            if !line.is_empty() && line_w + space_w + word_w > max_w
2747                                            {
2748                                                lines.push(line.clone());
2749                                                line.clear();
2750                                                line_w = 0.0;
2751                                            }
2752                                            if !line.is_empty() {
2753                                                line.push(' ');
2754                                                line_w += space_w;
2755                                            }
2756                                            line.push_str(word);
2757                                            line_w += word_w;
2758                                        }
2759                                        if !line.is_empty() {
2760                                            lines.push(line);
2761                                        }
2762                                    }
2763                                    let text_y = pdf_y + h - font_size - 2.0;
2764                                    for (i, line_text) in lines.iter().enumerate() {
2765                                        let ly = text_y - (i as f64) * (font_size * 1.2);
2766                                        if ly < pdf_y {
2767                                            break;
2768                                        }
2769                                        let esc = Self::encode_winansi_text(builder, line_text);
2770                                        let _ = writeln!(
2771                                            stream,
2772                                            "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2773                                            font_idx,
2774                                            font_size,
2775                                            pdf_x + 2.0,
2776                                            ly,
2777                                            esc
2778                                        );
2779                                    }
2780                                } else {
2781                                    let escaped = Self::encode_winansi_text(builder, &display_text);
2782                                    let text_y = pdf_y + (h - font_size) / 2.0;
2783                                    let _ = writeln!(
2784                                        stream,
2785                                        "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2786                                        font_idx,
2787                                        font_size,
2788                                        pdf_x + 2.0,
2789                                        text_y,
2790                                        escaped
2791                                    );
2792                                }
2793                            } else if let Some(ref ph) = placeholder {
2794                                if !ph.is_empty() {
2795                                    // Render placeholder in grey
2796                                    let font_idx = builder
2797                                        .font_objects
2798                                        .iter()
2799                                        .enumerate()
2800                                        .find(|(_, (key, _))| {
2801                                            key.family == "Helvetica"
2802                                                && key.weight == 400
2803                                                && !key.italic
2804                                        })
2805                                        .map(|(i, _)| i)
2806                                        .unwrap_or(0);
2807                                    let escaped = Self::encode_winansi_text(builder, ph);
2808                                    let text_y = pdf_y + (h - font_size) / 2.0;
2809                                    let _ = writeln!(
2810                                        stream,
2811                                        "BT /F{} {:.1} Tf 0.6 g {:.2} {:.2} Td ({}) Tj ET",
2812                                        font_idx,
2813                                        font_size,
2814                                        pdf_x + 2.0,
2815                                        text_y,
2816                                        escaped
2817                                    );
2818                                }
2819                            }
2820                        }
2821                    }
2822                    FormFieldType::Dropdown {
2823                        value, font_size, ..
2824                    } => {
2825                        // White fill + grey border
2826                        let _ = writeln!(stream, "1 1 1 rg");
2827                        let _ = writeln!(stream, "0.6 0.6 0.6 RG");
2828                        let _ = writeln!(stream, "0.5 w");
2829                        let _ =
2830                            writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re B", pdf_x, pdf_y, w, h);
2831                        // Render selected value text when flattening
2832                        if flatten_forms {
2833                            if let Some(ref val) = value {
2834                                if !val.is_empty() {
2835                                    let font_idx = builder
2836                                        .font_objects
2837                                        .iter()
2838                                        .enumerate()
2839                                        .find(|(_, (key, _))| {
2840                                            key.family == "Helvetica"
2841                                                && key.weight == 400
2842                                                && !key.italic
2843                                        })
2844                                        .map(|(i, _)| i)
2845                                        .unwrap_or(0);
2846                                    let escaped = Self::encode_winansi_text(builder, val);
2847                                    let text_y = pdf_y + (h - font_size) / 2.0;
2848                                    let _ = writeln!(
2849                                        stream,
2850                                        "BT /F{} {:.1} Tf 0 g {:.2} {:.2} Td ({}) Tj ET",
2851                                        font_idx,
2852                                        font_size,
2853                                        pdf_x + 2.0,
2854                                        text_y,
2855                                        escaped
2856                                    );
2857                                }
2858                            }
2859                        }
2860                    }
2861                }
2862                let _ = writeln!(stream, "Q");
2863            }
2864        }
2865
2866        // Close the /Artifact bracket around the element's own ink (opened
2867        // before the draw match) — children below stay outside it.
2868        if wrap_own_draw_as_artifact {
2869            let _ = writeln!(stream, "EMC");
2870        }
2871
2872        // Overflow clipping: wrap children in q/clip/Q when overflow is Hidden.
2873        // When the element's Rect has a non-zero border_radius, clip to the
2874        // rounded path so descendants don't visually overflow the rounded
2875        // corners. Plain rectangular clip otherwise.
2876        let clip_overflow = matches!(element.overflow, Overflow::Hidden);
2877        if clip_overflow {
2878            let clip_x = element.x;
2879            let clip_y = page_height - element.y - element.height;
2880            let clip_w = element.width;
2881            let clip_h = element.height;
2882            // Pull border_radius from the Rect DrawCommand if present.
2883            // Other element kinds (Text, Image, Svg, ...) don't carry a
2884            // border_radius — they fall back to a rectangular clip.
2885            let radius = if let DrawCommand::Rect { border_radius, .. } = &element.draw {
2886                Some(border_radius)
2887            } else {
2888                None
2889            };
2890            let has_rounded_corners = radius.is_some_and(|r| {
2891                r.top_left > 0.0 || r.top_right > 0.0 || r.bottom_right > 0.0 || r.bottom_left > 0.0
2892            });
2893            let _ = writeln!(stream, "q");
2894            if has_rounded_corners {
2895                self.write_rounded_rect(stream, clip_x, clip_y, clip_w, clip_h, radius.unwrap());
2896                let _ = writeln!(stream, "W n");
2897            } else {
2898                let _ = writeln!(
2899                    stream,
2900                    "{:.2} {:.2} {:.2} {:.2} re W n",
2901                    clip_x, clip_y, clip_w, clip_h
2902                );
2903            }
2904        }
2905
2906        for child in &element.children {
2907            self.write_element(
2908                stream,
2909                child,
2910                page_height,
2911                builder,
2912                page_idx,
2913                element_counter,
2914                gradient_counter,
2915                page_number,
2916                total_pages,
2917                tag_builder.as_deref_mut(),
2918                flatten_forms,
2919            );
2920        }
2921
2922        if clip_overflow {
2923            let _ = writeln!(stream, "Q");
2924        }
2925
2926        // Close the transform wrap (paired with the inner q above).
2927        if has_transform {
2928            let _ = writeln!(stream, "Q");
2929        }
2930
2931        // Close the element-level opacity wrap (paired with the q above).
2932        // Goes before EMC so the marker boundary is preserved.
2933        if needs_element_opacity {
2934            let _ = writeln!(stream, "Q");
2935        }
2936
2937        // Tagged PDF: emit EMC (end marked content)
2938        if tagged_mcid.is_some() {
2939            let _ = writeln!(stream, "EMC");
2940            if let Some(ref mut tb) = tag_builder {
2941                tb.end_element();
2942            }
2943        } else if is_artifact {
2944            let _ = writeln!(stream, "EMC");
2945        } else if artifact_own_draw {
2946            // The element opened a structure entry but no marked content
2947            // (PDF/UA-2 forbidden-content role) — close just the element.
2948            if let Some(ref mut tb) = tag_builder {
2949                tb.end_element();
2950            }
2951        }
2952    }
2953
2954    fn write_rounded_rect(
2955        &self,
2956        stream: &mut String,
2957        x: f64,
2958        y: f64,
2959        w: f64,
2960        h: f64,
2961        r: &crate::style::CornerValues,
2962    ) {
2963        let k = 0.5522847498;
2964
2965        let tl = r.top_left.min(w / 2.0).min(h / 2.0);
2966        let tr = r.top_right.min(w / 2.0).min(h / 2.0);
2967        let br = r.bottom_right.min(w / 2.0).min(h / 2.0);
2968        let bl = r.bottom_left.min(w / 2.0).min(h / 2.0);
2969
2970        let _ = writeln!(stream, "{:.2} {:.2} m", x + bl, y);
2971
2972        let _ = writeln!(stream, "{:.2} {:.2} l", x + w - br, y);
2973        if br > 0.0 {
2974            let _ = writeln!(
2975                stream,
2976                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2977                x + w - br + br * k,
2978                y,
2979                x + w,
2980                y + br - br * k,
2981                x + w,
2982                y + br
2983            );
2984        }
2985
2986        let _ = writeln!(stream, "{:.2} {:.2} l", x + w, y + h - tr);
2987        if tr > 0.0 {
2988            let _ = writeln!(
2989                stream,
2990                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
2991                x + w,
2992                y + h - tr + tr * k,
2993                x + w - tr + tr * k,
2994                y + h,
2995                x + w - tr,
2996                y + h
2997            );
2998        }
2999
3000        let _ = writeln!(stream, "{:.2} {:.2} l", x + tl, y + h);
3001        if tl > 0.0 {
3002            let _ = writeln!(
3003                stream,
3004                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
3005                x + tl - tl * k,
3006                y + h,
3007                x,
3008                y + h - tl + tl * k,
3009                x,
3010                y + h - tl
3011            );
3012        }
3013
3014        let _ = writeln!(stream, "{:.2} {:.2} l", x, y + bl);
3015        if bl > 0.0 {
3016            let _ = writeln!(
3017                stream,
3018                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
3019                x,
3020                y + bl - bl * k,
3021                x + bl - bl * k,
3022                y,
3023                x + bl,
3024                y
3025            );
3026        }
3027
3028        let _ = writeln!(stream, "h");
3029    }
3030
3031    #[allow(clippy::too_many_arguments)]
3032    fn write_border_sides(
3033        &self,
3034        stream: &mut String,
3035        x: f64,
3036        y: f64,
3037        w: f64,
3038        h: f64,
3039        bw: &Edges,
3040        bc: &crate::style::EdgeValues<Color>,
3041        bs: &crate::style::EdgeValues<crate::style::BorderStyle>,
3042    ) {
3043        // PDF dash + line-cap ops for a side, calibrated against Chrome:
3044        //   dashed → dash 2×width, gap 1×width (butt cap)
3045        //   dotted → round-capped dots, diameter 1×width, 2×width centre spacing
3046        // Each side is wrapped in q/Q so the graphics state (cap, dash) resets.
3047        fn dash_ops(style: crate::style::BorderStyle, width: f64) -> String {
3048            use crate::style::BorderStyle::*;
3049            match style {
3050                Solid => String::new(),
3051                Dashed => format!("[{:.2} {:.2}] 0 d\n", width * 2.0, width),
3052                Dotted => format!("1 J\n[0 {:.2}] 0 d\n", width * 2.0),
3053            }
3054        }
3055        // side: (color, width, style, x0,y0, x1,y1)
3056        let sides = [
3057            (bc.top, bw.top, bs.top, x, y + h, x + w, y + h),
3058            (bc.bottom, bw.bottom, bs.bottom, x, y, x + w, y),
3059            (bc.left, bw.left, bs.left, x, y, x, y + h),
3060            (bc.right, bw.right, bs.right, x + w, y, x + w, y + h),
3061        ];
3062        for (color, width, style, x0, y0, x1, y1) in sides {
3063            if width <= 0.0 {
3064                continue;
3065            }
3066            let _ = write!(
3067                stream,
3068                "q\n{:.3} {:.3} {:.3} RG\n{:.2} w\n{}{:.2} {:.2} m\n{:.2} {:.2} l\nS\nQ\n",
3069                color.r,
3070                color.g,
3071                color.b,
3072                width,
3073                dash_ops(style, width),
3074                x0,
3075                y0,
3076                x1,
3077                y1
3078            );
3079        }
3080    }
3081
3082    /// Register fonts used across all pages — each unique (family, weight, italic)
3083    /// combination gets its own PDF font object.
3084    /// pdfUa: embed a metric-compatible substitute (Liberation, via
3085    /// `@formepdf/fonts-standard`) for a base-14 font, as a SIMPLE TrueType
3086    /// font carrying the base-14 AFM `/Widths` and WinAnsiEncoding. Because the
3087    /// widths, encoding, and font key are unchanged, the content stream is
3088    /// byte-identical to the non-embedded base-14 path — only the font
3089    /// dictionary gains an embedded program, so text positions are exact by
3090    /// construction. Returns `false` (caller emits the non-embedded base-14)
3091    /// when there is no metric-compatible substitute (Symbol/ZapfDingbats) or
3092    /// `@formepdf/fonts-standard` is not registered.
3093    fn emit_pdfua_embedded_standard(
3094        builder: &mut PdfBuilder,
3095        key: &FontKey,
3096        std_font: &crate::font::StandardFont,
3097        metrics: &crate::font::StandardFontMetrics,
3098        font_context: &FontContext,
3099    ) -> bool {
3100        let lib_family = match std_font.liberation_family() {
3101            Some(f) => f,
3102            None => return false, // Symbol / ZapfDingbats — no substitute
3103        };
3104        // The substitute must have been registered (fonts-standard) — otherwise
3105        // it resolves back to a Standard font and there is nothing to embed.
3106        let lib_bytes: &[u8] = match font_context.resolve(lib_family, key.weight, key.italic) {
3107            FontData::Custom { data, .. } => data,
3108            FontData::Standard(_) => return false,
3109        };
3110        let face = match ttf_parser::Face::parse(lib_bytes, 0) {
3111            Ok(f) => f,
3112            Err(_) => return false,
3113        };
3114        let scale = 1000.0 / face.units_per_em() as f64;
3115        let bbox = face.global_bounding_box();
3116        let pdf_name = Self::sanitize_font_name(lib_family, key.weight, key.italic);
3117
3118        // 1. FontFile2 — the full Liberation program, zlib-compressed.
3119        let compressed = compress_to_vec_zlib(lib_bytes, 6);
3120        let fontfile2_id = builder.objects.len();
3121        let mut ff2: Vec<u8> = Vec::new();
3122        let _ = write!(
3123            ff2,
3124            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
3125            compressed.len(),
3126            lib_bytes.len()
3127        );
3128        ff2.extend_from_slice(&compressed);
3129        ff2.extend_from_slice(b"\nendstream");
3130        builder.objects.push(PdfObject {
3131            id: fontfile2_id,
3132            data: ff2,
3133        });
3134
3135        // 2. FontDescriptor.
3136        let fd_id = builder.objects.len();
3137        let cap_height =
3138            (face.capital_height().unwrap_or_else(|| face.ascender()) as f64 * scale) as i32;
3139        let fd = format!(
3140            "<< /Type /FontDescriptor /FontName /{name} /Flags {flags} \
3141             /FontBBox [{x0} {y0} {x1} {y1}] /ItalicAngle {ia} \
3142             /Ascent {asc} /Descent {desc} /CapHeight {cap} /StemV {stem} \
3143             /FontFile2 {ff2} 0 R >>",
3144            name = pdf_name,
3145            flags = std_font.descriptor_flags(),
3146            x0 = (bbox.x_min as f64 * scale) as i32,
3147            y0 = (bbox.y_min as f64 * scale) as i32,
3148            x1 = (bbox.x_max as f64 * scale) as i32,
3149            y1 = (bbox.y_max as f64 * scale) as i32,
3150            ia = if key.italic { -12 } else { 0 },
3151            asc = (face.ascender() as f64 * scale) as i32,
3152            desc = (face.descender() as f64 * scale) as i32,
3153            cap = cap_height,
3154            stem = if key.weight >= 700 { 120 } else { 80 },
3155            ff2 = fontfile2_id,
3156        );
3157        builder.objects.push(PdfObject {
3158            id: fd_id,
3159            data: fd.into_bytes(),
3160        });
3161
3162        // 3. Simple TrueType font dict — base-14 AFM widths + WinAnsiEncoding,
3163        //    with the PDF/A width carve-out.
3164        //
3165        // For most glyphs the substitute's advance equals the base-14 AFM
3166        // width (Liberation is metric-compatible), so we declare the AFM value
3167        // and positioning stays exact. For the handful of rare accent/symbol
3168        // glyphs per proportional family where they diverge (e.g. macron,
3169        // grave, middot, ÷, ±, quotesingle, µ), we declare the substitute's
3170        // OWN advance instead — so /Widths agrees with the embedded program,
3171        // which ISO 19005 (PDF/A) requires and veraPDF's PDF/A profile checks.
3172        // The trade is a sub-glyph advance drift on those rare glyphs, which
3173        // real documents almost never contain. (Liberation Mono has zero
3174        // divergent glyphs; the carve-out is a no-op there.)
3175        let declared_widths: Vec<u16> = metrics
3176            .widths
3177            .iter()
3178            .enumerate()
3179            .map(|(i, &afm)| {
3180                let code = 32u8.wrapping_add(i as u8); // index 0 = WinAnsi code 32
3181                if let Some(ch) = crate::font::winansi_to_char(code) {
3182                    if let Some(gid) = face.glyph_index(ch) {
3183                        if let Some(adv) = face.glyph_hor_advance(gid) {
3184                            let hmtx = (adv as f64 * scale).round() as u16;
3185                            if (hmtx as i32 - afm as i32).abs() > 1 {
3186                                return hmtx;
3187                            }
3188                        }
3189                    }
3190                }
3191                afm
3192            })
3193            .collect();
3194        let widths_str: String = declared_widths
3195            .iter()
3196            .map(|w| w.to_string())
3197            .collect::<Vec<_>>()
3198            .join(" ");
3199        let obj_id = builder.objects.len();
3200        let font_dict = format!(
3201            "<< /Type /Font /Subtype /TrueType /BaseFont /{name} \
3202             /Encoding /WinAnsiEncoding \
3203             /FirstChar 32 /LastChar 255 /Widths [{w}] \
3204             /FontDescriptor {fd} 0 R >>",
3205            name = pdf_name,
3206            w = widths_str,
3207            fd = fd_id,
3208        );
3209        builder.objects.push(PdfObject {
3210            id: obj_id,
3211            data: font_dict.into_bytes(),
3212        });
3213        builder.font_objects.push((key.clone(), obj_id));
3214        // Record that this base-14 family is embedded (via substitution) so the
3215        // PDF/A all-fonts-embedded check accepts it — this is what lets PDF/A
3216        // and PDF/UA compose.
3217        builder.embedded_standard_fonts.insert(key.clone());
3218        true
3219    }
3220
3221    #[allow(clippy::too_many_arguments)]
3222    fn register_fonts(
3223        &self,
3224        builder: &mut PdfBuilder,
3225        pages: &[LayoutPage],
3226        font_context: &FontContext,
3227        pdf_ua: bool,
3228        pdfa: bool,
3229        pdf_version: crate::model::PdfVersion,
3230    ) -> Result<(), FormeError> {
3231        // Collect font usage: glyph IDs, chars, and glyph→char mapping per font
3232        let mut font_usage_map: HashMap<FontKey, FontUsage> = HashMap::new();
3233
3234        for page in pages {
3235            Self::collect_font_usage(&page.elements, &mut font_usage_map);
3236        }
3237
3238        let mut keys: Vec<FontKey> = font_usage_map.keys().cloned().collect();
3239
3240        // Sort for deterministic ordering, then dedup
3241        keys.sort_by(|a, b| {
3242            a.family
3243                .cmp(&b.family)
3244                .then(a.weight.cmp(&b.weight))
3245                .then(a.italic.cmp(&b.italic))
3246        });
3247        keys.dedup();
3248
3249        // Always have at least Helvetica
3250        if keys.is_empty() {
3251            keys.push(FontKey {
3252                family: "Helvetica".to_string(),
3253                weight: 400,
3254                italic: false,
3255            });
3256        }
3257
3258        for key in &keys {
3259            let font_data = font_context.resolve(&key.family, key.weight, key.italic);
3260
3261            match font_data {
3262                FontData::Standard(std_font) => {
3263                    let metrics = std_font.metrics();
3264
3265                    // PDF/UA + PDF/A require every font embedded, which the
3266                    // base-14 fonts are not. In pdfUa mode, if a
3267                    // metric-compatible substitute (Liberation, via
3268                    // @formepdf/fonts-standard) is registered, embed it as a
3269                    // SIMPLE TrueType carrying the base-14 AFM /Widths and
3270                    // WinAnsiEncoding — the content stream is untouched (same
3271                    // `(text) Tj` WinAnsi path, same positions), only the font
3272                    // dictionary gains an embedded program.
3273                    // pdfa alone needs the same substitution: its own
3274                    // embedded-fonts check (below the call site) counts a
3275                    // base-14 family as embedded ONLY via this path, so
3276                    // gating on pdfUa made pdfa-without-pdfUa error even
3277                    // with fonts-standard registered — the substitute was
3278                    // registered and never consulted.
3279                    if pdf_ua || pdfa || pdf_version == crate::model::PdfVersion::V2_0 {
3280                        if Self::emit_pdfua_embedded_standard(
3281                            builder,
3282                            key,
3283                            std_font,
3284                            &metrics,
3285                            font_context,
3286                        ) {
3287                            continue;
3288                        }
3289                        // PDF 2.0 removes the standard-14 provision —
3290                        // conforming readers need not ship these fonts, so
3291                        // non-embedded base-14 output is a bet on reader
3292                        // goodwill. Hard error by name, with the remedy,
3293                        // exactly like the pdfA contract.
3294                        if pdf_version == crate::model::PdfVersion::V2_0 {
3295                            let remedy = match std_font.liberation_family() {
3296                                Some(lib) => format!(
3297                                    "install @formepdf/fonts-standard and register its fonts (`for (const f of standardFonts()) Font.register(f)`) — Forme will embed the metric-compatible {lib} in its place"
3298                                ),
3299                                None => "register an embeddable TrueType font for this text                                          (Symbol/ZapfDingbats have no metric-compatible substitute)"
3300                                    .to_string(),
3301                            };
3302                            return Err(FormeError::FontError(format!(
3303                                "pdfVersion \"2.0\": font '{}' is not embedded. ISO 32000-2 removes the standard-14 provision, so every font must be embedded — {}.",
3304                                std_font.pdf_name(),
3305                                remedy,
3306                            )));
3307                        }
3308                        // Substitution didn't happen. If a metric-compatible
3309                        // substitute exists but wasn't registered, say so by
3310                        // name with the remedy — never silently emit a
3311                        // non-conforming file. (Symbol/ZapfDingbats have no
3312                        // substitute, so there is nothing to suggest.)
3313                        if let Some(lib) = std_font.liberation_family() {
3314                            builder.warnings.push(format!(
3315                                "pdfUa: font '{}' is not embedded, so the PDF will not conform to \
3316                                 PDF/UA (all fonts must be embedded). Install \
3317                                 @formepdf/fonts-standard and register its fonts \
3318                                 (`for (const f of standardFonts()) Font.register(f)`) — Forme \
3319                                 will then embed the metric-compatible {} in its place.",
3320                                std_font.pdf_name(),
3321                                lib,
3322                            ));
3323                        }
3324                    }
3325
3326                    let obj_id = builder.objects.len();
3327                    // Include /Widths so PDF viewers use our exact metrics
3328                    // instead of substituting a system font with different widths
3329                    let widths_str: String = metrics
3330                        .widths
3331                        .iter()
3332                        .map(|w| w.to_string())
3333                        .collect::<Vec<_>>()
3334                        .join(" ");
3335                    let font_dict = format!(
3336                        "<< /Type /Font /Subtype /Type1 /BaseFont /{} \
3337                         /Encoding /WinAnsiEncoding \
3338                         /FirstChar 32 /LastChar 255 /Widths [{}] >>",
3339                        std_font.pdf_name(),
3340                        widths_str,
3341                    );
3342                    builder.objects.push(PdfObject {
3343                        id: obj_id,
3344                        data: font_dict.into_bytes(),
3345                    });
3346                    builder.font_objects.push((key.clone(), obj_id));
3347                }
3348                FontData::Custom { data, .. } => {
3349                    let usage = font_usage_map.get(key);
3350                    let used_glyph_ids = usage.map(|u| &u.glyph_ids);
3351                    let used_chars = usage.map(|u| &u.chars);
3352                    let glyph_to_text = usage.map(|u| &u.glyph_to_text);
3353                    let type0_obj_id = Self::write_custom_font_objects(
3354                        builder,
3355                        key,
3356                        data,
3357                        used_glyph_ids.cloned().unwrap_or_default(),
3358                        used_chars.cloned().unwrap_or_default(),
3359                        glyph_to_text.cloned().unwrap_or_default(),
3360                    )?;
3361                    builder.font_objects.push((key.clone(), type0_obj_id));
3362                }
3363            }
3364        }
3365
3366        Ok(())
3367    }
3368
3369    /// Collect font usage data from layout elements: used chars, glyph IDs, and glyph→char mapping.
3370    fn collect_font_usage(
3371        elements: &[LayoutElement],
3372        font_usage: &mut HashMap<FontKey, FontUsage>,
3373    ) {
3374        for element in elements {
3375            let lines_opt = match &element.draw {
3376                DrawCommand::Text { lines, .. } => Some(lines),
3377                DrawCommand::Watermark { lines, .. } => Some(lines),
3378                _ => None,
3379            };
3380            if let Some(lines) = lines_opt {
3381                for line in lines {
3382                    for glyph in &line.glyphs {
3383                        let italic =
3384                            matches!(glyph.font_style, FontStyle::Italic | FontStyle::Oblique);
3385                        let key = FontKey {
3386                            family: glyph.font_family.to_string(),
3387                            weight: glyph.font_weight,
3388                            italic,
3389                        };
3390                        let usage = font_usage.entry(key).or_insert_with(|| FontUsage {
3391                            chars: HashSet::new(),
3392                            glyph_ids: HashSet::new(),
3393                            glyph_to_text: HashMap::new(),
3394                        });
3395                        usage.chars.insert(glyph.char_value);
3396                        // A page-number sentinel becomes digits at write
3397                        // time — subset all ten for this font, or the
3398                        // substituted numbers would render as .notdef
3399                        // (char_to_gid would have no digit entries).
3400                        if glyph.char_value == PAGE_NUMBER_SENTINEL
3401                            || glyph.char_value == TOTAL_PAGES_SENTINEL
3402                        {
3403                            usage.chars.extend('0'..='9');
3404                        }
3405                        usage.glyph_ids.insert(glyph.glyph_id);
3406                        record_glyph_text(&mut usage.glyph_to_text, glyph);
3407                    }
3408                }
3409            }
3410            Self::collect_font_usage(&element.children, font_usage);
3411        }
3412    }
3413
3414    /// Walk all pages, create XObject PDF objects for each image,
3415    /// Register PDF Shading dictionaries for every Rect with a
3416    /// `background_gradient`. Walks the element tree once per page in
3417    /// pre-order (same order `write_element` recurses) so the counter-
3418    /// indexed `shading_map` lookups during emission match.
3419    fn register_shadings(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3420        for (page_idx, page) in pages.iter().enumerate() {
3421            let mut counter = 0usize;
3422            Self::collect_shadings_recursive(&page.elements, page_idx, &mut counter, builder);
3423        }
3424    }
3425
3426    fn collect_shadings_recursive(
3427        elements: &[LayoutElement],
3428        page_idx: usize,
3429        counter: &mut usize,
3430        builder: &mut PdfBuilder,
3431    ) {
3432        for element in elements {
3433            if let DrawCommand::Rect {
3434                background_gradient: Some(gradient),
3435                ..
3436            } = &element.draw
3437            {
3438                let ordinal = *counter;
3439                *counter += 1;
3440                let (obj_id, name) =
3441                    Self::write_shading_objects(builder, gradient, element, ordinal);
3442                builder
3443                    .shading_map
3444                    .insert((page_idx, ordinal), (obj_id, name));
3445            }
3446            Self::collect_shadings_recursive(&element.children, page_idx, counter, builder);
3447        }
3448    }
3449
3450    /// Build the Function + Shading PDF objects for one gradient. Returns
3451    /// (shading_obj_id, "Sh{n}"). 2-stop gradients use a single Type 2
3452    /// (exponential) function. 3+ stop gradients use a Type 3 (stitching)
3453    /// function combining N-1 Type 2 sub-functions, with /Bounds at each
3454    /// interior stop position.
3455    fn write_shading_objects(
3456        builder: &mut PdfBuilder,
3457        gradient: &crate::style::Background,
3458        element: &LayoutElement,
3459        ordinal: usize,
3460    ) -> (usize, String) {
3461        use crate::style::Background;
3462        use crate::style::GradientStop;
3463
3464        // Materialize the gradient as a normalized stop list (positions
3465        // sorted ascending, clamped to [0,1]). Solid-color backgrounds
3466        // collapse to two identical stops at 0 and 1.
3467        let black = Color {
3468            r: 0.0,
3469            g: 0.0,
3470            b: 0.0,
3471            a: 1.0,
3472        };
3473        let stops: Vec<GradientStop> = match gradient {
3474            Background::Color(c) => vec![
3475                GradientStop {
3476                    position: 0.0,
3477                    color: *c,
3478                },
3479                GradientStop {
3480                    position: 1.0,
3481                    color: *c,
3482                },
3483            ],
3484            Background::Linear(g) => normalize_gradient_stops(&g.stops, black),
3485            Background::Radial(g) => normalize_gradient_stops(&g.stops, black),
3486        };
3487
3488        // Build the color-interpolation function. With <=2 stops we emit
3489        // a single Type 2 (exponential) function; with 3+ stops we emit a
3490        // Type 3 (stitching) function combining N-1 Type 2 sub-functions.
3491        let function_id = if stops.len() <= 2 {
3492            let c0 = stops.first().map(|s| s.color).unwrap_or(black);
3493            let c1 = stops.last().map(|s| s.color).unwrap_or(c0);
3494            let id = builder.objects.len();
3495            let data = format!(
3496                "<< /FunctionType 2 /Domain [0 1] /C0 [{:.4} {:.4} {:.4}] /C1 [{:.4} {:.4} {:.4}] /N 1 >>",
3497                c0.r, c0.g, c0.b, c1.r, c1.g, c1.b,
3498            );
3499            builder.objects.push(PdfObject {
3500                id,
3501                data: data.into_bytes(),
3502            });
3503            id
3504        } else {
3505            // Reserve N-1 Type 2 sub-function objects.
3506            let mut sub_ids: Vec<usize> = Vec::with_capacity(stops.len() - 1);
3507            for window in stops.windows(2) {
3508                let c0 = window[0].color;
3509                let c1 = window[1].color;
3510                let id = builder.objects.len();
3511                let data = format!(
3512                    "<< /FunctionType 2 /Domain [0 1] /C0 [{:.4} {:.4} {:.4}] /C1 [{:.4} {:.4} {:.4}] /N 1 >>",
3513                    c0.r, c0.g, c0.b, c1.r, c1.g, c1.b,
3514                );
3515                builder.objects.push(PdfObject {
3516                    id,
3517                    data: data.into_bytes(),
3518                });
3519                sub_ids.push(id);
3520            }
3521            // Bounds = interior stop positions (exclude first and last).
3522            // Encode = [0 1] per sub-function — each sub-function uses its
3523            // full domain regardless of the bound interval width.
3524            let bounds: Vec<String> = stops[1..stops.len() - 1]
3525                .iter()
3526                .map(|s| format!("{:.4}", s.position))
3527                .collect();
3528            let encode: Vec<&str> = (0..sub_ids.len()).map(|_| "0 1").collect();
3529            let functions: Vec<String> = sub_ids.iter().map(|i| format!("{} 0 R", i)).collect();
3530            let id = builder.objects.len();
3531            let data = format!(
3532                "<< /FunctionType 3 /Domain [0 1] /Functions [{}] /Bounds [{}] /Encode [{}] >>",
3533                functions.join(" "),
3534                bounds.join(" "),
3535                encode.join(" "),
3536            );
3537            builder.objects.push(PdfObject {
3538                id,
3539                data: data.into_bytes(),
3540            });
3541            id
3542        };
3543
3544        // Element dimensions. The shading's coord space is local to the
3545        // rect (we cm-translate to the rect's bottom-left at draw time),
3546        // so x/y aren't needed here — only w/h.
3547        let _ = element.x;
3548        let _ = element.y;
3549        let w = element.width;
3550        let h = element.height;
3551
3552        let shading_id = builder.objects.len();
3553        let shading_data = match gradient {
3554            Background::Linear(g) => {
3555                // CSS angle convention: 0deg = bottom→top, 90deg = left→right,
3556                // 180deg = top→bottom (clockwise from up).
3557                // Our layout uses Y-down; PDF uses Y-up. Compute the axis
3558                // in PDF coords directly: dx = sin(θ), dy = cos(θ) where
3559                // CSS 0deg points "up" (positive PDF y).
3560                // CSS angle convention: 0deg = bottom→top, 180deg =
3561                // top→bottom. PDF y-axis is flipped vs CSS-on-screen, so
3562                // dy comes from cos(θ) directly (CSS 0deg points "up"
3563                // which is +y in PDF coords).
3564                let theta = g.angle_deg.to_radians();
3565                let dx = theta.sin();
3566                let dy = theta.cos();
3567                // Axis length spans the rect along the gradient direction
3568                // (CSS spec covering box).
3569                let axis_len = w * dx.abs() + h * dy.abs();
3570                // Coords are RELATIVE to the rect's bottom-left corner
3571                // (the cm-translate at draw time positions absolutely).
3572                let cx_rel = w / 2.0;
3573                let cy_rel = h / 2.0;
3574                let half = axis_len / 2.0;
3575                let x0 = cx_rel - dx * half;
3576                let y0 = cy_rel - dy * half;
3577                let x1 = cx_rel + dx * half;
3578                let y1 = cy_rel + dy * half;
3579                format!(
3580                    "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [{:.3} {:.3} {:.3} {:.3}] /Function {} 0 R /Extend [true true] >>",
3581                    x0, y0, x1, y1, function_id,
3582                )
3583            }
3584            Background::Radial(_) => {
3585                // Circle from center, inner r=0, outer r=max(w/2, h/2),
3586                // expressed relative to rect bottom-left.
3587                let cx_rel = w / 2.0;
3588                let cy_rel = h / 2.0;
3589                let r_outer = (w / 2.0).max(h / 2.0);
3590                format!(
3591                    "<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [{:.3} {:.3} 0 {:.3} {:.3} {:.3}] /Function {} 0 R /Extend [true true] >>",
3592                    cx_rel, cy_rel, cx_rel, cy_rel, r_outer, function_id,
3593                )
3594            }
3595            Background::Color(_) => {
3596                // Solid: emit a constant 1.0-stop function via the Coords
3597                // collapsed to a point. (Shouldn't normally hit this path —
3598                // background_gradient should only be set for true gradients.)
3599                format!(
3600                    "<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 0 0] /Function {} 0 R /Extend [true true] >>",
3601                    function_id,
3602                )
3603            }
3604        };
3605        builder.objects.push(PdfObject {
3606            id: shading_id,
3607            data: shading_data.into_bytes(),
3608        });
3609        (shading_id, format!("Sh{}", ordinal))
3610    }
3611
3612    /// Decode and embed each page's optional `background_image` as a PDF
3613    /// XObject. Identical URLs across pages share a single XObject (the
3614    /// `page_background_url_cache` does the deduplication).
3615    fn register_page_background_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3616        for (page_idx, page) in pages.iter().enumerate() {
3617            let Some(src) = &page.config.background_image else {
3618                continue;
3619            };
3620            // Reuse the XObject if a previous page used the same source.
3621            if let Some(&entry) = builder.page_background_url_cache.get(src) {
3622                builder.page_background_image_map.insert(page_idx, entry);
3623                continue;
3624            }
3625            // Decode + embed; on failure, log a warning and skip the
3626            // background for that page (don't fail the whole render).
3627            match crate::image_loader::load_image(src) {
3628                Ok(image_data) => {
3629                    let img_idx = builder.image_objects.len();
3630                    let dims = (img_idx, image_data.width_px, image_data.height_px);
3631                    let xobj_id = Self::write_image_xobject(builder, &image_data);
3632                    builder.image_objects.push(xobj_id);
3633                    builder.page_background_image_map.insert(page_idx, dims);
3634                    builder.page_background_url_cache.insert(src.clone(), dims);
3635                }
3636                Err(e) => {
3637                    eprintln!("[forme] page background image failed to load: {}", e);
3638                }
3639            }
3640        }
3641    }
3642
3643    /// Emit the page background paint (q + optional ExtGState + cm + Do + Q)
3644    /// at the start of a page's content stream. Sizing follows CSS
3645    /// `background-size` semantics (fill/cover/contain) with positioning
3646    /// per `background-position`.
3647    fn write_page_background(
3648        &self,
3649        stream: &mut String,
3650        page: &LayoutPage,
3651        page_bg: (usize, u32, u32),
3652        builder: &PdfBuilder,
3653    ) {
3654        use crate::model::{BackgroundPosition, BackgroundSize};
3655        let (img_idx, iw_px, ih_px) = page_bg;
3656        let page_w = page.width;
3657        let page_h = page.height;
3658        let iw = iw_px as f64;
3659        let ih = ih_px as f64;
3660
3661        let size = page.config.background_size.unwrap_or_default();
3662        let (dest_w, dest_h) = match size {
3663            BackgroundSize::Fill => (page_w, page_h),
3664            BackgroundSize::Cover => {
3665                let s = (page_w / iw).max(page_h / ih);
3666                (iw * s, ih * s)
3667            }
3668            BackgroundSize::Contain => {
3669                let s = (page_w / iw).min(page_h / ih);
3670                (iw * s, ih * s)
3671            }
3672        };
3673
3674        // Position: for `fill`, dest matches page exactly so position is
3675        // moot; otherwise place per `background-position` against the
3676        // page's bounding box.
3677        let position = page.config.background_position.unwrap_or_default();
3678        // PDF Y origin is bottom-left, so "top" means pdf_y = page_h - dest_h
3679        // and "bottom" means pdf_y = 0.
3680        let (dest_x, dest_y) = match position {
3681            BackgroundPosition::TopLeft => (0.0, page_h - dest_h),
3682            BackgroundPosition::TopRight => (page_w - dest_w, page_h - dest_h),
3683            BackgroundPosition::BottomLeft => (0.0, 0.0),
3684            BackgroundPosition::BottomRight => (page_w - dest_w, 0.0),
3685            BackgroundPosition::Center => ((page_w - dest_w) / 2.0, (page_h - dest_h) / 2.0),
3686        };
3687
3688        // Optional ExtGState wrap for backgroundOpacity < 1.0.
3689        let opacity = page.config.background_opacity.unwrap_or(1.0);
3690        let needs_opacity = opacity < 1.0;
3691        if needs_opacity {
3692            if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
3693                let _ = writeln!(stream, "q\n/{} gs", gs_name);
3694            } else {
3695                let _ = writeln!(stream, "q");
3696            }
3697        } else {
3698            let _ = writeln!(stream, "q");
3699        }
3700        // PDF cm: a b c d e f → matrix [[a c e][b d f][0 0 1]]; for a
3701        // simple scale + translate, that's: w 0 0 h x y cm.
3702        let _ = writeln!(
3703            stream,
3704            "{:.2} 0 0 {:.2} {:.2} {:.2} cm\n/Im{} Do\nQ",
3705            dest_w, dest_h, dest_x, dest_y, img_idx,
3706        );
3707    }
3708
3709    /// and populate the image_index_map for content stream reference.
3710    fn register_images(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3711        for (page_idx, page) in pages.iter().enumerate() {
3712            let mut element_counter = 0usize;
3713            Self::collect_images_recursive(&page.elements, page_idx, &mut element_counter, builder);
3714        }
3715    }
3716
3717    fn collect_images_recursive(
3718        elements: &[LayoutElement],
3719        page_idx: usize,
3720        element_counter: &mut usize,
3721        builder: &mut PdfBuilder,
3722    ) {
3723        for element in elements {
3724            match &element.draw {
3725                DrawCommand::Image { image_data } => {
3726                    let elem_idx = *element_counter;
3727                    *element_counter += 1;
3728
3729                    let img_idx = builder.image_objects.len();
3730                    let xobj_id = Self::write_image_xobject(builder, image_data);
3731                    builder.image_objects.push(xobj_id);
3732                    builder
3733                        .image_index_map
3734                        .insert((page_idx, elem_idx), img_idx);
3735                }
3736                DrawCommand::ImagePlaceholder => {
3737                    *element_counter += 1;
3738                }
3739                _ => {
3740                    Self::collect_images_recursive(
3741                        &element.children,
3742                        page_idx,
3743                        element_counter,
3744                        builder,
3745                    );
3746                }
3747            }
3748        }
3749    }
3750
3751    /// Collect unique opacity values from all pages and create ExtGState PDF objects.
3752    fn register_ext_gstates(&self, builder: &mut PdfBuilder, pages: &[LayoutPage]) {
3753        let mut unique_opacities: Vec<f64> = Vec::new();
3754        for page in pages {
3755            Self::collect_opacities_recursive(&page.elements, &mut unique_opacities);
3756            // Page background opacity (independent of element-level alphas).
3757            if let Some(o) = page.config.background_opacity {
3758                if o < 1.0 {
3759                    unique_opacities.push(o);
3760                }
3761            }
3762        }
3763        unique_opacities.sort_by(|a, b| a.partial_cmp(b).unwrap());
3764        unique_opacities.dedup();
3765
3766        for (idx, &opacity) in unique_opacities.iter().enumerate() {
3767            let obj_id = builder.objects.len();
3768            let gs_name = format!("GS{}", idx);
3769            let obj_data = format!(
3770                "<< /Type /ExtGState /ca {:.4} /CA {:.4} >>",
3771                opacity, opacity
3772            );
3773            builder.objects.push(PdfObject {
3774                id: obj_id,
3775                data: obj_data.into_bytes(),
3776            });
3777            let key = opacity.to_bits();
3778            builder.ext_gstate_map.insert(key, (obj_id, gs_name));
3779        }
3780    }
3781
3782    fn collect_opacities_recursive(elements: &[LayoutElement], opacities: &mut Vec<f64>) {
3783        for element in elements {
3784            // Element-level opacity wraps the whole subtree (including
3785            // children) in `q\n/GS{n} gs ... Q` so descendants render at
3786            // the cumulative alpha. Collect it independently of the
3787            // per-DrawCommand opacities below — they coexist for now,
3788            // and the per-Rect/Text/Watermark opacities are gradually
3789            // being deprecated in favor of the element-level one.
3790            if element.opacity < 1.0 {
3791                opacities.push(element.opacity);
3792            }
3793            // Shadow color alpha — needs its own ExtGState entry so the
3794            // shadow renders semi-transparently independent of the
3795            // element's opacity.
3796            if let DrawCommand::Rect {
3797                box_shadow: Some(shadow),
3798                ..
3799            } = &element.draw
3800            {
3801                if shadow.color.a < 1.0 {
3802                    opacities.push(shadow.color.a);
3803                }
3804            }
3805            match &element.draw {
3806                DrawCommand::Rect { opacity, .. }
3807                | DrawCommand::Text { opacity, .. }
3808                | DrawCommand::Watermark { opacity, .. }
3809                    if *opacity < 1.0 =>
3810                {
3811                    opacities.push(*opacity);
3812                }
3813                DrawCommand::Chart { primitives } => {
3814                    for prim in primitives {
3815                        if let crate::chart::ChartPrimitive::FilledPath { opacity, .. } = prim {
3816                            if *opacity < 1.0 {
3817                                opacities.push(*opacity);
3818                            }
3819                        }
3820                    }
3821                }
3822                DrawCommand::Svg { commands, .. } => {
3823                    for cmd in commands {
3824                        if let crate::svg::SvgCommand::SetOpacity(opacity) = cmd {
3825                            if *opacity < 1.0 {
3826                                opacities.push(*opacity);
3827                            }
3828                        }
3829                    }
3830                }
3831                _ => {}
3832            }
3833            Self::collect_opacities_recursive(&element.children, opacities);
3834        }
3835    }
3836
3837    /// Build the ExtGState resource dict entries for a page.
3838    fn build_ext_gstate_resource_dict(&self, builder: &PdfBuilder) -> String {
3839        if builder.ext_gstate_map.is_empty() {
3840            return String::new();
3841        }
3842        let mut entries: Vec<(&String, usize)> = builder
3843            .ext_gstate_map
3844            .values()
3845            .map(|(obj_id, name)| (name, *obj_id))
3846            .collect();
3847        entries.sort_by_key(|(name, _)| (*name).clone());
3848        entries
3849            .iter()
3850            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
3851            .collect::<Vec<_>>()
3852            .join(" ")
3853    }
3854
3855    /// Write a single image as one or two XObject PDF objects.
3856    /// Returns the main XObject ID.
3857    fn write_image_xobject(
3858        builder: &mut PdfBuilder,
3859        image: &crate::image_loader::LoadedImage,
3860    ) -> usize {
3861        use crate::image_loader::{ImagePixelData, JpegColorSpace};
3862
3863        match &image.pixel_data {
3864            ImagePixelData::Jpeg { data, color_space } => {
3865                let color_space_str = match color_space {
3866                    JpegColorSpace::DeviceRGB => "/DeviceRGB",
3867                    JpegColorSpace::DeviceGray => "/DeviceGray",
3868                };
3869
3870                let obj_id = builder.objects.len();
3871                let mut obj_data: Vec<u8> = Vec::new();
3872                let _ = write!(
3873                    obj_data,
3874                    "<< /Type /XObject /Subtype /Image \
3875                     /Width {} /Height {} \
3876                     /ColorSpace {} \
3877                     /BitsPerComponent 8 \
3878                     /Filter /DCTDecode \
3879                     /Length {} >>\nstream\n",
3880                    image.width_px,
3881                    image.height_px,
3882                    color_space_str,
3883                    data.len()
3884                );
3885                obj_data.extend_from_slice(data);
3886                obj_data.extend_from_slice(b"\nendstream");
3887                builder.objects.push(PdfObject {
3888                    id: obj_id,
3889                    data: obj_data,
3890                });
3891                obj_id
3892            }
3893
3894            ImagePixelData::Decoded { rgb, alpha } => {
3895                // Write SMask first if alpha channel exists
3896                let smask_id = alpha.as_ref().map(|alpha_data| {
3897                    let compressed_alpha = compress_to_vec_zlib(alpha_data, 6);
3898                    let smask_obj_id = builder.objects.len();
3899                    let mut smask_data: Vec<u8> = Vec::new();
3900                    let _ = write!(
3901                        smask_data,
3902                        "<< /Type /XObject /Subtype /Image \
3903                         /Width {} /Height {} \
3904                         /ColorSpace /DeviceGray \
3905                         /BitsPerComponent 8 \
3906                         /Filter /FlateDecode \
3907                         /Length {} >>\nstream\n",
3908                        image.width_px,
3909                        image.height_px,
3910                        compressed_alpha.len()
3911                    );
3912                    smask_data.extend_from_slice(&compressed_alpha);
3913                    smask_data.extend_from_slice(b"\nendstream");
3914                    builder.objects.push(PdfObject {
3915                        id: smask_obj_id,
3916                        data: smask_data,
3917                    });
3918                    smask_obj_id
3919                });
3920
3921                // Write main RGB image XObject
3922                let compressed_rgb = compress_to_vec_zlib(rgb, 6);
3923                let obj_id = builder.objects.len();
3924                let mut obj_data: Vec<u8> = Vec::new();
3925
3926                let smask_ref = smask_id
3927                    .map(|id| format!(" /SMask {} 0 R", id))
3928                    .unwrap_or_default();
3929
3930                let _ = write!(
3931                    obj_data,
3932                    "<< /Type /XObject /Subtype /Image \
3933                     /Width {} /Height {} \
3934                     /ColorSpace /DeviceRGB \
3935                     /BitsPerComponent 8 \
3936                     /Filter /FlateDecode \
3937                     /Length {}{} >>\nstream\n",
3938                    image.width_px,
3939                    image.height_px,
3940                    compressed_rgb.len(),
3941                    smask_ref
3942                );
3943                obj_data.extend_from_slice(&compressed_rgb);
3944                obj_data.extend_from_slice(b"\nendstream");
3945                builder.objects.push(PdfObject {
3946                    id: obj_id,
3947                    data: obj_data,
3948                });
3949                obj_id
3950            }
3951        }
3952    }
3953
3954    /// Build the /XObject resource dict entries for a specific page.
3955    /// Build the page's `/Shading << ... >>` resource dict from the
3956    /// shading_map entries that match `page_idx`.
3957    fn build_shading_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
3958        let mut entries: Vec<(String, usize)> = builder
3959            .shading_map
3960            .iter()
3961            .filter(|(&(p, _), _)| p == page_idx)
3962            .map(|(_, (obj_id, name))| (name.clone(), *obj_id))
3963            .collect();
3964        if entries.is_empty() {
3965            return String::new();
3966        }
3967        entries.sort_by(|a, b| a.0.cmp(&b.0));
3968        entries
3969            .iter()
3970            .map(|(name, obj_id)| format!("/{} {} 0 R", name, obj_id))
3971            .collect::<Vec<_>>()
3972            .join(" ")
3973    }
3974
3975    fn build_xobject_resource_dict(&self, page_idx: usize, builder: &PdfBuilder) -> String {
3976        let mut entries: Vec<(usize, usize)> = Vec::new();
3977        for (&(pidx, _), &img_idx) in &builder.image_index_map {
3978            if pidx == page_idx {
3979                let obj_id = builder.image_objects[img_idx];
3980                entries.push((img_idx, obj_id));
3981            }
3982        }
3983        // Include the page's background image (if any) so the `/Im{n} Do`
3984        // operator at the start of the content stream resolves.
3985        if let Some(&(img_idx, _, _)) = builder.page_background_image_map.get(&page_idx) {
3986            let obj_id = builder.image_objects[img_idx];
3987            entries.push((img_idx, obj_id));
3988        }
3989        if entries.is_empty() {
3990            return String::new();
3991        }
3992        entries.sort_by_key(|(idx, _)| *idx);
3993        entries.dedup();
3994        entries
3995            .iter()
3996            .map(|(idx, obj_id)| format!("/Im{} {} 0 R", idx, obj_id))
3997            .collect::<Vec<_>>()
3998            .join(" ")
3999    }
4000
4001    /// Write the 5 CIDFont PDF objects for a custom TrueType font.
4002    /// Returns the object ID of the Type0 root font dictionary.
4003    ///
4004    /// `used_glyph_ids`: original glyph IDs from shaping (from PositionedGlyph.glyph_id).
4005    /// `used_chars`: characters used (for char→gid fallback, e.g., page number placeholders).
4006    /// `glyph_to_text_map`: maps original glyph ID → the text it stands for (for ToUnicode CMap).
4007    fn write_custom_font_objects(
4008        builder: &mut PdfBuilder,
4009        key: &FontKey,
4010        ttf_data: &[u8],
4011        used_glyph_ids: HashSet<u16>,
4012        used_chars: HashSet<char>,
4013        glyph_to_text_map: HashMap<u16, String>,
4014    ) -> Result<usize, FormeError> {
4015        let face = ttf_parser::Face::parse(ttf_data, 0).map_err(|e| {
4016            FormeError::FontError(format!(
4017                "Failed to parse TTF data for font '{}': {}",
4018                key.family, e
4019            ))
4020        })?;
4021
4022        let units_per_em = face.units_per_em();
4023        let ascender = face.ascender();
4024        let descender = face.descender();
4025
4026        // Build char → original glyph ID mapping (for fallback/placeholders)
4027        let mut char_to_orig_gid: HashMap<char, u16> = HashMap::new();
4028        for &ch in &used_chars {
4029            if let Some(gid) = face.glyph_index(ch) {
4030                char_to_orig_gid.insert(ch, gid.0);
4031            }
4032        }
4033
4034        // Combine shaped glyph IDs + char-based glyph IDs for subsetting.
4035        // This ensures ligature glyphs (from shaping) AND individual char glyphs
4036        // (for placeholder fallback) are all included.
4037        let mut all_orig_gids: HashSet<u16> = used_glyph_ids.clone();
4038        for &gid in char_to_orig_gid.values() {
4039            all_orig_gids.insert(gid);
4040        }
4041
4042        // Subset the font to only include used glyphs
4043        let (embed_ttf, gid_remap) = match subset_ttf(ttf_data, &all_orig_gids) {
4044            Ok(subset_result) => (subset_result.ttf_data, subset_result.gid_remap),
4045            Err(_) => {
4046                // Subsetting failed — fall back to embedding the full font (identity remap)
4047                let identity: HashMap<u16, u16> =
4048                    all_orig_gids.iter().map(|&gid| (gid, gid)).collect();
4049                (ttf_data.to_vec(), identity)
4050            }
4051        };
4052
4053        // Build char→new_gid mapping (for placeholder fallback in content stream)
4054        let char_to_gid: HashMap<char, u16> = char_to_orig_gid
4055            .iter()
4056            .filter_map(|(&ch, &orig_gid)| gid_remap.get(&orig_gid).map(|&new_gid| (ch, new_gid)))
4057            .collect();
4058
4059        // Build glyph_id→new_gid mapping (for shaped content stream)
4060        let gid_remap_for_embed = gid_remap.clone();
4061
4062        // Build new_gid→text mapping for ToUnicode CMap
4063        let mut new_gid_to_text: HashMap<u16, String> = HashMap::new();
4064        // From shaped glyph→text mapping
4065        for (orig_gid, text) in &glyph_to_text_map {
4066            if let Some(&new_gid) = gid_remap.get(orig_gid) {
4067                new_gid_to_text
4068                    .entry(new_gid)
4069                    .or_insert_with(|| text.clone());
4070            }
4071        }
4072        // Fill in from char→gid mapping too
4073        for (&ch, &new_gid) in &char_to_gid {
4074            new_gid_to_text
4075                .entry(new_gid)
4076                .or_insert_with(|| ch.to_string());
4077        }
4078
4079        let pdf_font_name = Self::sanitize_font_name(&key.family, key.weight, key.italic);
4080
4081        // 1. FontFile2 stream — compressed subset TTF bytes
4082        let compressed_ttf = compress_to_vec_zlib(&embed_ttf, 6);
4083        let fontfile2_id = builder.objects.len();
4084        let mut fontfile2_data: Vec<u8> = Vec::new();
4085        let _ = write!(
4086            fontfile2_data,
4087            "<< /Length {} /Length1 {} /Filter /FlateDecode >>\nstream\n",
4088            compressed_ttf.len(),
4089            embed_ttf.len()
4090        );
4091        fontfile2_data.extend_from_slice(&compressed_ttf);
4092        fontfile2_data.extend_from_slice(b"\nendstream");
4093        builder.objects.push(PdfObject {
4094            id: fontfile2_id,
4095            data: fontfile2_data,
4096        });
4097
4098        // Parse the subset font for metrics (width array uses subset GIDs)
4099        let subset_face = ttf_parser::Face::parse(&embed_ttf, 0).unwrap_or_else(|_| face.clone());
4100        let subset_upem = subset_face.units_per_em();
4101
4102        // 2. FontDescriptor
4103        let font_descriptor_id = builder.objects.len();
4104        let bbox = face.global_bounding_box();
4105        let scale = 1000.0 / units_per_em as f64;
4106        let bbox_str = format!(
4107            "[{} {} {} {}]",
4108            (bbox.x_min as f64 * scale) as i32,
4109            (bbox.y_min as f64 * scale) as i32,
4110            (bbox.x_max as f64 * scale) as i32,
4111            (bbox.y_max as f64 * scale) as i32,
4112        );
4113
4114        let flags = 4u32;
4115        let cap_height = face.capital_height().unwrap_or(ascender) as f64 * scale;
4116        let stem_v = if key.weight >= 700 { 120 } else { 80 };
4117
4118        let font_descriptor_dict = format!(
4119            "<< /Type /FontDescriptor /FontName /{} /Flags {} \
4120             /FontBBox {} /ItalicAngle {} \
4121             /Ascent {} /Descent {} /CapHeight {} /StemV {} \
4122             /FontFile2 {} 0 R >>",
4123            pdf_font_name,
4124            flags,
4125            bbox_str,
4126            if key.italic { -12 } else { 0 },
4127            (ascender as f64 * scale) as i32,
4128            (descender as f64 * scale) as i32,
4129            cap_height as i32,
4130            stem_v,
4131            fontfile2_id,
4132        );
4133        builder.objects.push(PdfObject {
4134            id: font_descriptor_id,
4135            data: font_descriptor_dict.into_bytes(),
4136        });
4137
4138        // 3. CIDFont dictionary (DescendantFont)
4139        let cidfont_id = builder.objects.len();
4140        // Build /W array using new_gid→width from subset face
4141        let (w_array, pdf_widths) =
4142            Self::build_w_array_from_gids(&gid_remap, &subset_face, subset_upem);
4143        let default_width = subset_face
4144            .glyph_hor_advance(ttf_parser::GlyphId(0))
4145            .map(|adv| (adv as f64 * 1000.0 / subset_upem as f64) as u32)
4146            .unwrap_or(1000);
4147        let cidfont_dict = format!(
4148            "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{} \
4149             /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> \
4150             /FontDescriptor {} 0 R /DW {} /W {} \
4151             /CIDToGIDMap /Identity >>",
4152            pdf_font_name, font_descriptor_id, default_width, w_array,
4153        );
4154        builder.objects.push(PdfObject {
4155            id: cidfont_id,
4156            data: cidfont_dict.into_bytes(),
4157        });
4158
4159        // 4. ToUnicode CMap
4160        let tounicode_id = builder.objects.len();
4161        let cmap_content = Self::build_tounicode_cmap_from_gids(&new_gid_to_text, &pdf_font_name);
4162        let compressed_cmap = compress_to_vec_zlib(cmap_content.as_bytes(), 6);
4163        let mut tounicode_data: Vec<u8> = Vec::new();
4164        let _ = write!(
4165            tounicode_data,
4166            "<< /Length {} /Filter /FlateDecode >>\nstream\n",
4167            compressed_cmap.len()
4168        );
4169        tounicode_data.extend_from_slice(&compressed_cmap);
4170        tounicode_data.extend_from_slice(b"\nendstream");
4171        builder.objects.push(PdfObject {
4172            id: tounicode_id,
4173            data: tounicode_data,
4174        });
4175
4176        // 5. Type0 font dictionary (the root, referenced by /Resources)
4177        let type0_id = builder.objects.len();
4178        let type0_dict = format!(
4179            "<< /Type /Font /Subtype /Type0 /BaseFont /{} \
4180             /Encoding /Identity-H \
4181             /DescendantFonts [{} 0 R] \
4182             /ToUnicode {} 0 R >>",
4183            pdf_font_name, cidfont_id, tounicode_id,
4184        );
4185        builder.objects.push(PdfObject {
4186            id: type0_id,
4187            data: type0_dict.into_bytes(),
4188        });
4189
4190        // Store embedding data for content stream encoding
4191        builder.custom_font_data.insert(
4192            key.clone(),
4193            CustomFontEmbedData {
4194                ttf_data: embed_ttf,
4195                gid_remap: gid_remap_for_embed,
4196                glyph_to_text: glyph_to_text_map,
4197                char_to_gid,
4198                pdf_widths,
4199                default_width,
4200                units_per_em,
4201                ascender,
4202                descender,
4203            },
4204        );
4205
4206        Ok(type0_id)
4207    }
4208
4209    /// Build the /W array from gid_remap (orig_gid→new_gid) using the subset face.
4210    fn build_w_array_from_gids(
4211        gid_remap: &HashMap<u16, u16>,
4212        face: &ttf_parser::Face,
4213        units_per_em: u16,
4214    ) -> (String, HashMap<u16, f64>) {
4215        let scale = 1000.0 / units_per_em as f64;
4216
4217        let mut entries: Vec<(u16, f64)> = Vec::new();
4218        let mut seen_gids: HashSet<u16> = HashSet::new();
4219
4220        for &new_gid in gid_remap.values() {
4221            if seen_gids.contains(&new_gid) {
4222                continue;
4223            }
4224            seen_gids.insert(new_gid);
4225            let advance = face
4226                .glyph_hor_advance(ttf_parser::GlyphId(new_gid))
4227                .unwrap_or(0);
4228            // Exact, not truncated: a truncated width drew every glyph up to
4229            // 1/1000 em narrower than layout placed it, a drift that grew
4230            // along the line.
4231            let width = advance as f64 * scale;
4232            entries.push((new_gid, width));
4233        }
4234
4235        entries.sort_by_key(|(gid, _)| *gid);
4236
4237        // Build the W array using individual entries: gid [width]
4238        let mut result = String::from("[");
4239        for (gid, width) in &entries {
4240            let _ = write!(result, " {} [{}]", gid, pdf_number(*width));
4241        }
4242        result.push_str(" ]");
4243        (result, entries.into_iter().collect())
4244    }
4245
4246    /// Build a ToUnicode CMap from new_gid → text mapping.
4247    ///
4248    /// Each destination is the text's UTF-16BE code units, so a ligature
4249    /// glyph maps to every char it stands for (`<0005> <006600660069>` for
4250    /// "ffi") and a non-BMP char is written as its surrogate pair, both as
4251    /// the PDF spec defines bfchar destinations (ISO 32000-1, 9.10.3).
4252    fn build_tounicode_cmap_from_gids(
4253        gid_to_text: &HashMap<u16, String>,
4254        font_name: &str,
4255    ) -> String {
4256        let mut gid_to_unicode: Vec<(u16, String)> = gid_to_text
4257            .iter()
4258            .filter(|(_, text)| !text.is_empty())
4259            .map(|(&gid, text)| {
4260                let hex: String = text
4261                    .encode_utf16()
4262                    .map(|unit| format!("{:04X}", unit))
4263                    .collect();
4264                (gid, hex)
4265            })
4266            .collect();
4267        gid_to_unicode.sort_by_key(|(gid, _)| *gid);
4268
4269        let mut cmap = String::new();
4270        let _ = writeln!(cmap, "/CIDInit /ProcSet findresource begin");
4271        let _ = writeln!(cmap, "12 dict begin");
4272        let _ = writeln!(cmap, "begincmap");
4273        let _ = writeln!(cmap, "/CIDSystemInfo");
4274        let _ = writeln!(
4275            cmap,
4276            "<< /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def"
4277        );
4278        let _ = writeln!(cmap, "/CMapName /{}-UTF16 def", font_name);
4279        let _ = writeln!(cmap, "/CMapType 2 def");
4280        let _ = writeln!(cmap, "1 begincodespacerange");
4281        let _ = writeln!(cmap, "<0000> <FFFF>");
4282        let _ = writeln!(cmap, "endcodespacerange");
4283
4284        // PDF spec limits beginbfchar to 100 entries per block
4285        for chunk in gid_to_unicode.chunks(100) {
4286            let _ = writeln!(cmap, "{} beginbfchar", chunk.len());
4287            for (gid, unicode) in chunk {
4288                let _ = writeln!(cmap, "<{:04X}> <{}>", gid, unicode);
4289            }
4290            let _ = writeln!(cmap, "endbfchar");
4291        }
4292
4293        let _ = writeln!(cmap, "endcmap");
4294        let _ = writeln!(cmap, "CMapName currentdict /CMap defineresource pop");
4295        let _ = writeln!(cmap, "end");
4296        let _ = writeln!(cmap, "end");
4297
4298        cmap
4299    }
4300
4301    /// Sanitize a font name for use as a PDF name object.
4302    /// Strips spaces and special characters, appends weight/style suffixes.
4303    fn sanitize_font_name(family: &str, weight: u32, italic: bool) -> String {
4304        let mut name: String = family
4305            .chars()
4306            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
4307            .collect();
4308
4309        if weight >= 700 {
4310            name.push_str("-Bold");
4311        }
4312        if italic {
4313            name.push_str("-Italic");
4314        }
4315
4316        // If name is empty after sanitization, use a fallback
4317        if name.is_empty() {
4318            name = "CustomFont".to_string();
4319        }
4320
4321        name
4322    }
4323
4324    fn build_font_resource_dict(&self, font_objects: &[(FontKey, usize)]) -> String {
4325        font_objects
4326            .iter()
4327            .enumerate()
4328            .map(|(i, (_, obj_id))| format!("/F{} {} 0 R", i, obj_id))
4329            .collect::<Vec<_>>()
4330            .join(" ")
4331    }
4332
4333    /// Look up the font index (/F0, /F1, etc.) for a given family+weight+style.
4334    fn font_index(
4335        &self,
4336        family: &str,
4337        weight: u32,
4338        font_style: FontStyle,
4339        font_objects: &[(FontKey, usize)],
4340    ) -> usize {
4341        let italic = matches!(font_style, FontStyle::Italic | FontStyle::Oblique);
4342
4343        // Exact weight match
4344        for (i, (key, _)) in font_objects.iter().enumerate() {
4345            if key.family == family && key.weight == weight && key.italic == italic {
4346                return i;
4347            }
4348        }
4349
4350        // Fallback: snapped weight (400/700)
4351        let snapped = if weight >= 600 { 700 } else { 400 };
4352        for (i, (key, _)) in font_objects.iter().enumerate() {
4353            if key.family == family && key.weight == snapped && key.italic == italic {
4354                return i;
4355            }
4356        }
4357
4358        // Fallback: try Helvetica with same weight/style
4359        for (i, (key, _)) in font_objects.iter().enumerate() {
4360            if key.family == "Helvetica" && key.weight == snapped && key.italic == italic {
4361                return i;
4362            }
4363        }
4364
4365        // Last resort: first font
4366        0
4367    }
4368
4369    /// Group consecutive glyphs by (font_family, font_weight, font_style, font_size, color)
4370    /// for multi-font text run rendering.
4371    /// The show operators (`TJ`, with `Ts` where a glyph is raised or
4372    /// lowered) that draw a registered-font group at its glyphs' layout
4373    /// positions, or `None` when every step matches the font's widths and
4374    /// nothing moves vertically (the group is then a plain `Tj`, as before).
4375    ///
4376    /// A viewer advances each glyph by its /W width plus Tc; layout placed it
4377    /// with the shaper's advances (kerning, zero-width marks) and the
4378    /// Knuth-Plass positions (justification, word spacing). Between glyphs
4379    /// `i` and `i + 1` the array carries `(drawn - wanted) * 1000 / size`, so
4380    /// every glyph lands on its own position. Tw cannot do the spacing part:
4381    /// these fonts are Type0 / Identity-H, and Tw applies only to the
4382    /// single-byte code 32 (ISO 32000-1 9.3.3).
4383    fn positioned_tj(
4384        group: &[&PositionedGlyph],
4385        gids: &[u16],
4386        embed: &CustomFontEmbedData,
4387        char_spacing: f64,
4388    ) -> Option<String> {
4389        let size = group.first()?.font_size;
4390        if size <= 0.0 {
4391            return None;
4392        }
4393        let mut adjustments = vec![0.0_f64; group.len()];
4394        let mut any = false;
4395        for i in 0..group.len().saturating_sub(1) {
4396            let width = embed
4397                .pdf_widths
4398                .get(&gids[i])
4399                .copied()
4400                .unwrap_or(embed.default_width as f64);
4401            let drawn = width * size / 1000.0 + char_spacing;
4402            let wanted = group[i + 1].x_offset - group[i].x_offset;
4403            let delta = drawn - wanted;
4404            if delta.abs() > 0.001 {
4405                adjustments[i] = delta * 1000.0 / size;
4406                any = true;
4407            }
4408        }
4409        let rises: Vec<f64> = group
4410            .iter()
4411            .map(|g| {
4412                if g.y_offset.abs() > 0.001 {
4413                    g.y_offset
4414                } else {
4415                    0.0
4416                }
4417            })
4418            .collect();
4419        if !any && rises.iter().all(|r| *r == 0.0) {
4420            return None;
4421        }
4422        // One TJ per run of equal rise; a change of rise (a mark the shaper
4423        // moved vertically) is a Ts between them, reset to 0 at the end. An
4424        // adjustment stays at the end of its glyph's segment: it moves the
4425        // pen to where the next glyph starts, whatever that glyph's rise.
4426        let mut out = String::new();
4427        let mut rise = 0.0_f64;
4428        let mut open = false;
4429        for (i, gid) in gids.iter().enumerate() {
4430            if rises[i] != rise {
4431                if open {
4432                    out.push_str("] TJ\n");
4433                    open = false;
4434                }
4435                let _ = writeln!(out, "{} Ts", pdf_number(rises[i]));
4436                rise = rises[i];
4437            }
4438            if !open {
4439                out.push('[');
4440                open = true;
4441            }
4442            let _ = write!(out, "<{:04X}>", gid);
4443            if adjustments[i] != 0.0 && i + 1 < gids.len() {
4444                let _ = write!(out, " {:.2} ", adjustments[i]);
4445            }
4446        }
4447        out.push_str("] TJ");
4448        if rise != 0.0 {
4449            out.push_str("\n0 Ts");
4450        }
4451        Some(out)
4452    }
4453
4454    fn group_glyphs_by_style(glyphs: &[PositionedGlyph]) -> Vec<Vec<&PositionedGlyph>> {
4455        Self::group_glyphs(glyphs, false)
4456    }
4457
4458    /// Group consecutive glyphs by style; with `split_on_href`, also where
4459    /// the per-glyph href changes, so a link's words form their own groups.
4460    fn group_glyphs(glyphs: &[PositionedGlyph], split_on_href: bool) -> Vec<Vec<&PositionedGlyph>> {
4461        if glyphs.is_empty() {
4462            return vec![];
4463        }
4464
4465        let mut groups: Vec<Vec<&PositionedGlyph>> = Vec::new();
4466        let mut current_group: Vec<&PositionedGlyph> = vec![&glyphs[0]];
4467
4468        for glyph in &glyphs[1..] {
4469            let prev = current_group.last().unwrap();
4470            let same_style = glyph.font_family == prev.font_family
4471                && glyph.font_weight == prev.font_weight
4472                && std::mem::discriminant(&glyph.font_style)
4473                    == std::mem::discriminant(&prev.font_style)
4474                && (glyph.font_size - prev.font_size).abs() < 0.01
4475                && Self::colors_equal(&glyph.color, &prev.color)
4476                && std::mem::discriminant(&glyph.text_decoration)
4477                    == std::mem::discriminant(&prev.text_decoration)
4478                && (!split_on_href || glyph.href == prev.href);
4479
4480            if same_style {
4481                current_group.push(glyph);
4482            } else {
4483                groups.push(current_group);
4484                current_group = vec![glyph];
4485            }
4486        }
4487        groups.push(current_group);
4488        groups
4489    }
4490
4491    /// For each group, the href of the inline link it is drawn in, or `None`.
4492    /// A link is a maximal run of groups with the same non-empty href; a run
4493    /// of nothing but whitespace is not one, matching `inline_link_spans`,
4494    /// which drops it (no ink, so no annotation and no /Link element).
4495    fn group_link_runs<'a>(groups: &[Vec<&'a PositionedGlyph>]) -> Vec<Option<&'a str>> {
4496        let hrefs: Vec<Option<&'a str>> = groups
4497            .iter()
4498            .map(|g| g[0].href.as_deref().filter(|h| !h.is_empty()))
4499            .collect();
4500        let mut out = vec![None; groups.len()];
4501        let mut i = 0;
4502        while i < groups.len() {
4503            let mut j = i + 1;
4504            while j < groups.len() && hrefs[j] == hrefs[i] {
4505                j += 1;
4506            }
4507            let inked = groups[i..j]
4508                .iter()
4509                .any(|g| g.iter().any(|gl| !gl.char_value.is_whitespace()));
4510            if hrefs[i].is_some() && inked {
4511                for slot in &mut out[i..j] {
4512                    *slot = hrefs[i];
4513                }
4514            }
4515            i = j;
4516        }
4517        out
4518    }
4519
4520    fn colors_equal(a: &Option<Color>, b: &Option<Color>) -> bool {
4521        match (a, b) {
4522            (None, None) => true,
4523            (Some(ca), Some(cb)) => {
4524                (ca.r - cb.r).abs() < 0.001
4525                    && (ca.g - cb.g).abs() < 0.001
4526                    && (ca.b - cb.b).abs() < 0.001
4527                    && (ca.a - cb.a).abs() < 0.001
4528            }
4529            _ => false,
4530        }
4531    }
4532
4533    /// Collect link annotations from layout elements recursively.
4534    /// When an element has an href, its rect covers all children, so we skip
4535    /// recursing into children to avoid duplicate annotations.
4536    /// Report links nested inside a different link. The outer link's
4537    /// annotation covers its whole box and `collect_link_annotations` does
4538    /// not look inside it, so an inner link (an element href or an inline
4539    /// run's) is dropped. HTML forbids nesting links, so the outer one
4540    /// winning is the defined behaviour; it used to be silent.
4541    fn warn_nested_links(
4542        elements: &[LayoutElement],
4543        outer: Option<&str>,
4544        warnings: &mut Vec<String>,
4545    ) {
4546        fn report(warnings: &mut Vec<String>, inner: &str, outer: &str) {
4547            let msg = format!(
4548                "render defect: a link to \"{inner}\" is inside a link to \"{outer}\". Links cannot be nested (HTML forbids it), so the outer link covers the whole area and the inner one was dropped"
4549            );
4550            if !warnings.contains(&msg) {
4551                warnings.push(msg);
4552            }
4553        }
4554        for el in elements {
4555            let own = el.href.as_deref().filter(|h| !h.is_empty());
4556            if let (Some(o), Some(h)) = (outer, own) {
4557                if h != o {
4558                    report(warnings, h, o);
4559                }
4560            }
4561            let enclosing = outer.or(own);
4562            if let Some(o) = enclosing {
4563                if let DrawCommand::Text { ref lines, .. } = el.draw {
4564                    for g in lines.iter().flat_map(|l| l.glyphs.iter()) {
4565                        if let Some(h) = g.href.as_deref().filter(|h| !h.is_empty()) {
4566                            if h != o {
4567                                report(warnings, h, o);
4568                            }
4569                        }
4570                    }
4571                }
4572            }
4573            Self::warn_nested_links(&el.children, enclosing, warnings);
4574        }
4575    }
4576
4577    fn collect_link_annotations(
4578        elements: &[LayoutElement],
4579        page_height: f64,
4580        annotations: &mut Vec<LinkAnnotation>,
4581    ) {
4582        for element in elements {
4583            if let Some(ref href) = element.href {
4584                if !href.is_empty() {
4585                    let pdf_y = page_height - element.y - element.height;
4586                    annotations.push(LinkAnnotation {
4587                        x: element.x,
4588                        y: pdf_y,
4589                        width: element.width,
4590                        height: element.height,
4591                        href: href.clone(),
4592                    });
4593                    // Don't recurse — parent annotation covers children
4594                    continue;
4595                }
4596            }
4597            // Inline links: a linked run inside a paragraph lives only on its
4598            // glyphs (`PositionedGlyph.href`), never on an element, so each
4599            // contiguous linked span gets its own annotation per line. A span
4600            // that wraps yields one rect per line it touches.
4601            if let DrawCommand::Text { ref lines, .. } = element.draw {
4602                for line in lines {
4603                    let spans = Self::inline_link_spans(line);
4604                    if spans.is_empty() {
4605                        continue;
4606                    }
4607                    // Layout emits one TextLine element per line, whose box
4608                    // IS the line box. A multi-line Text command has no
4609                    // per-line box, so estimate it around the baseline.
4610                    let (top, height) = if lines.len() == 1 {
4611                        (element.y, element.height)
4612                    } else {
4613                        let fs = line.glyphs.first().map(|g| g.font_size).unwrap_or(12.0);
4614                        (line.y - fs * 0.8 - (line.height - fs) / 2.0, line.height)
4615                    };
4616                    for span in spans {
4617                        annotations.push(LinkAnnotation {
4618                            x: span.x0,
4619                            y: page_height - top - height,
4620                            width: span.x1 - span.x0,
4621                            height,
4622                            href: span.href,
4623                        });
4624                    }
4625                }
4626            }
4627            Self::collect_link_annotations(&element.children, page_height, annotations);
4628        }
4629    }
4630
4631    /// Contiguous runs of glyphs on one line that share a per-glyph href,
4632    /// with the x extent they are drawn at. A glyph's offset already
4633    /// includes justification and `wordSpacing` (Tw only makes the drawn
4634    /// text match it), so it is used as is. Adding Tw per space again here
4635    /// put a justified line's link rect up to 6pt past its text (#162's
4636    /// mistake, in a second place).
4637    fn inline_link_spans(line: &TextLine) -> Vec<InlineLinkSpan> {
4638        let mut spans: Vec<InlineLinkSpan> = Vec::new();
4639        if !line.glyphs.iter().any(|g| g.href.is_some()) {
4640            return spans;
4641        }
4642        let mut prev_href: Option<&str> = None;
4643        for g in &line.glyphs {
4644            let x0 = line.x + g.x_offset;
4645            let x1 = x0 + g.x_advance;
4646            let href = g.href.as_deref().filter(|h| !h.is_empty());
4647            if let Some(h) = href {
4648                if prev_href != Some(h) {
4649                    spans.push(InlineLinkSpan {
4650                        href: h.to_string(),
4651                        x0: f64::INFINITY,
4652                        x1: f64::NEG_INFINITY,
4653                    });
4654                }
4655                // Only ink extends the rect: a space at a span's edge
4656                // (the one a wrapped line ends on, or "docs " in the
4657                // source) would widen the target past the text.
4658                if !g.char_value.is_whitespace() {
4659                    if let Some(last) = spans.last_mut() {
4660                        last.x0 = last.x0.min(x0);
4661                        last.x1 = last.x1.max(x1);
4662                    }
4663                }
4664            }
4665            prev_href = href;
4666        }
4667        // A span of nothing but spaces has no ink to link.
4668        spans.retain(|s| s.x1 > s.x0);
4669        spans
4670    }
4671
4672    /// Collect form field annotations from layout elements.
4673    fn collect_form_fields(
4674        elements: &[LayoutElement],
4675        page_height: f64,
4676        page_idx: usize,
4677        fields: &mut Vec<FormFieldData>,
4678    ) {
4679        for element in elements {
4680            if let DrawCommand::FormField {
4681                ref field_type,
4682                ref name,
4683            } = element.draw
4684            {
4685                let pdf_y = page_height - element.y - element.height;
4686                fields.push(FormFieldData {
4687                    field_type: field_type.clone(),
4688                    name: name.clone(),
4689                    x: element.x,
4690                    y: pdf_y,
4691                    width: element.width,
4692                    height: element.height,
4693                    page_idx,
4694                });
4695            }
4696            Self::collect_form_fields(&element.children, page_height, page_idx, fields);
4697        }
4698    }
4699
4700    /// Collect bookmarks from layout elements.
4701    fn collect_bookmarks(
4702        elements: &[LayoutElement],
4703        page_height: f64,
4704        page_obj_id: usize,
4705        bookmarks: &mut Vec<PdfBookmark>,
4706    ) {
4707        for element in elements {
4708            if let Some(ref title) = element.bookmark {
4709                let y_pdf = page_height - element.y;
4710                bookmarks.push(PdfBookmark {
4711                    title: title.clone(),
4712                    page_obj_id,
4713                    y_pdf,
4714                });
4715            }
4716            Self::collect_bookmarks(&element.children, page_height, page_obj_id, bookmarks);
4717        }
4718    }
4719
4720    /// Build the PDF outline tree from bookmark entries.
4721    /// Returns the object ID of the /Outlines dictionary.
4722    fn write_outline_tree(
4723        &self,
4724        builder: &mut PdfBuilder,
4725        bookmarks: &[PdfBookmark],
4726        mut tag_builder: Option<&mut tagged::TagBuilder>,
4727    ) -> usize {
4728        // Reserve the Outlines dictionary object
4729        let outlines_id = builder.objects.len();
4730        builder.objects.push(PdfObject {
4731            id: outlines_id,
4732            data: vec![],
4733        });
4734
4735        // Create outline item objects
4736        let mut item_ids: Vec<usize> = Vec::new();
4737        for _bm in bookmarks {
4738            let item_id = builder.objects.len();
4739            builder.objects.push(PdfObject {
4740                id: item_id,
4741                data: vec![],
4742            });
4743            item_ids.push(item_id);
4744        }
4745
4746        // Fill in outline items with /Prev, /Next, /Parent, /Dest
4747        for (i, (bm, &item_id)) in bookmarks.iter().zip(item_ids.iter()).enumerate() {
4748            // ISO 14289-2 8.8: "All destinations whose target lies within
4749            // the current document shall be structure destinations" — and
4750            // veraPDF flags a plain page /Dest array itself, /SD sibling or
4751            // not. Under UA-2 the outline item therefore carries ONLY /SD,
4752            // targeting the bookmark's structure element (placeholder
4753            // patched after write_objects, like the link annotations).
4754            let wants_sd = tag_builder
4755                .as_mut()
4756                .map(|tb| tb.request_struct_destination(&bm.title, item_id))
4757                .unwrap_or(false);
4758            let dest = if wants_sd {
4759                format!("/SD [999999999 0 R /XYZ 0 {:.2} null]", bm.y_pdf)
4760            } else {
4761                format!("/Dest [{} 0 R /XYZ 0 {:.2} null]", bm.page_obj_id, bm.y_pdf)
4762            };
4763            let mut dict = format!(
4764                "<< /Title {} /Parent {} 0 R {}",
4765                Self::encode_text_string(&bm.title),
4766                outlines_id,
4767                dest,
4768            );
4769            if i > 0 {
4770                let _ = write!(dict, " /Prev {} 0 R", item_ids[i - 1]);
4771            }
4772            if i + 1 < item_ids.len() {
4773                let _ = write!(dict, " /Next {} 0 R", item_ids[i + 1]);
4774            }
4775            dict.push_str(" >>");
4776            builder.objects[item_id].data = dict.into_bytes();
4777        }
4778
4779        // Fill in Outlines dictionary
4780        let first_id = item_ids.first().copied().unwrap_or(0);
4781        let last_id = item_ids.last().copied().unwrap_or(0);
4782        let outlines_dict = format!(
4783            "<< /Type /Outlines /First {} 0 R /Last {} 0 R /Count {} >>",
4784            first_id,
4785            last_id,
4786            bookmarks.len()
4787        );
4788        builder.objects[outlines_id].data = outlines_dict.into_bytes();
4789
4790        outlines_id
4791    }
4792
4793    /// Write SVG drawing commands to a PDF content stream.
4794    fn write_svg_commands(
4795        stream: &mut String,
4796        commands: &[SvgCommand],
4797        ext_gstate_map: &HashMap<u64, (usize, String)>,
4798    ) {
4799        for cmd in commands {
4800            match cmd {
4801                SvgCommand::MoveTo(x, y) => {
4802                    let _ = writeln!(stream, "{:.2} {:.2} m", x, y);
4803                }
4804                SvgCommand::LineTo(x, y) => {
4805                    let _ = writeln!(stream, "{:.2} {:.2} l", x, y);
4806                }
4807                SvgCommand::CurveTo(x1, y1, x2, y2, x3, y3) => {
4808                    let _ = writeln!(
4809                        stream,
4810                        "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
4811                        x1, y1, x2, y2, x3, y3
4812                    );
4813                }
4814                SvgCommand::ClosePath => {
4815                    let _ = writeln!(stream, "h");
4816                }
4817                SvgCommand::SetFill(r, g, b) => {
4818                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", r, g, b);
4819                }
4820                SvgCommand::SetFillNone => {
4821                    // No-op in PDF; handled by fill/stroke selection
4822                }
4823                SvgCommand::SetStroke(r, g, b) => {
4824                    let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", r, g, b);
4825                }
4826                SvgCommand::SetStrokeNone => {
4827                    // No-op in PDF
4828                }
4829                SvgCommand::SetStrokeWidth(w) => {
4830                    let _ = writeln!(stream, "{:.2} w", w);
4831                }
4832                SvgCommand::Fill => {
4833                    let _ = writeln!(stream, "f");
4834                }
4835                SvgCommand::Stroke => {
4836                    let _ = writeln!(stream, "S");
4837                }
4838                SvgCommand::FillAndStroke => {
4839                    let _ = writeln!(stream, "B");
4840                }
4841                SvgCommand::SetLineCap(cap) => {
4842                    let _ = writeln!(stream, "{} J", cap);
4843                }
4844                SvgCommand::SetLineJoin(join) => {
4845                    let _ = writeln!(stream, "{} j", join);
4846                }
4847                SvgCommand::SaveState => {
4848                    let _ = writeln!(stream, "q");
4849                }
4850                SvgCommand::RestoreState => {
4851                    let _ = writeln!(stream, "Q");
4852                }
4853                SvgCommand::SetOpacity(opacity) => {
4854                    if let Some((_, gs_name)) = ext_gstate_map.get(&opacity.to_bits()) {
4855                        let _ = writeln!(stream, "/{} gs", gs_name);
4856                    }
4857                }
4858            }
4859        }
4860    }
4861
4862    /// Escape special characters in a PDF string.
4863    pub(crate) fn escape_pdf_string(s: &str) -> String {
4864        s.replace('\\', "\\\\")
4865            .replace('(', "\\(")
4866            .replace(')', "\\)")
4867    }
4868
4869    /// Encode a PDF *text string* (ISO 32000-1 7.9.2.2), delimiters
4870    /// included. A text string is PDFDocEncoding or UTF-16BE with a BOM;
4871    /// raw UTF-8 in a literal is neither, and readers decode it as
4872    /// PDFDocEncoding ("Ü" shows as "Ü", issue #158).
4873    ///
4874    /// Printable ASCII (0x20..=0x7E) is identical in PDFDocEncoding, so it
4875    /// stays an escaped literal, byte-for-byte what was written before.
4876    /// Anything else becomes `<FEFF...>`: UTF-16BE hex with the BOM, with
4877    /// surrogate pairs outside the BMP. PDFDocEncoding's Latin-1 range would
4878    /// cover some non-ASCII text too, but it differs from Latin-1 in
4879    /// 0x7F..=0xA0 and cannot express most scripts, so one rule that is
4880    /// always correct beats a second table to keep right.
4881    pub(crate) fn encode_text_string(s: &str) -> String {
4882        if s.bytes().all(|b| (0x20..=0x7E).contains(&b)) {
4883            return format!("({})", Self::escape_pdf_string(s));
4884        }
4885        let mut out = String::with_capacity(6 + s.len() * 4);
4886        out.push_str("<FEFF");
4887        for unit in s.encode_utf16() {
4888            let _ = write!(out, "{unit:04X}");
4889        }
4890        out.push('>');
4891        out
4892    }
4893
4894    /// Decode an attachment `src`: plain base64, with an optional
4895    /// `data:...;base64,` prefix tolerated (same convention as fonts).
4896    fn decode_attachment_src(src: &str) -> Result<Vec<u8>, FormeError> {
4897        use base64::Engine as _;
4898        let b64 = src.rsplit_once(";base64,").map(|(_, d)| d).unwrap_or(src);
4899        base64::engine::general_purpose::STANDARD
4900            .decode(b64.trim())
4901            .map_err(|e| {
4902                FormeError::RenderError(format!(
4903                    "attachment src is not valid base64 (expected base64 bytes or a data: URI): {e}"
4904                ))
4905            })
4906    }
4907
4908    /// Encode a MIME type as a PDF name (PDF 32000 §7.3.5): delimiter and
4909    /// non-regular characters become #XX — `text/xml` → `text#2Fxml`.
4910    fn mime_to_pdf_name(mime: &str) -> String {
4911        let mut out = String::with_capacity(mime.len() + 2);
4912        for b in mime.bytes() {
4913            let regular =
4914                b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'+' | b'\'' | b'"');
4915            if regular {
4916                out.push(b as char);
4917            } else {
4918                let _ = write!(out, "#{:02X}", b);
4919            }
4920        }
4921        out
4922    }
4923
4924    /// Encode a string for use in a PDF content stream with WinAnsi encoding.
4925    /// Characters outside WinAnsi range are replaced with '?' and recorded
4926    /// for the missing-glyph render defect.
4927    fn encode_winansi_text(builder: &PdfBuilder, s: &str) -> String {
4928        let mut result = String::with_capacity(s.len());
4929        for ch in s.chars() {
4930            let b = Self::unicode_to_winansi(ch).unwrap_or_else(|| {
4931                builder.missing_glyphs.borrow_mut().insert(ch);
4932                b'?'
4933            });
4934            match b {
4935                b'\\' => result.push_str("\\\\"),
4936                b'(' => result.push_str("\\("),
4937                b')' => result.push_str("\\)"),
4938                0x20..=0x7E => result.push(b as char),
4939                _ => {
4940                    let _ = write!(result, "\\{:03o}", b);
4941                }
4942            }
4943        }
4944        result
4945    }
4946
4947    /// Map a Unicode codepoint to a WinAnsiEncoding byte value.
4948    fn unicode_to_winansi(ch: char) -> Option<u8> {
4949        crate::font::unicode_to_winansi(ch)
4950    }
4951
4952    /// Serialize all objects into the final PDF byte stream.
4953    fn serialize(&self, builder: &PdfBuilder, info_obj_id: Option<usize>) -> Vec<u8> {
4954        let mut output: Vec<u8> = Vec::new();
4955        let mut offsets: Vec<usize> = vec![0; builder.objects.len()];
4956
4957        // Header
4958        output.extend_from_slice(match builder.pdf_version {
4959            crate::model::PdfVersion::V1_7 => b"%PDF-1.7\n".as_slice(),
4960            crate::model::PdfVersion::V2_0 => b"%PDF-2.0\n".as_slice(),
4961        });
4962        output.extend_from_slice(b"%\xe2\xe3\xcf\xd3\n");
4963
4964        for (i, obj) in builder.objects.iter().enumerate().skip(1) {
4965            offsets[i] = output.len();
4966            let header = format!("{} 0 obj\n", i);
4967            output.extend_from_slice(header.as_bytes());
4968            output.extend_from_slice(&obj.data);
4969            output.extend_from_slice(b"\nendobj\n\n");
4970        }
4971
4972        let xref_offset = output.len();
4973        let _ = writeln!(output, "xref\n0 {}", builder.objects.len());
4974        let _ = writeln!(output, "0000000000 65535 f ");
4975        for offset in offsets.iter().skip(1) {
4976            let _ = writeln!(output, "{:010} 00000 n ", offset);
4977        }
4978
4979        let _ = write!(
4980            output,
4981            "trailer\n<< /Size {} /Root 1 0 R",
4982            builder.objects.len()
4983        );
4984        if let Some(info_id) = info_obj_id {
4985            let _ = write!(output, " /Info {} 0 R", info_id);
4986        }
4987        // /ID — required by PDF/A (6.1.3) and generally expected. Derived
4988        // deterministically from the file content (SHA-256 of everything written
4989        // so far), NOT a timestamp or random bytes, so native and WASM builds
4990        // stay byte-identical. The two identifiers are equal for a freshly
4991        // created (never incrementally updated) file, per ISO 32000-1 14.4.
4992        {
4993            use sha2::Digest as _;
4994            let digest = sha2::Sha256::digest(&output);
4995            let mut id_hex = String::with_capacity(32);
4996            for b in &digest[..16] {
4997                let _ = write!(id_hex, "{:02X}", b);
4998            }
4999            let _ = write!(output, " /ID [<{id_hex}> <{id_hex}>]");
5000        }
5001        let _ = writeln!(output, " >>\nstartxref\n{}\n%%EOF", xref_offset);
5002
5003        output
5004    }
5005}
5006
5007/// Write a single chart drawing primitive to the PDF content stream.
5008///
5009/// Called within a Y-flipped coordinate system (1 0 0 -1 x page_h-y cm),
5010/// so chart primitives use top-left origin (Y increases downward).
5011fn write_chart_primitive(
5012    stream: &mut String,
5013    prim: &crate::chart::ChartPrimitive,
5014    _chart_height: f64,
5015    builder: &PdfBuilder,
5016) {
5017    use crate::chart::{ChartPrimitive, TextAnchor};
5018    use crate::font::metrics::unicode_to_winansi;
5019
5020    match prim {
5021        ChartPrimitive::Rect { x, y, w, h, fill } => {
5022            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
5023            let _ = writeln!(stream, "{:.2} {:.2} {:.2} {:.2} re f", x, y, w, h);
5024        }
5025
5026        ChartPrimitive::Line {
5027            x1,
5028            y1,
5029            x2,
5030            y2,
5031            stroke,
5032            width,
5033        } => {
5034            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
5035            let _ = writeln!(stream, "{:.2} w", width);
5036            let _ = writeln!(stream, "{:.2} {:.2} m {:.2} {:.2} l S", x1, y1, x2, y2);
5037        }
5038
5039        ChartPrimitive::Polyline {
5040            points,
5041            stroke,
5042            width,
5043        } => {
5044            if points.len() < 2 {
5045                return;
5046            }
5047            let _ = writeln!(stream, "{:.3} {:.3} {:.3} RG", stroke.r, stroke.g, stroke.b);
5048            let _ = writeln!(stream, "{:.2} w", width);
5049            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
5050            for &(px, py) in &points[1..] {
5051                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
5052            }
5053            let _ = writeln!(stream, "S");
5054        }
5055
5056        ChartPrimitive::FilledPath {
5057            points,
5058            fill,
5059            opacity,
5060        } => {
5061            if points.len() < 3 {
5062                return;
5063            }
5064            let _ = writeln!(stream, "q");
5065            // Set opacity via ExtGState if available
5066            if *opacity < 1.0 {
5067                if let Some((_, gs_name)) = builder.ext_gstate_map.get(&opacity.to_bits()) {
5068                    let _ = writeln!(stream, "/{} gs", gs_name);
5069                }
5070            }
5071            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
5072            let _ = writeln!(stream, "{:.2} {:.2} m", points[0].0, points[0].1);
5073            for &(px, py) in &points[1..] {
5074                let _ = writeln!(stream, "{:.2} {:.2} l", px, py);
5075            }
5076            let _ = writeln!(stream, "h f");
5077            let _ = writeln!(stream, "Q");
5078        }
5079
5080        ChartPrimitive::Circle { cx, cy, r, fill } => {
5081            // Approximate circle with 4 cubic bezier curves
5082            let kappa: f64 = 0.5523;
5083            let kr = kappa * r;
5084            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
5085            let _ = writeln!(stream, "{:.2} {:.2} m", cx + r, cy);
5086            let _ = writeln!(
5087                stream,
5088                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
5089                cx + r,
5090                cy + kr,
5091                cx + kr,
5092                cy + r,
5093                cx,
5094                cy + r
5095            );
5096            let _ = writeln!(
5097                stream,
5098                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
5099                cx - kr,
5100                cy + r,
5101                cx - r,
5102                cy + kr,
5103                cx - r,
5104                cy
5105            );
5106            let _ = writeln!(
5107                stream,
5108                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
5109                cx - r,
5110                cy - kr,
5111                cx - kr,
5112                cy - r,
5113                cx,
5114                cy - r
5115            );
5116            let _ = writeln!(
5117                stream,
5118                "{:.2} {:.2} {:.2} {:.2} {:.2} {:.2} c",
5119                cx + kr,
5120                cy - r,
5121                cx + r,
5122                cy - kr,
5123                cx + r,
5124                cy
5125            );
5126            let _ = writeln!(stream, "f");
5127        }
5128
5129        ChartPrimitive::ArcSector {
5130            cx,
5131            cy,
5132            r,
5133            start_angle,
5134            end_angle,
5135            fill,
5136        } => {
5137            let _ = writeln!(stream, "{:.3} {:.3} {:.3} rg", fill.r, fill.g, fill.b);
5138            // Move to center
5139            let _ = writeln!(stream, "{:.2} {:.2} m", cx, cy);
5140            // Line to arc start
5141            let sx = cx + r * start_angle.cos();
5142            let sy = cy + r * start_angle.sin();
5143            let _ = writeln!(stream, "{:.2} {:.2} l", sx, sy);
5144
5145            // Approximate arc with cubic bezier segments (max 90° per segment)
5146            let mut angle = *start_angle;
5147            let total = end_angle - start_angle;
5148            let segments = ((total.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).max(1);
5149            let step = total / segments as f64;
5150
5151            for _ in 0..segments {
5152                let a1 = angle;
5153                let a2 = angle + step;
5154                let alpha = 4.0 / 3.0 * ((a2 - a1) / 4.0).tan();
5155
5156                let p1x = cx + r * a1.cos();
5157                let p1y = cy + r * a1.sin();
5158                let p2x = cx + r * a2.cos();
5159                let p2y = cy + r * a2.sin();
5160
5161                let cp1x = p1x - alpha * r * a1.sin();
5162                let cp1y = p1y + alpha * r * a1.cos();
5163                let cp2x = p2x + alpha * r * a2.sin();
5164                let cp2y = p2y - alpha * r * a2.cos();
5165
5166                let _ = writeln!(
5167                    stream,
5168                    "{:.4} {:.4} {:.4} {:.4} {:.4} {:.4} c",
5169                    cp1x, cp1y, cp2x, cp2y, p2x, p2y
5170                );
5171                angle = a2;
5172            }
5173
5174            // Close path back to center and fill
5175            let _ = writeln!(stream, "h f");
5176        }
5177
5178        ChartPrimitive::Label {
5179            text,
5180            x,
5181            y,
5182            font_size,
5183            color,
5184            anchor,
5185        } => {
5186            // Measure text width for anchor alignment
5187            let metrics = crate::font::StandardFont::Helvetica.metrics();
5188            let text_width = metrics.measure_string(text, *font_size, 0.0);
5189            let x_offset = match anchor {
5190                TextAnchor::Left => 0.0,
5191                TextAnchor::Center => -text_width / 2.0,
5192                TextAnchor::Right => -text_width,
5193            };
5194
5195            // Find Helvetica font index in font_objects
5196            let font_idx = builder
5197                .font_objects
5198                .iter()
5199                .enumerate()
5200                .find(|(_, (key, _))| key.family == "Helvetica" && key.weight == 400 && !key.italic)
5201                .map(|(i, _)| i)
5202                .unwrap_or(0);
5203
5204            // Encode text to WinAnsi
5205            let encoded: String = text
5206                .chars()
5207                .map(|ch| {
5208                    if let Some(code) = unicode_to_winansi(ch) {
5209                        code as char
5210                    } else if (ch as u32) >= 32 && (ch as u32) <= 255 {
5211                        ch
5212                    } else {
5213                        builder.missing_glyphs.borrow_mut().insert(ch);
5214                        '?'
5215                    }
5216                })
5217                .collect();
5218            let escaped = pdf_escape_string(&encoded);
5219
5220            // Undo Y-flip for text rendering, then position
5221            let _ = writeln!(stream, "q");
5222            let _ = writeln!(stream, "1 0 0 -1 {:.4} {:.4} cm", x + x_offset, *y);
5223            let _ = writeln!(
5224                stream,
5225                "BT /F{} {:.1} Tf {:.3} {:.3} {:.3} rg 0 0 Td ({}) Tj ET",
5226                font_idx, font_size, color.r, color.g, color.b, escaped
5227            );
5228            let _ = writeln!(stream, "Q");
5229        }
5230        ChartPrimitive::VerticalLabel {
5231            text,
5232            x,
5233            y,
5234            font_size,
5235            color,
5236        } => {
5237            let metrics = crate::font::StandardFont::Helvetica.metrics();
5238            let text_width = metrics.measure_string(text, *font_size, 0.0);
5239            let font_idx = builder
5240                .font_objects
5241                .iter()
5242                .enumerate()
5243                .find(|(_, (key, _))| key.family == "Helvetica" && key.weight == 400 && !key.italic)
5244                .map(|(i, _)| i)
5245                .unwrap_or(0);
5246            let encoded: String = text
5247                .chars()
5248                .map(|ch| {
5249                    if let Some(code) = unicode_to_winansi(ch) {
5250                        code as char
5251                    } else if (ch as u32) >= 32 && (ch as u32) <= 255 {
5252                        ch
5253                    } else {
5254                        builder.missing_glyphs.borrow_mut().insert(ch);
5255                        '?'
5256                    }
5257                })
5258                .collect();
5259            let escaped = pdf_escape_string(&encoded);
5260
5261            // Chart space is y-down. Text runs up the page, so its baseline
5262            // points to -y here and its glyphs' up points to -x: `0 -1 -1 0`.
5263            // Start half the text's length below the centre, with the
5264            // baseline 0.35em right of it so the glyphs sit centred on x.
5265            let _ = writeln!(stream, "q");
5266            let _ = writeln!(
5267                stream,
5268                "0 -1 -1 0 {:.4} {:.4} cm",
5269                x + font_size * 0.35,
5270                y + text_width / 2.0
5271            );
5272            let _ = writeln!(
5273                stream,
5274                "BT /F{} {:.1} Tf {:.3} {:.3} {:.3} rg 0 0 Td ({}) Tj ET",
5275                font_idx, font_size, color.r, color.g, color.b, escaped
5276            );
5277            let _ = writeln!(stream, "Q");
5278        }
5279    }
5280}
5281
5282/// Normalize a list of gradient stops for PDF Shading emission. Clamps
5283/// positions to [0, 1], sorts ascending by position, and pads with
5284/// implicit stops at 0 and 1 (using the closest defined stop's color)
5285/// when the input doesn't cover the full range. Empty input collapses to
5286/// two `fallback`-colored stops at 0 and 1 so the caller never has to
5287/// special-case zero stops.
5288fn normalize_gradient_stops(
5289    stops: &[crate::style::GradientStop],
5290    fallback: Color,
5291) -> Vec<crate::style::GradientStop> {
5292    use crate::style::GradientStop;
5293    if stops.is_empty() {
5294        return vec![
5295            GradientStop {
5296                position: 0.0,
5297                color: fallback,
5298            },
5299            GradientStop {
5300                position: 1.0,
5301                color: fallback,
5302            },
5303        ];
5304    }
5305    let mut sorted: Vec<GradientStop> = stops
5306        .iter()
5307        .map(|s| GradientStop {
5308            position: s.position.clamp(0.0, 1.0),
5309            color: s.color,
5310        })
5311        .collect();
5312    sorted.sort_by(|a, b| {
5313        a.position
5314            .partial_cmp(&b.position)
5315            .unwrap_or(std::cmp::Ordering::Equal)
5316    });
5317    if sorted[0].position > 0.0 {
5318        sorted.insert(
5319            0,
5320            GradientStop {
5321                position: 0.0,
5322                color: sorted[0].color,
5323            },
5324        );
5325    }
5326    if sorted[sorted.len() - 1].position < 1.0 {
5327        let last = sorted[sorted.len() - 1].color;
5328        sorted.push(GradientStop {
5329            position: 1.0,
5330            color: last,
5331        });
5332    }
5333    sorted
5334}
5335
5336fn pdf_escape_string(s: &str) -> String {
5337    let mut out = String::with_capacity(s.len());
5338    for ch in s.chars() {
5339        match ch {
5340            '(' => out.push_str("\\("),
5341            ')' => out.push_str("\\)"),
5342            '\\' => out.push_str("\\\\"),
5343            _ => out.push(ch),
5344        }
5345    }
5346    out
5347}
5348
5349/// A group carrying page-number sentinels, which is drawn from the
5350/// substituted text rather than from its glyphs' positions.
5351fn has_placeholder_group(group: &[&PositionedGlyph]) -> bool {
5352    group
5353        .iter()
5354        .any(|g| g.char_value == PAGE_NUMBER_SENTINEL || g.char_value == TOTAL_PAGES_SENTINEL)
5355}
5356
5357/// A PDF number with at most three decimals and no trailing zeros
5358/// (556.152 -> "556.152", 556.0 -> "556").
5359fn pdf_number(v: f64) -> String {
5360    let s = format!("{:.3}", v);
5361    let s = s.trim_end_matches('0').trim_end_matches('.');
5362    if s.is_empty() || s == "-" {
5363        "0".to_string()
5364    } else {
5365        s.to_string()
5366    }
5367}
5368
5369#[cfg(test)]
5370mod tests {
5371    use super::*;
5372    use crate::font::FontContext;
5373
5374    /// The embedded sRGB profile must be a REAL ICC profile suitable for a
5375    /// PDF/A OutputIntent — not, say, an HTML error page a `curl` returned and
5376    /// nobody inspected (which is exactly what shipped from v0.6.0 through 0.15.0,
5377    /// silently making every PDF/A OutputIntent invalid). This is the check
5378    /// that would have caught it: ICC signature, an OutputIntent-legal device
5379    /// class (`mntr`/`prtr`), and an RGB data colour space.
5380    #[test]
5381    fn test_embedded_srgb_is_a_valid_icc_profile() {
5382        let icc: &[u8] = include_bytes!("sRGB.icc");
5383        assert!(
5384            icc.len() >= 128,
5385            "ICC shorter than its 128-byte header: {}",
5386            icc.len()
5387        );
5388        // Not HTML / not a text error page.
5389        assert_ne!(
5390            icc[0], b'<',
5391            "embedded ICC starts with '<' — looks like HTML, not a profile"
5392        );
5393        // 'acsp' profile-file signature at bytes 36..40 (ISO 15076-1 / ICC.1).
5394        assert_eq!(&icc[36..40], b"acsp", "missing ICC 'acsp' signature");
5395        // Device class (bytes 12..16) must be monitor or output for an OutputIntent.
5396        let device_class = &icc[12..16];
5397        assert!(
5398            device_class == b"mntr" || device_class == b"prtr",
5399            "ICC device class {:?} is not mntr/prtr (PDF/A 6.2.3)",
5400            String::from_utf8_lossy(device_class),
5401        );
5402        // Data colour space (bytes 16..20) must be RGB for an sRGB OutputIntent.
5403        assert_eq!(&icc[16..20], b"RGB ", "ICC data colour space is not RGB");
5404    }
5405
5406    #[test]
5407    fn test_escape_pdf_string() {
5408        assert_eq!(
5409            PdfWriter::escape_pdf_string("Hello (World)"),
5410            "Hello \\(World\\)"
5411        );
5412        assert_eq!(PdfWriter::escape_pdf_string("back\\slash"), "back\\\\slash");
5413    }
5414
5415    #[test]
5416    fn test_encode_text_string() {
5417        // Printable ASCII: the escaped literal, unchanged from before.
5418        assert_eq!(PdfWriter::encode_text_string("A (b)"), "(A \\(b\\))");
5419        assert_eq!(PdfWriter::encode_text_string(""), "()");
5420        // Non-ASCII: UTF-16BE with a BOM. U+00DC is 00DC.
5421        assert_eq!(PdfWriter::encode_text_string("Üb"), "<FEFF00DC0062>");
5422        // Outside the BMP: a surrogate pair (U+1D11E -> D834 DD1E).
5423        assert_eq!(PdfWriter::encode_text_string("𝄞"), "<FEFFD834DD1E>");
5424        // A control character is not printable ASCII and is not left raw.
5425        assert_eq!(PdfWriter::encode_text_string("a\nb"), "<FEFF0061000A0062>");
5426    }
5427
5428    #[test]
5429    fn test_empty_document_produces_valid_pdf() {
5430        let writer = PdfWriter::new();
5431        let font_context = FontContext::new();
5432        let pages = vec![LayoutPage {
5433            width: 595.28,
5434            height: 841.89,
5435            elements: vec![],
5436            fixed_header: vec![],
5437            fixed_footer: vec![],
5438            watermarks: vec![],
5439            config: PageConfig::default(),
5440            page_name: None,
5441        }];
5442        let metadata = Metadata::default();
5443        let (bytes, _warnings) = writer
5444            .write(
5445                &pages,
5446                &metadata,
5447                &font_context,
5448                false,
5449                None,
5450                false,
5451                None,
5452                &[],
5453                None,
5454                false,
5455                crate::model::PdfVersion::V1_7,
5456                false,
5457            )
5458            .unwrap();
5459
5460        assert!(bytes.starts_with(b"%PDF-1.7"));
5461        assert!(bytes.windows(5).any(|w| w == b"%%EOF"));
5462        assert!(bytes.windows(4).any(|w| w == b"xref"));
5463        assert!(bytes.windows(7).any(|w| w == b"trailer"));
5464    }
5465
5466    #[test]
5467    fn test_metadata_in_pdf() {
5468        let writer = PdfWriter::new();
5469        let font_context = FontContext::new();
5470        let pages = vec![LayoutPage {
5471            width: 595.28,
5472            height: 841.89,
5473            elements: vec![],
5474            fixed_header: vec![],
5475            fixed_footer: vec![],
5476            watermarks: vec![],
5477            config: PageConfig::default(),
5478            page_name: None,
5479        }];
5480        let metadata = Metadata {
5481            title: Some("Test Document".to_string()),
5482            author: Some("Forme".to_string()),
5483            subject: None,
5484            creator: None,
5485            lang: None,
5486        };
5487        let (bytes, _warnings) = writer
5488            .write(
5489                &pages,
5490                &metadata,
5491                &font_context,
5492                false,
5493                None,
5494                false,
5495                None,
5496                &[],
5497                None,
5498                false,
5499                crate::model::PdfVersion::V1_7,
5500                false,
5501            )
5502            .unwrap();
5503        let text = String::from_utf8_lossy(&bytes);
5504
5505        assert!(text.contains("/Title (Test Document)"));
5506        assert!(text.contains("/Author (Forme)"));
5507    }
5508
5509    #[test]
5510    fn test_bold_font_registered_separately() {
5511        let writer = PdfWriter::new();
5512        let font_context = FontContext::new();
5513
5514        // Create pages with both regular and bold text
5515        let pages = vec![LayoutPage {
5516            width: 595.28,
5517            height: 841.89,
5518            elements: vec![
5519                LayoutElement {
5520                    x: 54.0,
5521                    y: 54.0,
5522                    width: 100.0,
5523                    height: 16.8,
5524                    draw: DrawCommand::Text {
5525                        lines: vec![TextLine {
5526                            x: 54.0,
5527                            y: 66.0,
5528                            width: 50.0,
5529                            height: 16.8,
5530                            glyphs: vec![PositionedGlyph {
5531                                glyph_id: 65,
5532                                x_offset: 0.0,
5533                                y_offset: 0.0,
5534                                x_advance: 8.0,
5535                                font_size: 12.0,
5536                                font_family: "Helvetica".into(),
5537                                font_weight: 400,
5538                                font_style: FontStyle::Normal,
5539                                char_value: 'A',
5540                                color: None,
5541                                href: None,
5542                                text_decoration: TextDecoration::None,
5543                                letter_spacing: 0.0,
5544                                cluster_text: None,
5545                                ligature: false,
5546                            }],
5547                            word_spacing: 0.0,
5548                        }],
5549                        color: Color::BLACK,
5550                        text_decoration: TextDecoration::None,
5551                        opacity: 1.0,
5552                    },
5553                    children: vec![],
5554                    node_type: None,
5555                    resolved_style: None,
5556                    source_location: None,
5557                    href: None,
5558                    bookmark: None,
5559                    alt: None,
5560                    is_header_row: false,
5561                    actual_text: None,
5562                    list_numbering: None,
5563                    col_span: 1,
5564                    overflow: Overflow::default(),
5565                    opacity: 1.0,
5566                },
5567                LayoutElement {
5568                    x: 54.0,
5569                    y: 74.0,
5570                    width: 100.0,
5571                    height: 16.8,
5572                    draw: DrawCommand::Text {
5573                        lines: vec![TextLine {
5574                            x: 54.0,
5575                            y: 86.0,
5576                            width: 50.0,
5577                            height: 16.8,
5578                            glyphs: vec![PositionedGlyph {
5579                                glyph_id: 65,
5580                                x_offset: 0.0,
5581                                y_offset: 0.0,
5582                                x_advance: 8.0,
5583                                font_size: 12.0,
5584                                font_family: "Helvetica".into(),
5585                                font_weight: 700,
5586                                font_style: FontStyle::Normal,
5587                                char_value: 'A',
5588                                color: None,
5589                                href: None,
5590                                text_decoration: TextDecoration::None,
5591                                letter_spacing: 0.0,
5592                                cluster_text: None,
5593                                ligature: false,
5594                            }],
5595                            word_spacing: 0.0,
5596                        }],
5597                        color: Color::BLACK,
5598                        text_decoration: TextDecoration::None,
5599                        opacity: 1.0,
5600                    },
5601                    children: vec![],
5602                    node_type: None,
5603                    resolved_style: None,
5604                    source_location: None,
5605                    href: None,
5606                    bookmark: None,
5607                    alt: None,
5608                    is_header_row: false,
5609                    actual_text: None,
5610                    list_numbering: None,
5611                    col_span: 1,
5612                    overflow: Overflow::default(),
5613                    opacity: 1.0,
5614                },
5615            ],
5616            fixed_header: vec![],
5617            fixed_footer: vec![],
5618            watermarks: vec![],
5619            config: PageConfig::default(),
5620            page_name: None,
5621        }];
5622
5623        let metadata = Metadata::default();
5624        let (bytes, _warnings) = writer
5625            .write(
5626                &pages,
5627                &metadata,
5628                &font_context,
5629                false,
5630                None,
5631                false,
5632                None,
5633                &[],
5634                None,
5635                false,
5636                crate::model::PdfVersion::V1_7,
5637                false,
5638            )
5639            .unwrap();
5640        let text = String::from_utf8_lossy(&bytes);
5641
5642        // Should have both Helvetica and Helvetica-Bold registered
5643        assert!(
5644            text.contains("Helvetica"),
5645            "Should contain regular Helvetica"
5646        );
5647        assert!(
5648            text.contains("Helvetica-Bold"),
5649            "Should contain Helvetica-Bold"
5650        );
5651    }
5652
5653    #[test]
5654    fn test_sanitize_font_name() {
5655        assert_eq!(PdfWriter::sanitize_font_name("Inter", 400, false), "Inter");
5656        assert_eq!(
5657            PdfWriter::sanitize_font_name("Inter", 700, false),
5658            "Inter-Bold"
5659        );
5660        assert_eq!(
5661            PdfWriter::sanitize_font_name("Inter", 400, true),
5662            "Inter-Italic"
5663        );
5664        assert_eq!(
5665            PdfWriter::sanitize_font_name("Inter", 700, true),
5666            "Inter-Bold-Italic"
5667        );
5668        assert_eq!(
5669            PdfWriter::sanitize_font_name("Noto Sans", 400, false),
5670            "NotoSans"
5671        );
5672        assert_eq!(
5673            PdfWriter::sanitize_font_name("Font (Display)", 400, false),
5674            "FontDisplay"
5675        );
5676    }
5677
5678    #[test]
5679    fn test_tounicode_cmap_format() {
5680        // glyph_to_text: maps subset glyph IDs → Unicode text
5681        let mut glyph_to_char = HashMap::new();
5682        glyph_to_char.insert(36u16, "A".to_string());
5683        glyph_to_char.insert(37u16, "B".to_string());
5684
5685        let cmap = PdfWriter::build_tounicode_cmap_from_gids(&glyph_to_char, "TestFont");
5686
5687        assert!(cmap.contains("begincmap"), "CMap should contain begincmap");
5688        assert!(cmap.contains("endcmap"), "CMap should contain endcmap");
5689        assert!(
5690            cmap.contains("beginbfchar"),
5691            "CMap should contain beginbfchar"
5692        );
5693        assert!(cmap.contains("endbfchar"), "CMap should contain endbfchar");
5694        assert!(
5695            cmap.contains("<0024> <0041>"),
5696            "Should map gid 0x0024 to Unicode 'A' 0x0041"
5697        );
5698        assert!(
5699            cmap.contains("<0025> <0042>"),
5700            "Should map gid 0x0025 to Unicode 'B' 0x0042"
5701        );
5702        assert!(
5703            cmap.contains("begincodespacerange"),
5704            "Should define codespace range"
5705        );
5706        assert!(
5707            cmap.contains("<0000> <FFFF>"),
5708            "Codespace should be 0000-FFFF"
5709        );
5710    }
5711
5712    /// Issue #156: a ligature destination carries every char, and a non-BMP
5713    /// char is written as its UTF-16 surrogate pair. The old writer emitted
5714    /// `{:04X}` of the code point, which for U+1F600 is the odd-length and
5715    /// invalid `<1F600>`.
5716    #[test]
5717    fn test_tounicode_cmap_multi_char_and_non_bmp_destinations() {
5718        let mut gid_to_text = HashMap::new();
5719        gid_to_text.insert(5u16, "ffi".to_string());
5720        gid_to_text.insert(6u16, "Th".to_string());
5721        gid_to_text.insert(7u16, "\u{1F600}".to_string());
5722
5723        let cmap = PdfWriter::build_tounicode_cmap_from_gids(&gid_to_text, "TestFont");
5724
5725        assert!(cmap.contains("<0005> <006600660069>"), "{cmap}");
5726        assert!(cmap.contains("<0006> <00540068>"), "{cmap}");
5727        assert!(cmap.contains("<0007> <D83DDE00>"), "{cmap}");
5728    }
5729
5730    fn text_glyph(
5731        glyph_id: u16,
5732        ch: char,
5733        cluster: Option<&str>,
5734        ligature: bool,
5735    ) -> PositionedGlyph {
5736        PositionedGlyph {
5737            glyph_id,
5738            x_offset: 0.0,
5739            y_offset: 0.0,
5740            x_advance: 5.0,
5741            font_size: 12.0,
5742            font_family: "Lig".into(),
5743            font_weight: 400,
5744            font_style: FontStyle::Normal,
5745            char_value: ch,
5746            color: None,
5747            href: None,
5748            text_decoration: TextDecoration::None,
5749            letter_spacing: 0.0,
5750            cluster_text: cluster.map(str::to_string),
5751            ligature,
5752        }
5753    }
5754
5755    #[test]
5756    fn test_record_glyph_text_ligature_maps_whole_cluster() {
5757        let mut map = HashMap::new();
5758        record_glyph_text(&mut map, &text_glyph(9, 'f', Some("ffi"), true));
5759        assert_eq!(map[&9], "ffi");
5760    }
5761
5762    /// A glyph that shares a multi-glyph cluster is NOT a ligature: it keeps
5763    /// its own char even though it carries the cluster text, so a shared
5764    /// mark or matra glyph never claims one particular base.
5765    #[test]
5766    fn test_record_glyph_text_shared_cluster_glyph_keeps_its_char() {
5767        let mut map = HashMap::new();
5768        record_glyph_text(&mut map, &text_glyph(9, 'k', Some("ki"), false));
5769        assert_eq!(map[&9], "k");
5770    }
5771
5772    /// One glyph, two meanings: the single-char meaning wins whichever order
5773    /// they are seen in, so a glyph that swallowed a following ignorable char
5774    /// once cannot corrupt every other occurrence of that char.
5775    #[test]
5776    fn test_record_glyph_text_single_char_beats_multi_char() {
5777        let mut first_multi = HashMap::new();
5778        record_glyph_text(
5779            &mut first_multi,
5780            &text_glyph(9, 'e', Some("e\u{FE0F}"), true),
5781        );
5782        record_glyph_text(&mut first_multi, &text_glyph(9, 'e', None, false));
5783        assert_eq!(first_multi[&9], "e");
5784
5785        let mut first_single = HashMap::new();
5786        record_glyph_text(&mut first_single, &text_glyph(9, 'e', None, false));
5787        record_glyph_text(
5788            &mut first_single,
5789            &text_glyph(9, 'e', Some("e\u{FE0F}"), true),
5790        );
5791        assert_eq!(first_single[&9], "e");
5792
5793        // Between two multi-char meanings, the first one seen stays.
5794        let mut two_multi = HashMap::new();
5795        record_glyph_text(&mut two_multi, &text_glyph(9, 'f', Some("fi"), true));
5796        record_glyph_text(&mut two_multi, &text_glyph(9, 'f', Some("ffi"), true));
5797        assert_eq!(two_multi[&9], "fi");
5798    }
5799
5800    #[test]
5801    fn test_w_array_format() {
5802        let mut char_to_gid = HashMap::new();
5803        char_to_gid.insert('A', 36u16);
5804
5805        // We need actual font data to test this properly, so just verify format
5806        // with a minimal check that the function produces valid output
5807        let w_array_str = "[ 36 [600] ]";
5808        assert!(w_array_str.starts_with('['));
5809        assert!(w_array_str.ends_with(']'));
5810    }
5811
5812    #[test]
5813    fn test_hex_glyph_encoding() {
5814        // Verify the hex format used for custom font text encoding
5815        let gid: u16 = 0x0041;
5816        let hex = format!("{:04X}", gid);
5817        assert_eq!(hex, "0041");
5818
5819        let gids = [0x0041u16, 0x0042, 0x0043];
5820        let hex_str: String = gids.iter().map(|g| format!("{:04X}", g)).collect();
5821        assert_eq!(hex_str, "004100420043");
5822    }
5823
5824    #[test]
5825    fn test_standard_font_still_uses_text_string() {
5826        let writer = PdfWriter::new();
5827        let font_context = FontContext::new();
5828
5829        let pages = vec![LayoutPage {
5830            width: 595.28,
5831            height: 841.89,
5832            elements: vec![LayoutElement {
5833                x: 54.0,
5834                y: 54.0,
5835                width: 100.0,
5836                height: 16.8,
5837                draw: DrawCommand::Text {
5838                    lines: vec![TextLine {
5839                        x: 54.0,
5840                        y: 66.0,
5841                        width: 50.0,
5842                        height: 16.8,
5843                        glyphs: vec![PositionedGlyph {
5844                            glyph_id: 65,
5845                            x_offset: 0.0,
5846                            y_offset: 0.0,
5847                            x_advance: 8.0,
5848                            font_size: 12.0,
5849                            font_family: "Helvetica".into(),
5850                            font_weight: 400,
5851                            font_style: FontStyle::Normal,
5852                            char_value: 'H',
5853                            color: None,
5854                            href: None,
5855                            text_decoration: TextDecoration::None,
5856                            letter_spacing: 0.0,
5857                            cluster_text: None,
5858                            ligature: false,
5859                        }],
5860                        word_spacing: 0.0,
5861                    }],
5862                    color: Color::BLACK,
5863                    text_decoration: TextDecoration::None,
5864                    opacity: 1.0,
5865                },
5866                children: vec![],
5867                node_type: None,
5868                resolved_style: None,
5869                source_location: None,
5870                href: None,
5871                bookmark: None,
5872                alt: None,
5873                is_header_row: false,
5874                actual_text: None,
5875                list_numbering: None,
5876                col_span: 1,
5877                overflow: Overflow::default(),
5878                opacity: 1.0,
5879            }],
5880            fixed_header: vec![],
5881            fixed_footer: vec![],
5882            watermarks: vec![],
5883            config: PageConfig::default(),
5884            page_name: None,
5885        }];
5886
5887        let metadata = Metadata::default();
5888        let (bytes, _warnings) = writer
5889            .write(
5890                &pages,
5891                &metadata,
5892                &font_context,
5893                false,
5894                None,
5895                false,
5896                None,
5897                &[],
5898                None,
5899                false,
5900                crate::model::PdfVersion::V1_7,
5901                false,
5902            )
5903            .unwrap();
5904        let text = String::from_utf8_lossy(&bytes);
5905
5906        // Standard fonts should use Type1, not CIDFontType2
5907        assert!(
5908            text.contains("/Type1"),
5909            "Standard font should use Type1 subtype"
5910        );
5911        assert!(
5912            !text.contains("CIDFontType2"),
5913            "Standard font should not use CIDFontType2"
5914        );
5915    }
5916}