Skip to main content

lightweight_pdf_writer/
doc.rs

1use crate::writer::{fmt_num, PdfWriter, Ref};
2
3/// `/Producer` is always set (unlike the other `/Info` fields, which are
4/// opt-in) — it identifies the generator, not the document, so there's no
5/// caller-supplied value to opt out of.
6const PRODUCER: &str = concat!("lightweight-pdf ", env!("CARGO_PKG_VERSION"));
7
8/// A subset, embedded TrueType font written as `/Subtype /Type0` with a
9/// `/CIDFontType2` descendant (ADR-012: Identity-H, `CIDToGIDMap`,
10/// `ToUnicode`). CID space equals the subset's own glyph-index space (the
11/// facade assigns CIDs that way), so `CIDToGIDMap` is always `/Identity`
12/// and no separate CID-to-GID stream is needed.
13pub struct CidFont {
14    pub base_font: String,
15    /// Already-subset sfnt bytes (`lightweight-pdf-fonts::subset_font`).
16    pub subset_bytes: Vec<u8>,
17    /// Advance width per CID, `widths[cid]` — CIDs `0..widths.len()` are
18    /// assumed consecutive (true by construction: CID == subset GID).
19    pub widths: Vec<f32>,
20    pub ascent: f32,
21    pub descent: f32,
22    pub cap_height: f32,
23    pub italic_angle: f32,
24    pub bbox: (f32, f32, f32, f32),
25    pub is_italic: bool,
26    pub is_bold: bool,
27    /// `(CID, Unicode scalar)` pairs for the `ToUnicode` CMap — what makes
28    /// the text copyable/searchable despite going through Identity-H CIDs.
29    pub to_unicode: Vec<(u16, char)>,
30}
31
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum ColorSpace {
34    DeviceGray,
35    DeviceRgb,
36}
37
38impl ColorSpace {
39    fn as_pdf_name(self) -> &'static str {
40        match self {
41            ColorSpace::DeviceGray => "DeviceGray",
42            ColorSpace::DeviceRgb => "DeviceRGB",
43        }
44    }
45}
46
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub enum ImageDataFilter {
49    /// Raw samples — Flate-compressed like any other stream by default
50    /// (ADR-016), unlike `DctDecode` below.
51    None,
52    /// The original JPEG bytes, embedded byte-for-byte (`phases/
53    /// phase-5-images.md` step 2: "kein Neukodieren").
54    DctDecode,
55}
56
57/// One embeddable `/Subtype /Image` XObject (ISO 32000-1 8.9.5), named for
58/// that specific PDF construct the same way `CidFont` is named for its
59/// (`Type0`/`CIDFontType2`) construct, rather than for the generic Pdf-noun
60/// pattern used by [`PdfPage`]/[`PdfDocument`]/[`PdfWriter`] (those name the
61/// crate's document-structure types; this and `CidFont` name embeddable
62/// resource types). `smask`, if present, is itself an `ImageXObject`
63/// (always `DeviceGray`, `filter: None`, no further `smask`) — PNG alpha,
64/// per ADR-013.
65pub struct ImageXObject {
66    pub width_px: u32,
67    pub height_px: u32,
68    pub color_space: ColorSpace,
69    pub bits_per_component: u8,
70    pub filter: ImageDataFilter,
71    pub bytes: Vec<u8>,
72    pub smask: Option<Box<ImageXObject>>,
73}
74
75#[derive(Clone, Debug)]
76pub enum PdfLinkAction {
77    /// `/A << /S /URI /URI (...) >>` — an external link.
78    Uri(String),
79    /// `/Dest [pageRef /XYZ null y null]` — an internal jump target.
80    /// `page_index` is resolved against the writer's own `page_refs`
81    /// (built before any page is written, so a forward reference to a
82    /// later page is fine) at write time, not by the caller.
83    GoTo { page_index: usize, y: f32 },
84}
85
86#[derive(Clone, Debug)]
87pub struct PdfLinkAnnotation {
88    pub rect: (f32, f32, f32, f32),
89    pub action: PdfLinkAction,
90}
91
92#[derive(Default)]
93pub struct PdfPage {
94    pub width: f32,
95    pub height: f32,
96    pub content: Vec<u8>,
97    pub annotations: Vec<PdfLinkAnnotation>,
98}
99
100/// One entry in the `/Outlines` bookmark tree (`Text::outline_level`,
101/// resolved). `page_index`/`y` mean the same thing as `PdfLinkAction::GoTo`
102/// — resolved against `page_refs` at write time, not by the caller.
103#[derive(Clone, Debug)]
104pub struct PdfOutlineNode {
105    pub title: String,
106    pub page_index: usize,
107    pub y: f32,
108    pub children: Vec<PdfOutlineNode>,
109}
110
111#[derive(Clone, Debug, Default)]
112pub struct PdfMetadata {
113    pub title: Option<String>,
114    pub author: Option<String>,
115    pub subject: Option<String>,
116    pub keywords: Option<String>,
117    pub creator: Option<String>,
118    /// Already-formatted PDF date strings (`D:YYYYMMDDHHmmSSZ`) — this
119    /// crate has no date logic of its own, the facade formats
120    /// `lightweight_pdf_core::PdfDate` before handing it over.
121    pub creation_date: Option<String>,
122    pub mod_date: Option<String>,
123    /// ISO 8601 versions of the two dates above, for XMP (`xmp:CreateDate`/
124    /// `xmp:ModifyDate`, issue #25) — a second field rather than
125    /// reformatting `creation_date`/`mod_date` here, keeping this crate's
126    /// "no date logic of its own" property (see above): the facade
127    /// already has `PdfDate` and formats both strings from it.
128    #[cfg(feature = "pdf-a")]
129    pub xmp_creation_date: Option<String>,
130    #[cfg(feature = "pdf-a")]
131    pub xmp_mod_date: Option<String>,
132}
133
134#[derive(Default)]
135pub struct PdfDocument {
136    fonts: Vec<CidFont>,
137    images: Vec<ImageXObject>,
138    pages: Vec<PdfPage>,
139    pub metadata: PdfMetadata,
140    /// Top-level bookmark entries; empty means no `/Outlines` object at
141    /// all (not an empty one — a reader shouldn't see a bookmark panel
142    /// with nothing in it for a document with no headings).
143    pub outline: Vec<PdfOutlineNode>,
144    /// Set by the facade when `Document::pdf_a3b()` was called (issue
145    /// #25) — adds XMP metadata, `/OutputIntent` (embedded sRGB ICC
146    /// profile) and a transparency-group colour space per page.
147    #[cfg(feature = "pdf-a")]
148    pub pdf_a3b: bool,
149    /// Set by the facade when `Document::zugferd_xml()` was called
150    /// (issue #26) — the raw ZUGFeRD/Factur-X invoice XML to embed.
151    #[cfg(feature = "zugferd")]
152    pub zugferd_xml: Option<Vec<u8>>,
153    /// Catalog `/Lang` (issue #27) — always available, not gated on
154    /// `tagged-pdf`: cheap, and meaningful to any reader/screen reader
155    /// regardless of whether the rest of the document is tagged.
156    pub lang: Option<String>,
157    /// Set by the facade when `Document::pdf_ua()` was called (issue
158    /// #27) — adds `/MarkInfo`, the `pdfuaid:*` XMP properties, and (via
159    /// `struct_tree`, populated by the facade during rendering)
160    /// `/StructTreeRoot`.
161    #[cfg(feature = "tagged-pdf")]
162    pub pdf_ua: bool,
163    /// The structure tree's root `Document` element, built by the facade
164    /// while rendering (mirrors how `outline` is built by
165    /// `text::build_outline`) — `None` until rendering finishes filling
166    /// it in, even when `pdf_ua` is set.
167    #[cfg(feature = "tagged-pdf")]
168    pub struct_tree: Option<crate::struct_tree::PdfStructNode>,
169}
170
171impl PdfDocument {
172    pub fn new() -> Self {
173        Self::default()
174    }
175
176    /// Registers a font, returning its index (used to build the resource
177    /// name `F{index + 1}` referenced from content streams via
178    /// [`Self::font_resource_name`]).
179    pub fn add_font(&mut self, font: CidFont) -> usize {
180        self.fonts.push(font);
181        self.fonts.len() - 1
182    }
183
184    pub fn font_resource_name(index: usize) -> String {
185        format!("F{}", index + 1)
186    }
187
188    /// Registers an image, returning its index (used to build the
189    /// resource name `Im{index + 1}`).
190    pub fn add_image(&mut self, image: ImageXObject) -> usize {
191        self.images.push(image);
192        self.images.len() - 1
193    }
194
195    pub fn image_resource_name(index: usize) -> String {
196        format!("Im{}", index + 1)
197    }
198
199    pub fn add_page(&mut self, page: PdfPage) {
200        self.pages.push(page);
201    }
202
203    /// `self.pdf_a3b` when the `pdf-a` feature is compiled in, `false`
204    /// otherwise — one place for the `#[cfg(...)]` instead of scattering
205    /// it through `write()`.
206    #[cfg(feature = "pdf-a")]
207    fn is_pdf_a3b(&self) -> bool {
208        self.pdf_a3b
209    }
210
211    #[cfg(not(feature = "pdf-a"))]
212    fn is_pdf_a3b(&self) -> bool {
213        false
214    }
215
216    /// FontDescriptor `/Flags`: bit 6 (32) = Nonsymbolic, bit 7 (64) =
217    /// Italic when applicable.
218    fn descriptor_flags(font: &CidFont) -> u32 {
219        let mut flags = 32u32;
220        if font.is_italic {
221            flags |= 64;
222        }
223        flags
224    }
225
226    /// `/ToUnicode` CMap program body: maps each CID back to its Unicode
227    /// scalar so text stays copyable/searchable despite Identity-H
228    /// encoding. Chunked into groups of <=100 `bfchar` entries, the
229    /// conventional safe limit for CMap resources.
230    fn to_unicode_cmap(font: &CidFont) -> Vec<u8> {
231        let mut body = String::new();
232        body.push_str("/CIDInit /ProcSet findresource begin\n");
233        body.push_str("12 dict begin\n");
234        body.push_str("begincmap\n");
235        body.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
236        body.push_str("/CMapName /Adobe-Identity-UCS def\n");
237        body.push_str("/CMapType 2 def\n");
238        body.push_str("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n");
239        for chunk in font.to_unicode.chunks(100) {
240            body.push_str(&format!("{} beginbfchar\n", chunk.len()));
241            for &(cid, ch) in chunk {
242                let utf16: Vec<u16> = ch.encode_utf16(&mut [0u16; 2]).to_vec();
243                let hex: String = utf16.iter().map(|u| format!("{u:04X}")).collect();
244                body.push_str(&format!("<{cid:04X}> <{hex}>\n"));
245            }
246            body.push_str("endbfchar\n");
247        }
248        body.push_str("endcmap\n");
249        body.push_str("CMapType findresource /CMap defineresource pop\n");
250        body.push_str("end\n");
251        body.push_str("end");
252        body.into_bytes()
253    }
254
255    /// The ICC Consortium's own reference sRGB profile (v4, `sRGB2014.icc`,
256    /// 3 KiB) — "may be copied, distributed, embedded, made, used, and
257    /// sold without restriction" per color.org's own license terms for
258    /// this file, unaltered here. Small enough that PDF/A-3b's mandatory
259    /// `/OutputIntent` (ISO 19005-3 6.2.4.3) barely moves output size —
260    /// see issue #25's size-impact question.
261    #[cfg(feature = "pdf-a")]
262    const SRGB_ICC_PROFILE: &[u8] = include_bytes!("../assets/sRGB2014.icc");
263
264    /// Writes the embedded-ICC-profile stream and the `/OutputIntent`
265    /// dictionary that references it, returning the latter's ref (what
266    /// `/OutputIntents` in the Catalog holds an array of).
267    #[cfg(feature = "pdf-a")]
268    fn write_output_intent(w: &mut PdfWriter) -> Ref {
269        let profile_ref = w.alloc();
270        w.compressed_stream(profile_ref, "/N 3", Self::SRGB_ICC_PROFILE);
271        let intent_ref = w.alloc();
272        w.object(
273            intent_ref,
274            &format!(
275                "<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB IEC61966-2.1) /Info (sRGB IEC61966-2.1) /DestOutputProfile {} >>",
276                profile_ref.write()
277            ),
278        );
279        intent_ref
280    }
281
282    /// Writes the XMP metadata stream (issue #25) and returns its ref —
283    /// `/Type /Metadata /Subtype /XML`, left uncompressed (conventional
284    /// for XMP packets: some tooling scans for `<?xpacket` directly
285    /// without going through `/FlateDecode`).
286    #[cfg(feature = "pdf-a")]
287    fn write_xmp_metadata(w: &mut PdfWriter, metadata: &PdfMetadata, zugferd: bool, pdf_ua: bool) -> Ref {
288        let xmp = build_xmp_packet(metadata, zugferd, pdf_ua);
289        let id = w.alloc();
290        w.stream(id, "/Type /Metadata /Subtype /XML", xmp.as_bytes());
291        id
292    }
293
294    /// `self.zugferd_xml.is_some()` when the `zugferd` feature is
295    /// compiled in, `false` otherwise (issue #26) — mirrors
296    /// [`Self::is_pdf_a3b`].
297    #[cfg(feature = "zugferd")]
298    fn is_zugferd(&self) -> bool {
299        self.zugferd_xml.is_some()
300    }
301
302    #[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
303    fn is_zugferd(&self) -> bool {
304        false
305    }
306
307    /// `self.pdf_ua` when the `tagged-pdf` feature is compiled in,
308    /// `false` otherwise (issue #27) — mirrors [`Self::is_pdf_a3b`].
309    #[cfg(feature = "tagged-pdf")]
310    fn is_pdf_ua(&self) -> bool {
311        self.pdf_ua
312    }
313
314    #[cfg(not(feature = "tagged-pdf"))]
315    fn is_pdf_ua(&self) -> bool {
316        false
317    }
318
319    /// Writes the embedded-file stream + file specification for the
320    /// ZUGFeRD/Factur-X XML (issue #26, ISO 19005-3 6.8) and returns the
321    /// file specification's ref — what `/AF` and `/Names/EmbeddedFiles`
322    /// in the Catalog both point at (the same object, two different
323    /// discovery mechanisms: `/AF` is PDF/A-3's own "this is associated
324    /// with the document" marker, `/Names/EmbeddedFiles` is the older,
325    /// universal attachments name tree most PDF viewers use for their
326    /// attachments panel).
327    #[cfg(feature = "zugferd")]
328    fn write_zugferd_attachment(w: &mut PdfWriter, xml: &[u8]) -> Ref {
329        const FILENAME: &str = "factur-x.xml";
330        let file_ref = w.alloc();
331        w.compressed_stream(file_ref, "/Type /EmbeddedFile /Subtype /text#2Fxml", xml);
332        let filespec_ref = w.alloc();
333        let name = format_pdf_string(FILENAME);
334        w.object(
335            filespec_ref,
336            &format!(
337                "<< /Type /Filespec /F {name} /UF {name} /AFRelationship /Alternative /EF << /F {file} /UF {file} >> >>",
338                file = file_ref.write(),
339            ),
340        );
341        filespec_ref
342    }
343
344    /// The Catalog-level `/AF`/`/Names/EmbeddedFiles` entries for the
345    /// ZUGFeRD attachment, or an empty string if none is set — mirrors
346    /// [`Self::is_pdf_a3b`]/[`Self::is_zugferd`]'s always-present-dispatch
347    /// shape so the `pdf-a`-only build (no `zugferd`) needs no `#[cfg]`
348    /// at the call site.
349    #[cfg(feature = "zugferd")]
350    fn write_zugferd_catalog_entry(&self, w: &mut PdfWriter) -> String {
351        match self.zugferd_xml.as_deref() {
352            Some(xml) => {
353                let filespec_ref = Self::write_zugferd_attachment(w, xml);
354                format!(
355                    " /AF [{fs}] /Names << /EmbeddedFiles << /Names [(factur-x.xml) {fs}] >> >>",
356                    fs = filespec_ref.write()
357                )
358            }
359            None => String::new(),
360        }
361    }
362
363    #[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
364    fn write_zugferd_catalog_entry(&self, _w: &mut PdfWriter) -> String {
365        String::new()
366    }
367
368    /// Writes one image XObject (recursing once for `smask`, PNG alpha)
369    /// and returns its object reference.
370    fn write_image(w: &mut PdfWriter, image: &ImageXObject) -> Ref {
371        let smask_ref = image.smask.as_deref().map(|m| Self::write_image(w, m));
372        let image_ref = w.alloc();
373        let filter = match image.filter {
374            ImageDataFilter::None => String::new(),
375            ImageDataFilter::DctDecode => " /Filter /DCTDecode".to_string(),
376        };
377        // `smask` is a genuinely optional field (most images carry no alpha
378        // mask) — omitting `/SMask` when absent is not a swallowed error.
379        let smask_entry = match smask_ref {
380            Some(r) => format!(" /SMask {}", r.write()),
381            None => String::new(),
382        };
383        let dict = format!(
384            "/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /{cs} /BitsPerComponent {bpc}{filter}{smask}",
385            w = image.width_px,
386            h = image.height_px,
387            cs = image.color_space.as_pdf_name(),
388            bpc = image.bits_per_component,
389            filter = filter,
390            smask = smask_entry,
391        );
392        // JPEG samples (DctDecode) are already compressed — re-deflating
393        // near-random bytes wastes CPU for ~0 size benefit, so only raw
394        // (None) samples go through the compressing path.
395        match image.filter {
396            ImageDataFilter::None => w.compressed_stream(image_ref, &dict, &image.bytes),
397            ImageDataFilter::DctDecode => w.stream(image_ref, &dict, &image.bytes),
398        }
399        image_ref
400    }
401
402    /// Maps each item through `f` and joins the results with a single
403    /// space — shared by [`Self::write_fonts`]'s glyph-width array and
404    /// [`Self::write`]'s `/Kids` array.
405    fn join_with_space<T>(items: &[T], f: impl Fn(&T) -> String) -> String {
406        items.iter().map(f).collect::<Vec<_>>().join(" ")
407    }
408
409    /// Formats `/Name Ref` resource-dictionary entries (space-joined) for a
410    /// sequence of object refs — shared by [`Self::write_fonts`]'s `/Font`
411    /// entries and [`Self::write`]'s `/XObject` entries.
412    fn resource_entries(refs: &[Ref], name_fn: impl Fn(usize) -> String) -> String {
413        refs.iter()
414            .enumerate()
415            .map(|(i, r)| format!("/{} {}", name_fn(i), r.write()))
416            .collect::<Vec<_>>()
417            .join(" ")
418    }
419
420    /// Writes all font objects (Type0 + CIDFontType2 + FontDescriptor +
421    /// embedded subset FontFile2 + ToUnicode) and returns the `/Font`
422    /// resource-dictionary entries for the page objects. Zips `fonts` with
423    /// their pre-allocated refs rather than indexing by position, so the
424    /// pairing can't panic even if the two ever fell out of step.
425    fn write_fonts(w: &mut PdfWriter, fonts: &[CidFont]) -> String {
426        let font_refs: Vec<(Ref, Ref, Ref, Ref)> = fonts.iter().map(|_| (w.alloc(), w.alloc(), w.alloc(), w.alloc())).collect();
427
428        for (font, &(type0_ref, cid_ref, descriptor_ref, file_ref)) in fonts.iter().zip(&font_refs) {
429            let to_unicode_ref = w.alloc();
430
431            let widths_str = Self::join_with_space(&font.widths, |w| fmt_num(*w));
432
433            w.object(
434                type0_ref,
435                &format!(
436                    "<< /Type /Font /Subtype /Type0 /BaseFont /{base} /Encoding /Identity-H /DescendantFonts [{cid}] /ToUnicode {tu} >>",
437                    base = font.base_font,
438                    cid = cid_ref.write(),
439                    tu = to_unicode_ref.write(),
440                ),
441            );
442            w.object(
443                cid_ref,
444                &format!(
445                    "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{base} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} /DW 1000 /W [0 [{widths}]] /CIDToGIDMap /Identity >>",
446                    base = font.base_font,
447                    desc = descriptor_ref.write(),
448                    widths = widths_str,
449                ),
450            );
451            w.object(
452                descriptor_ref,
453                &format!(
454                    "<< /Type /FontDescriptor /FontName /{base} /Flags {flags} /FontBBox [{bx0} {by0} {bx1} {by1}] /ItalicAngle {italic} /Ascent {ascent} /Descent {descent} /CapHeight {cap} /StemV {stemv} /FontFile2 {file} >>",
455                    base = font.base_font,
456                    flags = Self::descriptor_flags(font),
457                    bx0 = fmt_num(font.bbox.0),
458                    by0 = fmt_num(font.bbox.1),
459                    bx1 = fmt_num(font.bbox.2),
460                    by1 = fmt_num(font.bbox.3),
461                    italic = fmt_num(font.italic_angle),
462                    ascent = fmt_num(font.ascent),
463                    descent = fmt_num(font.descent),
464                    cap = fmt_num(font.cap_height),
465                    stemv = if font.is_bold { 120 } else { 80 },
466                    file = file_ref.write(),
467                ),
468            );
469            w.compressed_stream(file_ref, &format!("/Length1 {}", font.subset_bytes.len()), &font.subset_bytes);
470            w.compressed_stream(to_unicode_ref, "", &Self::to_unicode_cmap(font));
471        }
472
473        let type0_refs: Vec<Ref> = font_refs.iter().map(|&(t, ..)| t).collect();
474        Self::resource_entries(&type0_refs, Self::font_resource_name)
475    }
476
477    /// Writes each page's `/Page` object and content stream and returns the
478    /// page object refs (used by the caller to build the `/Pages /Kids`
479    /// array). Zips `pages` with their pre-allocated refs rather than
480    /// indexing by position, so the pairing can't panic even if the two
481    /// ever fell out of step.
482    fn write_pages(
483        w: &mut PdfWriter,
484        pages: &[PdfPage],
485        pages_ref: Ref,
486        font_resources: &str,
487        image_resources: &str,
488        pdf_a3b: bool,
489        pdf_ua: bool,
490    ) -> Vec<Ref> {
491        let page_refs: Vec<Ref> = (0..pages.len()).map(|_| w.alloc()).collect();
492        let content_refs: Vec<Ref> = (0..pages.len()).map(|_| w.alloc()).collect();
493
494        for (page_index, ((page, &page_ref), &content_ref)) in pages.iter().zip(&page_refs).zip(&content_refs).enumerate() {
495            let mut annot_refs = Vec::new();
496            for annot in &page.annotations {
497                let id = w.alloc();
498                let action = match &annot.action {
499                    PdfLinkAction::Uri(uri) => format!("/A << /S /URI /URI {} >>", format_pdf_string(uri)),
500                    PdfLinkAction::GoTo { page_index, y } => {
501                        // Falls back to this annotation's own page if
502                        // `page_index` is somehow out of range — a link to
503                        // itself is a harmless no-op, not a broken PDF.
504                        let target = page_refs.get(*page_index).copied().unwrap_or(page_ref);
505                        format!("/Dest [{} /XYZ null {} null]", target.write(), fmt_num(*y))
506                    }
507                };
508                // PDF/A-3b (ISO 19005-3 6.5.2): every annotation needs an
509                // `/F` flags entry — `4` is the Print bit alone (Hidden/
510                // NoView unset, both forbidden by the same clause).
511                let flags_entry = if pdf_a3b { " /F 4" } else { "" };
512                w.object(
513                    id,
514                    &format!(
515                        "<< /Type /Annot /Subtype /Link /Rect [{x0} {y0} {x1} {y1}] /Border [0 0 0]{flags} {action} >>",
516                        x0 = fmt_num(annot.rect.0),
517                        y0 = fmt_num(annot.rect.1),
518                        x1 = fmt_num(annot.rect.2),
519                        y1 = fmt_num(annot.rect.3),
520                        flags = flags_entry,
521                    ),
522                );
523                annot_refs.push(id);
524            }
525
526            let annots_entry = if !annot_refs.is_empty() {
527                let refs = Self::join_with_space(&annot_refs, |r| r.write());
528                format!(" /Annots [{refs}]")
529            } else {
530                String::new()
531            };
532
533            // PDF/A-3b (issue #25, ISO 19005-3 6.2.10): a page with a
534            // transparent object (PNG alpha via `/SMask`) needs a defined
535            // blending colour space — declared once per page rather than
536            // only on pages that actually use transparency, since that's
537            // simpler and costs a few bytes.
538            let group_entry = if pdf_a3b {
539                " /Group << /Type /Group /S /Transparency /CS /DeviceRGB >>"
540            } else {
541                ""
542            };
543
544            // Issue #27: `/StructParents` is this page's key into
545            // `/ParentTree` (`struct_tree::write_struct_tree` assigns
546            // every page `0..page_refs.len()`, matching `page_index` here
547            // exactly).
548            let struct_parents_entry = if pdf_ua {
549                format!(" /StructParents {page_index}")
550            } else {
551                String::new()
552            };
553
554            w.object(
555                page_ref,
556                &format!(
557                    "<< /Type /Page /Parent {parent} /MediaBox [0 0 {w} {h}] /Resources << /Font << {fonts} >> /XObject << {images} >> >>{group}{struct_parents} /Contents {content}{annots} >>",
558                    parent = pages_ref.write(),
559                    w = fmt_num(page.width),
560                    h = fmt_num(page.height),
561                    fonts = font_resources,
562                    images = image_resources,
563                    group = group_entry,
564                    struct_parents = struct_parents_entry,
565                    content = content_ref.write(),
566                    annots = annots_entry,
567                ),
568            );
569            w.compressed_stream(content_ref, "", &page.content);
570        }
571
572        page_refs
573    }
574
575    /// Writes the `/Outlines` bookmark tree and returns its object ref, or
576    /// `None` if `outline` is empty — a document with no headings gets no
577    /// `/Outlines` entry at all, not an empty bookmark panel. Two passes:
578    /// [`alloc_outline_refs`] allocates one `Ref` per node first (so
579    /// siblings/parents can reference each other regardless of write
580    /// order), [`write_outline_siblings`] then writes every node's dict.
581    fn write_outline(w: &mut PdfWriter, outline: &[PdfOutlineNode], page_refs: &[Ref]) -> Option<Ref> {
582        if outline.is_empty() {
583            return None;
584        }
585        let outlines_ref = w.alloc();
586        let ref_tree = alloc_outline_refs(w, outline);
587        write_outline_siblings(w, outline, &ref_tree, outlines_ref, page_refs);
588
589        let total_count: i64 = outline.iter().map(|n| 1 + count_descendants(n)).sum();
590        let first = ref_tree.first().map(|t| t.r);
591        let last = ref_tree.last().map(|t| t.r);
592        let mut entries = vec!["/Type /Outlines".to_string(), format!("/Count {total_count}")];
593        if let Some(f) = first {
594            entries.push(format!("/First {}", f.write()));
595        }
596        if let Some(l) = last {
597            entries.push(format!("/Last {}", l.write()));
598        }
599        w.object(outlines_ref, &format!("<< {} >>", entries.join(" ")));
600        Some(outlines_ref)
601    }
602
603    /// Assembles the full PDF byte stream: Catalog, Pages, Page objects,
604    /// content streams, fonts (Type0 + CIDFontType2 + FontDescriptor +
605    /// embedded subset FontFile2 + ToUnicode), images (XObjects + optional
606    /// SMask), xref and trailer.
607    pub fn write(&self) -> Vec<u8> {
608        let mut w = PdfWriter::new();
609
610        let catalog_ref = w.alloc();
611        let pages_ref = w.alloc();
612
613        let image_refs: Vec<Ref> = self.images.iter().map(|img| Self::write_image(&mut w, img)).collect();
614        let image_resources = Self::resource_entries(&image_refs, Self::image_resource_name);
615
616        let pdf_a3b = self.is_pdf_a3b();
617        let pdf_ua = self.is_pdf_ua();
618
619        let font_resources = Self::write_fonts(&mut w, &self.fonts);
620        let page_refs = Self::write_pages(&mut w, &self.pages, pages_ref, &font_resources, &image_resources, pdf_a3b, pdf_ua);
621
622        let kids = Self::join_with_space(&page_refs, |r| r.write());
623        w.object(pages_ref, &format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", self.pages.len()));
624
625        let outlines_entry = match Self::write_outline(&mut w, &self.outline, &page_refs) {
626            Some(outlines_ref) => format!(" /Outlines {}", outlines_ref.write()),
627            None => String::new(),
628        };
629
630        #[cfg(feature = "pdf-a")]
631        let pdf_a_entry = if pdf_a3b {
632            let output_intent_ref = Self::write_output_intent(&mut w);
633            let metadata_ref = Self::write_xmp_metadata(&mut w, &self.metadata, self.is_zugferd(), pdf_ua);
634            let zugferd_entry = self.write_zugferd_catalog_entry(&mut w);
635            format!(
636                " /OutputIntents [{}] /Metadata {}{zugferd_entry}",
637                output_intent_ref.write(),
638                metadata_ref.write()
639            )
640        } else {
641            String::new()
642        };
643        #[cfg(not(feature = "pdf-a"))]
644        let pdf_a_entry = String::new();
645
646        #[cfg(feature = "tagged-pdf")]
647        let tagged_entry = if pdf_ua {
648            use crate::struct_tree::{write_struct_tree, PdfStructNode};
649            let empty_root = PdfStructNode::Elem {
650                tag: "Document",
651                alt: None,
652                attrs: None,
653                children: Vec::new(),
654            };
655            let root = self.struct_tree.as_ref().unwrap_or(&empty_root);
656            let (struct_tree_root_ref, _struct_parents) = write_struct_tree(&mut w, root, &page_refs);
657            // `/ViewerPreferences /DisplayDocTitle true` (ISO 14289-1
658            // 7.1, "the DisplayDocTitle... shall be true") — found via
659            // an actual veraPDF PDF/UA run, not from the spec text
660            // alone.
661            format!(
662                " /StructTreeRoot {} /MarkInfo << /Marked true >> /ViewerPreferences << /DisplayDocTitle true >>",
663                struct_tree_root_ref.write()
664            )
665        } else {
666            String::new()
667        };
668        #[cfg(not(feature = "tagged-pdf"))]
669        let tagged_entry = String::new();
670
671        let lang_entry = match &self.lang {
672            Some(lang) => format!(" /Lang {}", format_pdf_string(lang)),
673            None => String::new(),
674        };
675
676        w.object(
677            catalog_ref,
678            &format!(
679                "<< /Type /Catalog /Pages {}{outlines_entry}{pdf_a_entry}{tagged_entry}{lang_entry} >>",
680                pages_ref.write()
681            ),
682        );
683
684        let mut info_entries = Vec::new();
685        if let Some(ref title) = self.metadata.title {
686            info_entries.push(format!("/Title {}", format_pdf_string(title)));
687        }
688        if let Some(ref author) = self.metadata.author {
689            info_entries.push(format!("/Author {}", format_pdf_string(author)));
690        }
691        if let Some(ref subject) = self.metadata.subject {
692            info_entries.push(format!("/Subject {}", format_pdf_string(subject)));
693        }
694        if let Some(ref keywords) = self.metadata.keywords {
695            info_entries.push(format!("/Keywords {}", format_pdf_string(keywords)));
696        }
697        if let Some(ref creator) = self.metadata.creator {
698            info_entries.push(format!("/Creator {}", format_pdf_string(creator)));
699        }
700        if let Some(ref creation_date) = self.metadata.creation_date {
701            info_entries.push(format!("/CreationDate {}", format_pdf_string(creation_date)));
702        }
703        if let Some(ref mod_date) = self.metadata.mod_date {
704            info_entries.push(format!("/ModDate {}", format_pdf_string(mod_date)));
705        }
706        info_entries.push(format!("/Producer {}", format_pdf_string(PRODUCER)));
707
708        let info_ref = {
709            let id = w.alloc();
710            w.object(id, &format!("<< {} >>", info_entries.join(" ")));
711            Some(id)
712        };
713
714        w.finish(catalog_ref, info_ref)
715    }
716}
717
718/// [`PdfOutlineNode`]'s shape, mirrored with an allocated [`Ref`] per node
719/// instead of the node data — lets [`write_outline_siblings`] look up any
720/// node's own/children's refs without re-allocating or borrowing `w`.
721struct RefTree {
722    r: Ref,
723    children: Vec<RefTree>,
724}
725
726fn alloc_outline_refs(w: &mut PdfWriter, nodes: &[PdfOutlineNode]) -> Vec<RefTree> {
727    nodes
728        .iter()
729        .map(|n| RefTree {
730            r: w.alloc(),
731            children: alloc_outline_refs(w, &n.children),
732        })
733        .collect()
734}
735
736/// Total number of descendants (not just direct children) — the PDF
737/// `/Count` an always-expanded outline entry needs.
738fn count_descendants(node: &PdfOutlineNode) -> i64 {
739    node.children.len() as i64 + node.children.iter().map(count_descendants).sum::<i64>()
740}
741
742/// Writes every node in `nodes` (a sibling list — top-level entries or one
743/// node's children) as its own indirect object: `/Title`, `/Parent`,
744/// `/Prev`/`/Next` (siblings), `/First`/`/Last`/`/Count` (children), and
745/// `/Dest` resolved from `page_index`/`y` against `page_refs` (falls back
746/// to the entry's own object if `page_index` is somehow out of range — a
747/// self-link is a harmless no-op, not a broken PDF). Recurses into each
748/// node's own children afterwards.
749fn write_outline_siblings(w: &mut PdfWriter, nodes: &[PdfOutlineNode], ref_nodes: &[RefTree], parent_ref: Ref, page_refs: &[Ref]) {
750    for (i, (node, ref_node)) in nodes.iter().zip(ref_nodes).enumerate() {
751        let prev = (i > 0).then(|| ref_nodes[i - 1].r);
752        let next = (i + 1 < nodes.len()).then(|| ref_nodes[i + 1].r);
753        let first = ref_node.children.first().map(|c| c.r);
754        let last = ref_node.children.last().map(|c| c.r);
755        let count = count_descendants(node);
756        let target_page = page_refs.get(node.page_index).copied().unwrap_or(ref_node.r);
757
758        let mut entries = vec![
759            format!("/Title {}", format_pdf_string(&node.title)),
760            format!("/Parent {}", parent_ref.write()),
761            format!("/Dest [{} /XYZ null {} null]", target_page.write(), fmt_num(node.y)),
762        ];
763        if let Some(p) = prev {
764            entries.push(format!("/Prev {}", p.write()));
765        }
766        if let Some(n) = next {
767            entries.push(format!("/Next {}", n.write()));
768        }
769        if let Some(f) = first {
770            entries.push(format!("/First {}", f.write()));
771        }
772        if let Some(l) = last {
773            entries.push(format!("/Last {}", l.write()));
774        }
775        if count > 0 {
776            entries.push(format!("/Count {count}"));
777        }
778        w.object(ref_node.r, &format!("<< {} >>", entries.join(" ")));
779
780        write_outline_siblings(w, &node.children, &ref_node.children, ref_node.r, page_refs);
781    }
782}
783
784pub(crate) fn format_pdf_string(s: &str) -> String {
785    let escaped = s.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
786    format!("({escaped})")
787}
788
789/// Escapes the five XML predefined entities' triggers that can appear in
790/// caller-supplied metadata text — enough for XMP's RDF/XML, which never
791/// needs attribute-quote escaping here (everything below goes in element
792/// content, not an attribute value).
793#[cfg(feature = "pdf-a")]
794fn xml_escape(s: &str) -> String {
795    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
796}
797
798/// Builds the XMP packet (issue #25): `dc:title`/`dc:creator`/
799/// `dc:description`/`pdf:Keywords`/`xmp:CreatorTool`/`xmp:CreateDate`/
800/// `xmp:ModifyDate` mirror `PdfMetadata`'s `/Info` fields 1:1 (ISO
801/// 19005-3 6.7.3 — the two are required to stay consistent, so this
802/// reads from the very same `PdfMetadata` the `/Info` dict is built
803/// from, never a separately-tracked copy), plus the `pdfaid:part`/
804/// `pdfaid:conformance` conformance markers every PDF/A file needs.
805#[cfg(feature = "pdf-a")]
806fn build_xmp_packet(metadata: &PdfMetadata, zugferd: bool, pdf_ua: bool) -> String {
807    let mut props = String::new();
808    if let Some(ref title) = metadata.title {
809        props.push_str(&format!(
810            "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>",
811            xml_escape(title)
812        ));
813    }
814    if let Some(ref author) = metadata.author {
815        props.push_str(&format!(
816            "<dc:creator><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></dc:creator>",
817            xml_escape(author)
818        ));
819    }
820    if let Some(ref subject) = metadata.subject {
821        props.push_str(&format!(
822            "<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:description>",
823            xml_escape(subject)
824        ));
825    }
826    if let Some(ref keywords) = metadata.keywords {
827        props.push_str(&format!("<pdf:Keywords>{}</pdf:Keywords>", xml_escape(keywords)));
828    }
829    if let Some(ref creator) = metadata.creator {
830        props.push_str(&format!("<xmp:CreatorTool>{}</xmp:CreatorTool>", xml_escape(creator)));
831    }
832    if let Some(ref created) = metadata.xmp_creation_date {
833        props.push_str(&format!("<xmp:CreateDate>{created}</xmp:CreateDate>"));
834    }
835    if let Some(ref modified) = metadata.xmp_mod_date {
836        props.push_str(&format!("<xmp:ModifyDate>{modified}</xmp:ModifyDate>"));
837    }
838    props.push_str("<pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>");
839    // PDF/UA-1 identification (issue #27) — `Document::pdf_ua()` always
840    // implies `pdf_a3b()` (ADR-019), so this XMP packet already carries
841    // the `pdfaid:*` markers above; PDF/UA just adds its own alongside
842    // them, both correctly describing the same file.
843    if pdf_ua {
844        props.push_str("<pdfuaid:part>1</pdfuaid:part>");
845    }
846
847    let zugferd_block = if zugferd { ZUGFERD_XMP_EXTENSION } else { "" };
848    // PDF/A-3b's own XMP validation rejects any property that isn't
849    // either a predefined schema or described by a PDF/A Extension
850    // Schema (exactly the rule that motivated `ZUGFERD_XMP_EXTENSION`
851    // above) — `pdfuaid:part` needs the same treatment. Found via an
852    // actual veraPDF PDF/A-3b run on a `pdf_ua()` document failing with
853    // "XMP property is either not predefined, or is not defined in any
854    // XMP extension schema" — not something the spec text alone would
855    // have flagged ahead of time.
856    let pdfua_block = if pdf_ua { PDFUA_XMP_EXTENSION } else { "" };
857
858    format!(
859        "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\
860<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\
861<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
862<rdf:Description rdf:about=\"\" \
863xmlns:dc=\"http://purl.org/dc/elements/1.1/\" \
864xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" \
865xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
866xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\" \
867xmlns:pdfuaid=\"http://www.aiim.org/pdfua/ns/id/\">\
868{props}\
869</rdf:Description>\
870{zugferd_block}\
871{pdfua_block}\
872</rdf:RDF>\
873</x:xmpmeta>\
874<?xpacket end=\"w\"?>"
875    )
876}
877
878#[cfg(all(feature = "pdf-a", not(feature = "tagged-pdf")))]
879const PDFUA_XMP_EXTENSION: &str = "";
880
881/// PDF/A Extension Schema description for the `pdfuaid` namespace (issue
882/// #27) — same shape as `ZUGFERD_XMP_EXTENSION`, one property
883/// (`part`, `Integer`).
884#[cfg(feature = "tagged-pdf")]
885const PDFUA_XMP_EXTENSION: &str = "\
886<rdf:Description rdf:about=\"\" \
887xmlns:pdfaExtension=\"http://www.aiim.org/pdfa/ns/extension/\" \
888xmlns:pdfaSchema=\"http://www.aiim.org/pdfa/ns/schema#\" \
889xmlns:pdfaProperty=\"http://www.aiim.org/pdfa/ns/property#\">\
890<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType=\"Resource\">\
891<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema>\
892<pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI>\
893<pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>\
894<pdfaSchema:property><rdf:Seq>\
895<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>part</pdfaProperty:name><pdfaProperty:valueType>Integer</pdfaProperty:valueType><pdfaProperty:category>internal</pdfaProperty:category><pdfaProperty:description>Indicates, as an integer, the part of ISO 14289 to which the file conforms</pdfaProperty:description></rdf:li>\
896</rdf:Seq></pdfaSchema:property>\
897</rdf:li></rdf:Bag></pdfaExtension:schemas>\
898</rdf:Description>";
899
900/// The Factur-X/ZUGFeRD 2.x XMP extension (issue #26): the `fx:*`
901/// property values (fixed for this crate's EN 16931/Comfort-only scope —
902/// `factur-x.xml`, `INVOICE`, version `1.0`) plus the mandatory
903/// `pdfaExtension`/`pdfaSchema`/`pdfaProperty` schema description PDF/A-3
904/// requires for any custom XMP namespace. Structure taken from PDFlib's
905/// own reference Factur-X sample
906/// (<https://github.com/atgp/factur-x/blob/master/xmp/Factur-X_extension_schema.xmp>),
907/// not reconstructed from the spec text — this block is exactly the kind
908/// of thing worth getting from a working reference rather than guessing.
909#[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
910const ZUGFERD_XMP_EXTENSION: &str = "";
911
912#[cfg(feature = "zugferd")]
913const ZUGFERD_XMP_EXTENSION: &str = "\
914<rdf:Description rdf:about=\"\" xmlns:fx=\"urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#\">\
915<fx:DocumentType>INVOICE</fx:DocumentType>\
916<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>\
917<fx:Version>1.0</fx:Version>\
918<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>\
919</rdf:Description>\
920<rdf:Description rdf:about=\"\" \
921xmlns:pdfaExtension=\"http://www.aiim.org/pdfa/ns/extension/\" \
922xmlns:pdfaSchema=\"http://www.aiim.org/pdfa/ns/schema#\" \
923xmlns:pdfaProperty=\"http://www.aiim.org/pdfa/ns/property#\">\
924<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType=\"Resource\">\
925<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>\
926<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>\
927<pdfaSchema:prefix>fx</pdfaSchema:prefix>\
928<pdfaSchema:property><rdf:Seq>\
929<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description></rdf:li>\
930<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>\
931<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description></rdf:li>\
932<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description></rdf:li>\
933</rdf:Seq></pdfaSchema:property>\
934</rdf:li></rdf:Bag></pdfaExtension:schemas>\
935</rdf:Description>";
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    fn tiny_font() -> CidFont {
942        CidFont {
943            base_font: "Test".to_string(),
944            subset_bytes: vec![0u8; 16],
945            widths: vec![0.0, 600.0],
946            ascent: 800.0,
947            descent: -200.0,
948            cap_height: 700.0,
949            italic_angle: 0.0,
950            bbox: (-100.0, -200.0, 900.0, 900.0),
951            is_italic: false,
952            is_bold: false,
953            to_unicode: vec![(1, 'H')],
954        }
955    }
956
957    #[test]
958    fn writes_a_single_empty_page() {
959        let mut doc = PdfDocument::new();
960        doc.add_page(PdfPage {
961            width: 595.0,
962            height: 842.0,
963            content: Vec::new(),
964            annotations: Vec::new(),
965        });
966        let bytes = doc.write();
967        let text = String::from_utf8_lossy(&bytes);
968        assert!(text.contains("/Type /Page"));
969        assert!(text.contains("/MediaBox [0 0 595 842]"));
970        assert!(text.contains("%%EOF"));
971    }
972
973    #[test]
974    fn writes_a_goto_destination_for_an_internal_link_annotation() {
975        let mut doc = PdfDocument::new();
976        doc.add_page(PdfPage {
977            width: 595.0,
978            height: 842.0,
979            content: Vec::new(),
980            annotations: vec![PdfLinkAnnotation {
981                rect: (10.0, 20.0, 100.0, 40.0),
982                action: PdfLinkAction::GoTo { page_index: 1, y: 700.0 },
983            }],
984        });
985        doc.add_page(PdfPage {
986            width: 595.0,
987            height: 842.0,
988            content: Vec::new(),
989            annotations: Vec::new(),
990        });
991        let bytes = doc.write();
992        let text = String::from_utf8_lossy(&bytes);
993        assert!(text.contains("/Subtype /Link"));
994        assert!(text.contains("/Dest ["));
995        assert!(text.contains("/XYZ null 700 null"));
996        assert!(!text.contains("/S /URI"), "a GoTo annotation must not also emit a URI action");
997    }
998
999    #[test]
1000    fn writes_type0_cid_font_structure() {
1001        let mut doc = PdfDocument::new();
1002        doc.add_font(tiny_font());
1003        doc.add_page(PdfPage {
1004            width: 595.0,
1005            height: 842.0,
1006            content: Vec::new(),
1007            annotations: Vec::new(),
1008        });
1009        let bytes = doc.write();
1010        let text = String::from_utf8_lossy(&bytes);
1011        assert!(text.contains("/Subtype /Type0"));
1012        assert!(text.contains("/Encoding /Identity-H"));
1013        assert!(text.contains("/Subtype /CIDFontType2"));
1014        assert!(text.contains("/CIDToGIDMap /Identity"));
1015        assert!(text.contains("/ToUnicode"));
1016        // The ToUnicode CMap body itself lives inside a stream, which is
1017        // `/FlateDecode`-compressed by default (ADR-016) — inflate every
1018        // stream body before checking, rather than the raw dict text.
1019        let decoded = stream_bodies_decoded(&bytes);
1020        assert!(decoded.contains("beginbfchar"));
1021        assert!(decoded.contains("<0001> <0048>")); // CID 1 -> U+0048 'H'
1022    }
1023
1024    /// Every `stream\n...\nendstream` payload in `bytes`, inflated (when
1025    /// `compress` is enabled — a no-op passthrough otherwise, since
1026    /// nothing is compressed then) and concatenated, for tests that need
1027    /// to read stream content rather than just check the surrounding
1028    /// dict.
1029    fn stream_bodies_decoded(bytes: &[u8]) -> String {
1030        const START: &[u8] = b"stream\n";
1031        const END: &[u8] = b"\nendstream";
1032        let mut bodies = Vec::new();
1033        let mut i = 0;
1034        while let Some(start_rel) = bytes[i..].windows(START.len()).position(|w| w == START) {
1035            let start = i + start_rel + START.len();
1036            let Some(end_rel) = bytes[start..].windows(END.len()).position(|w| w == END) else {
1037                break;
1038            };
1039            let end = start + end_rel;
1040            bodies.push(&bytes[start..end]);
1041            i = end + END.len();
1042        }
1043        bodies.into_iter().map(decode_one_stream_body).collect::<Vec<_>>().join("\n")
1044    }
1045
1046    #[cfg(feature = "compress")]
1047    fn decode_one_stream_body(body: &[u8]) -> String {
1048        match miniz_oxide::inflate::decompress_to_vec_zlib(body) {
1049            Ok(v) => String::from_utf8_lossy(&v).into_owned(),
1050            Err(_) => String::new(), // not a zlib stream (shouldn't happen for our own output) — skip, don't panic
1051        }
1052    }
1053
1054    #[cfg(not(feature = "compress"))]
1055    fn decode_one_stream_body(body: &[u8]) -> String {
1056        String::from_utf8_lossy(body).into_owned()
1057    }
1058
1059    #[test]
1060    fn writes_image_xobject_with_smask() {
1061        let mut doc = PdfDocument::new();
1062        doc.add_image(ImageXObject {
1063            width_px: 4,
1064            height_px: 4,
1065            color_space: ColorSpace::DeviceRgb,
1066            bits_per_component: 8,
1067            filter: ImageDataFilter::None,
1068            bytes: vec![0u8; 4 * 4 * 3],
1069            smask: Some(Box::new(ImageXObject {
1070                width_px: 4,
1071                height_px: 4,
1072                color_space: ColorSpace::DeviceGray,
1073                bits_per_component: 8,
1074                filter: ImageDataFilter::None,
1075                bytes: vec![255u8; 4 * 4],
1076                smask: None,
1077            })),
1078        });
1079        doc.add_page(PdfPage {
1080            width: 200.0,
1081            height: 200.0,
1082            content: Vec::new(),
1083            annotations: Vec::new(),
1084        });
1085        let bytes = doc.write();
1086        let text = String::from_utf8_lossy(&bytes);
1087        assert!(text.contains("/Subtype /Image"));
1088        assert!(text.contains("/ColorSpace /DeviceRGB"));
1089        assert!(text.contains("/ColorSpace /DeviceGray"));
1090        assert!(text.contains("/SMask"));
1091        assert!(text.contains("/XObject << /Im1"));
1092    }
1093
1094    #[test]
1095    fn writes_jpeg_image_with_dct_decode_filter() {
1096        let mut doc = PdfDocument::new();
1097        doc.add_image(ImageXObject {
1098            width_px: 10,
1099            height_px: 10,
1100            color_space: ColorSpace::DeviceRgb,
1101            bits_per_component: 8,
1102            filter: ImageDataFilter::DctDecode,
1103            bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], // stand-in bytes, structure only
1104            smask: None,
1105        });
1106        doc.add_page(PdfPage {
1107            width: 200.0,
1108            height: 200.0,
1109            content: Vec::new(),
1110            annotations: Vec::new(),
1111        });
1112        let bytes = doc.write();
1113        let text = String::from_utf8_lossy(&bytes);
1114        assert!(text.contains("/Filter /DCTDecode"));
1115    }
1116
1117    #[cfg(feature = "pdf-a")]
1118    #[test]
1119    fn writes_output_intent_and_xmp_metadata_when_pdf_a3b_is_set() {
1120        let mut doc = PdfDocument::new();
1121        doc.pdf_a3b = true;
1122        doc.metadata.title = Some("Rechnung".to_string());
1123        doc.add_page(PdfPage {
1124            width: 200.0,
1125            height: 200.0,
1126            content: Vec::new(),
1127            annotations: Vec::new(),
1128        });
1129        let bytes = doc.write();
1130        let text = String::from_utf8_lossy(&bytes);
1131        assert!(text.contains("/OutputIntents ["));
1132        assert!(text.contains("/S /GTS_PDFA1"));
1133        assert!(text.contains("/DestOutputProfile"));
1134        assert!(text.contains("/Type /Metadata /Subtype /XML"));
1135        assert!(text.contains("<pdfaid:part>3</pdfaid:part>"));
1136        assert!(text.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
1137        assert!(text.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Rechnung</rdf:li></rdf:Alt></dc:title>"));
1138    }
1139
1140    #[cfg(feature = "pdf-a")]
1141    #[test]
1142    fn omits_pdf_a_entries_when_pdf_a3b_is_not_set() {
1143        let mut doc = PdfDocument::new();
1144        doc.add_page(PdfPage {
1145            width: 200.0,
1146            height: 200.0,
1147            content: Vec::new(),
1148            annotations: Vec::new(),
1149        });
1150        let bytes = doc.write();
1151        let text = String::from_utf8_lossy(&bytes);
1152        assert!(!text.contains("/OutputIntents"));
1153        assert!(!text.contains("/Type /Metadata"));
1154        assert!(!text.contains("/Group"));
1155    }
1156
1157    #[cfg(feature = "zugferd")]
1158    #[test]
1159    fn embeds_zugferd_xml_with_af_and_xmp_extension() {
1160        let mut doc = PdfDocument::new();
1161        doc.pdf_a3b = true;
1162        doc.zugferd_xml = Some(b"<CrossIndustryInvoice/>".to_vec());
1163        doc.add_page(PdfPage {
1164            width: 200.0,
1165            height: 200.0,
1166            content: Vec::new(),
1167            annotations: Vec::new(),
1168        });
1169        let bytes = doc.write();
1170        let text = String::from_utf8_lossy(&bytes);
1171        assert!(text.contains("/Type /Filespec"));
1172        assert!(text.contains("/AFRelationship /Alternative"));
1173        assert!(text.contains("/Type /EmbeddedFile /Subtype /text#2Fxml"));
1174        assert!(text.contains("/AF ["));
1175        assert!(text.contains("/Names << /EmbeddedFiles"));
1176        assert!(text.contains("factur-x.xml"));
1177        assert!(text.contains("xmlns:fx=\"urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#\""));
1178        assert!(text.contains("<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>"));
1179        assert!(text.contains("pdfaSchema:namespaceURI"));
1180    }
1181
1182    #[cfg(feature = "zugferd")]
1183    #[test]
1184    fn omits_zugferd_entries_when_zugferd_xml_is_not_set() {
1185        let mut doc = PdfDocument::new();
1186        doc.pdf_a3b = true;
1187        doc.add_page(PdfPage {
1188            width: 200.0,
1189            height: 200.0,
1190            content: Vec::new(),
1191            annotations: Vec::new(),
1192        });
1193        let bytes = doc.write();
1194        let text = String::from_utf8_lossy(&bytes);
1195        assert!(!text.contains("/Type /Filespec"));
1196        assert!(!text.contains("/AF ["));
1197        assert!(!text.contains("xmlns:fx="));
1198    }
1199
1200    #[cfg(feature = "tagged-pdf")]
1201    #[test]
1202    fn writes_struct_tree_mark_info_and_lang_when_pdf_ua_is_set() {
1203        use crate::struct_tree::PdfStructNode;
1204
1205        let mut doc = PdfDocument::new();
1206        doc.pdf_a3b = true;
1207        doc.pdf_ua = true;
1208        doc.lang = Some("en-US".to_string());
1209        doc.add_page(PdfPage {
1210            width: 200.0,
1211            height: 200.0,
1212            content: Vec::new(),
1213            annotations: Vec::new(),
1214        });
1215        doc.struct_tree = Some(PdfStructNode::Elem {
1216            tag: "Document",
1217            alt: None,
1218            attrs: None,
1219            children: vec![PdfStructNode::Elem {
1220                tag: "H1",
1221                alt: None,
1222                attrs: None,
1223                children: vec![PdfStructNode::ContentRef { page_index: 0, mcid: 0 }],
1224            }],
1225        });
1226        let bytes = doc.write();
1227        let text = String::from_utf8_lossy(&bytes);
1228        assert!(text.contains("/MarkInfo << /Marked true >>"));
1229        assert!(text.contains("/Lang (en-US)"));
1230        assert!(text.contains("/Type /StructTreeRoot"));
1231        assert!(text.contains("/Type /StructElem /S /Document"));
1232        assert!(text.contains("/Type /StructElem /S /H1"));
1233        assert!(text.contains("/Type /MCR /Pg"));
1234        assert!(text.contains("/StructParents 0"));
1235        assert!(text.contains("/Nums ["));
1236        assert!(text.contains("<pdfuaid:part>1</pdfuaid:part>"));
1237    }
1238
1239    #[cfg(feature = "tagged-pdf")]
1240    #[test]
1241    fn omits_struct_tree_entries_when_pdf_ua_is_not_set() {
1242        let mut doc = PdfDocument::new();
1243        doc.add_page(PdfPage {
1244            width: 200.0,
1245            height: 200.0,
1246            content: Vec::new(),
1247            annotations: Vec::new(),
1248        });
1249        let bytes = doc.write();
1250        let text = String::from_utf8_lossy(&bytes);
1251        assert!(!text.contains("/StructTreeRoot"));
1252        assert!(!text.contains("/MarkInfo"));
1253        assert!(!text.contains("/StructParents"));
1254    }
1255}