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