Skip to main content

pdfboss_write/
pdf.rs

1//! Document assembly: pages of canvas content, document metadata, and the
2//! save path. `Pdf` is a plain struct — the fields are the composition,
3//! and `Default` fills everything optional.
4
5use std::path::Path;
6
7use pdfboss_core::{Dict, Name, ObjRef, Object};
8
9use crate::canvas::{Canvas, CanvasParts};
10use crate::content::serialize_ops;
11use crate::element::{self, Content};
12use crate::error::{Error, Result};
13use crate::font::Standard14;
14use crate::sink::AsyncByteSink;
15use crate::writer::{WriteOptions, Writer};
16
17/// A page size, in default user-space units (1/72 inch), portrait.
18#[derive(Debug, Clone, Copy, PartialEq, Default)]
19pub enum PageSize {
20    /// 297 × 420 mm.
21    A3,
22    /// 210 × 297 mm.
23    #[default]
24    A4,
25    /// 148 × 210 mm.
26    A5,
27    /// 8.5 × 11 in.
28    Letter,
29    /// 8.5 × 14 in.
30    Legal,
31    /// Explicit dimensions in user-space units.
32    Custom {
33        /// Width in units.
34        width: f32,
35        /// Height in units.
36        height: f32,
37    },
38}
39
40impl PageSize {
41    /// Width and height in user-space units.
42    pub fn dimensions(self) -> (f32, f32) {
43        match self {
44            PageSize::A3 => (841.89, 1190.55),
45            PageSize::A4 => (595.28, 841.89),
46            PageSize::A5 => (419.53, 595.28),
47            PageSize::Letter => (612.0, 792.0),
48            PageSize::Legal => (612.0, 1008.0),
49            PageSize::Custom { width, height } => (width, height),
50        }
51    }
52
53    /// The same size with width and height swapped.
54    pub fn landscape(self) -> PageSize {
55        let (width, height) = self.dimensions();
56        PageSize::Custom {
57            width: height,
58            height: width,
59        }
60    }
61
62    /// Parses one of the five named sizes case-insensitively: `a3`, `a4`,
63    /// `a5`, `letter`, `legal`. `None` for anything else — a custom size
64    /// has no name to parse.
65    pub fn by_name(name: &str) -> Option<PageSize> {
66        match name.to_ascii_lowercase().as_str() {
67            "a3" => Some(PageSize::A3),
68            "a4" => Some(PageSize::A4),
69            "a5" => Some(PageSize::A5),
70            "letter" => Some(PageSize::Letter),
71            "legal" => Some(PageSize::Legal),
72            _ => None,
73        }
74    }
75}
76
77/// A calendar date and time with a UTC offset, for `/CreationDate` and
78/// `/ModDate`. The writer never reads a clock — dates appear in output
79/// only when a caller provides them, keeping builds reproducible.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct Date {
82    /// Four-digit year.
83    pub year: u16,
84    /// Month, 1–12.
85    pub month: u8,
86    /// Day of month, 1–31.
87    pub day: u8,
88    /// Hour, 0–23.
89    pub hour: u8,
90    /// Minute, 0–59.
91    pub minute: u8,
92    /// Second, 0–59.
93    pub second: u8,
94    /// Offset from UTC in minutes (positive east).
95    pub utc_offset_minutes: i16,
96}
97
98impl Date {
99    /// Formats as a PDF date string, `D:YYYYMMDDHHmmSSOHH'mm` — with a
100    /// literal `Z` in place of the offset when the date is exactly UTC.
101    pub fn to_pdf_string(self) -> String {
102        let Date {
103            year,
104            month,
105            day,
106            hour,
107            minute,
108            second,
109            utc_offset_minutes,
110        } = self;
111        let mut out = format!("D:{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}");
112        if utc_offset_minutes == 0 {
113            out.push('Z');
114            return out;
115        }
116        let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
117        let magnitude = utc_offset_minutes.unsigned_abs();
118        out.push_str(&format!(
119            "{sign}{:02}'{:02}",
120            magnitude / 60,
121            magnitude % 60
122        ));
123        out
124    }
125
126    /// Formats as an ISO-8601 date-time, `YYYY-MM-DDTHH:mm:SS±HH:MM` — with
127    /// a literal `Z` in place of the offset when the date is exactly UTC.
128    /// Used for the XMP `xmp:CreateDate`/`xmp:ModifyDate` elements.
129    pub(crate) fn to_iso8601(self) -> String {
130        let Date {
131            year,
132            month,
133            day,
134            hour,
135            minute,
136            second,
137            utc_offset_minutes,
138        } = self;
139        let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
140        if utc_offset_minutes == 0 {
141            out.push('Z');
142            return out;
143        }
144        let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
145        let magnitude = utc_offset_minutes.unsigned_abs();
146        out.push_str(&format!(
147            "{sign}{:02}:{:02}",
148            magnitude / 60,
149            magnitude % 60
150        ));
151        out
152    }
153}
154
155/// Document information written to the `/Info` dictionary. Every field is
156/// optional; an all-`None` value writes no dictionary at all.
157#[derive(Debug, Clone, Default, PartialEq)]
158pub struct Metadata {
159    /// `/Title`.
160    pub title: Option<String>,
161    /// `/Author`.
162    pub author: Option<String>,
163    /// `/Subject`.
164    pub subject: Option<String>,
165    /// `/Keywords`.
166    pub keywords: Option<String>,
167    /// `/Creator` (the producing application's name).
168    pub creator: Option<String>,
169    /// `/Producer`.
170    pub producer: Option<String>,
171    /// `/CreationDate`.
172    pub creation_date: Option<Date>,
173    /// `/ModDate`.
174    pub modification_date: Option<Date>,
175}
176
177/// One page: its size, rotation, painted content and link annotations.
178#[derive(Debug, Default)]
179pub struct Page {
180    /// Page size (the `/MediaBox`).
181    pub size: PageSize,
182    /// Clockwise view rotation in degrees; must be a multiple of 90.
183    pub rotation: i32,
184    /// The page's painted content.
185    pub canvas: Canvas,
186    /// Composed elements, painted onto `canvas` at assemble time, after
187    /// any content already painted there directly.
188    pub content: Vec<Content>,
189    /// Clickable link areas, emitted as `/Annots`.
190    pub links: Vec<LinkAnnotation>,
191}
192
193/// A clickable rectangle on a page that opens a URI or jumps to a page in
194/// the same document (a `/Link` annotation with a `/URI` or `/GoTo`
195/// action; ISO 32000 §12.5.6.5, §12.6.4.7, §12.3.2).
196#[derive(Debug, Clone, PartialEq)]
197pub struct LinkAnnotation {
198    /// The clickable area, `[x0, y0, x1, y1]` in the page's user space.
199    pub rect: [f32; 4],
200    /// Where the link goes.
201    pub target: LinkTarget,
202}
203
204/// Where a [`LinkAnnotation`] leads.
205#[derive(Debug, Clone, PartialEq)]
206pub enum LinkTarget {
207    /// An external URI, opened with a `/URI` action.
208    Uri(String),
209    /// A page within the same document, by index, opened with a `/GoTo`
210    /// action that keeps the viewer's current position and zoom.
211    Page(usize),
212}
213
214impl Page {
215    /// An empty page of the given size.
216    pub fn new(size: PageSize) -> Page {
217        Page {
218            size,
219            ..Page::default()
220        }
221    }
222}
223
224/// A document's bookmark panel: an ordered forest of [`Bookmark`] nodes,
225/// each linking to a page via an explicit `/XYZ` destination.
226#[derive(Debug, Clone, Default, PartialEq)]
227pub struct Outline {
228    /// Top-level bookmarks, in reading order.
229    pub bookmarks: Vec<Bookmark>,
230}
231
232/// One outline entry: a title, the page it jumps to, and nested children.
233#[derive(Debug, Clone, Default, PartialEq)]
234pub struct Bookmark {
235    /// Text shown in the outline panel.
236    pub title: String,
237    /// Target page index, opened keeping the viewer's current position
238    /// and zoom.
239    pub page: usize,
240    /// Nested bookmarks, in reading order.
241    pub children: Vec<Bookmark>,
242}
243
244impl Bookmark {
245    /// A leaf bookmark: `title` targeting `page`, with no children.
246    pub fn new(title: impl Into<String>, page: usize) -> Bookmark {
247        Bookmark {
248            title: title.into(),
249            page,
250            children: Vec::new(),
251        }
252    }
253}
254
255/// A document-level attachment, embedded via the catalog's `/Names
256/// /EmbeddedFiles` name tree (ISO 32000 §7.11.4). Unlike a page's painted
257/// content, an attachment carries no rendering — only the bytes and the
258/// metadata a viewer shows about them.
259#[derive(Debug, Clone, PartialEq)]
260pub struct Attachment {
261    /// The file name: written as both the filespec's `/F` and `/UF`, and
262    /// as the name-tree key.
263    pub name: String,
264    /// The attachment's raw bytes, stored as the embedded-file stream
265    /// (compressed like any other stream, per [`WriteOptions::compress`]).
266    pub data: Vec<u8>,
267    /// MIME type, written as the embedded-file stream's `/Subtype`.
268    /// `None` writes `application/octet-stream`.
269    pub mime: Option<String>,
270    /// `/Params /ModDate`, written only when given.
271    pub modified: Option<Date>,
272    /// `/Desc` on the filespec, written only when given.
273    pub description: Option<String>,
274}
275
276/// A page-numbering style for a [`PageLabel`] range, written as its `/S`
277/// (ISO 32000 §12.4.2, Table 159).
278#[derive(Debug, Clone, Copy, PartialEq)]
279pub enum LabelStyle {
280    /// Arabic numerals: 1, 2, 3…
281    Decimal,
282    /// Uppercase Roman numerals: I, II, III…
283    RomanUpper,
284    /// Lowercase Roman numerals: i, ii, iii…
285    RomanLower,
286    /// Uppercase letters: A, B, …, Z, AA…
287    LettersUpper,
288    /// Lowercase letters: a, b, …, z, aa…
289    LettersLower,
290}
291
292/// One page-numbering range, taking effect from `first_page` (0-based)
293/// until the next range's `first_page` or the document's end (ISO 32000
294/// §12.4.2). A document's `page_labels` must include a range with
295/// `first_page == 0` whenever it is non-empty.
296#[derive(Debug, Clone, PartialEq)]
297pub struct PageLabel {
298    /// 0-based page index where this range begins.
299    pub first_page: usize,
300    /// Numbering style, written as `/S`; `None` omits it, showing only
301    /// `prefix` for every page in the range.
302    pub style: Option<LabelStyle>,
303    /// Text prepended to every number in the range, written as `/P`.
304    pub prefix: Option<String>,
305    /// The number shown on `first_page`, written as `/St` only when not
306    /// `1`. Conventionally `1`.
307    pub start_at: u32,
308}
309
310/// Initial page-layout mode, written as the catalog's `/PageLayout` (ISO
311/// 32000 §7.7.2, Table 27).
312#[derive(Debug, Clone, Copy, PartialEq)]
313pub enum PageLayout {
314    /// One page at a time.
315    SinglePage,
316    /// One continuously scrolling column of pages.
317    OneColumn,
318    /// Two columns, an odd-numbered page on the left.
319    TwoColumnLeft,
320    /// Two columns, an odd-numbered page on the right.
321    TwoColumnRight,
322    /// Two pages at a time, an odd-numbered page on the left.
323    TwoPageLeft,
324    /// Two pages at a time, an odd-numbered page on the right.
325    TwoPageRight,
326}
327
328/// Initial navigation-panel mode, written as the catalog's `/PageMode`
329/// (ISO 32000 §7.7.2, Table 28).
330#[derive(Debug, Clone, Copy, PartialEq)]
331pub enum PageMode {
332    /// No panel open.
333    UseNone,
334    /// The outline (bookmarks) panel.
335    UseOutlines,
336    /// The page-thumbnails panel.
337    UseThumbs,
338    /// Full-screen presentation mode.
339    FullScreen,
340}
341
342/// Viewer preferences written to the catalog: initial layout, navigation
343/// mode, and the page opened at document start (ISO 32000 §7.7.2).
344#[derive(Debug, Clone, Default, PartialEq)]
345pub struct Viewer {
346    /// `/PageLayout`, omitted when `None`.
347    pub layout: Option<PageLayout>,
348    /// `/PageMode`, omitted when `None`.
349    pub mode: Option<PageMode>,
350    /// Page index opened via `/OpenAction`, keeping the viewer's current
351    /// position and zoom, omitted when `None`.
352    pub open_to: Option<usize>,
353}
354
355/// A document under construction. The fields are the composition:
356/// singleton slots are `Option`s, pages keep the order given.
357#[derive(Debug, Default)]
358pub struct Pdf {
359    /// Document information, if any.
360    pub metadata: Option<Metadata>,
361    /// Pages, in reading order.
362    pub pages: Vec<Page>,
363    /// Bookmark panel, if any.
364    pub outline: Option<Outline>,
365    /// Document-level attachments. Reordered by `name`, lexicographically
366    /// by bytes, at emission — the name-tree keys must be sorted, so the
367    /// order given here is not preserved. Duplicate names are an error.
368    pub attachments: Vec<Attachment>,
369    /// Page-numbering ranges shown in viewer UI as `/PageLabels`.
370    /// Reordered by `first_page` at emission. Must include a range
371    /// starting at page 0 when non-empty; duplicate `first_page` values
372    /// are an error.
373    pub page_labels: Vec<PageLabel>,
374    /// Viewer preferences, if any.
375    pub viewer: Option<Viewer>,
376    /// File-emission options.
377    pub options: WriteOptions,
378}
379
380impl Pdf {
381    /// Serializes the document to complete PDF file bytes.
382    ///
383    /// Fonts are shared document-wide: each distinct [`Standard14`] face
384    /// gets one font object, in first-use order. Images are embedded per
385    /// page with no cross-page deduplication — the same raster drawn on
386    /// two pages is stored twice. Groups follow the same rule: a canvas
387    /// registered with `Canvas::group` on two different pages produces two
388    /// Form XObjects — cross-page group sharing is deferred.
389    pub fn to_bytes(self) -> Result<Vec<u8>> {
390        let (w, root) = self.assemble()?;
391        w.finish(root)
392    }
393
394    /// [`Pdf::to_bytes`] streaming into a [`std::io::Write`]: the same
395    /// bytes, delivered in bounded chunks instead of one buffer. Unlike
396    /// `to_bytes`, an error can leave a prefix of the file already written
397    /// to `out`. No flush is performed.
398    pub fn write_into(self, out: impl std::io::Write) -> Result<()> {
399        let (w, root) = self.assemble()?;
400        w.finish_into(root, out)
401    }
402
403    /// [`Pdf::to_bytes`] streaming into any [`AsyncByteSink`] — the
404    /// asynchronous twin of [`Pdf::write_into`]. An error can leave a
405    /// prefix of the file already written. Hands the sink back unflushed.
406    pub async fn write_into_with<S: AsyncByteSink>(self, sink: S) -> Result<S> {
407        let (w, root) = self.assemble()?;
408        w.finish_into_with(root, sink).await
409    }
410
411    /// Builds the writer every write path finishes: all objects placed,
412    /// the catalog's reference returned alongside.
413    fn assemble(self) -> Result<(Writer, ObjRef)> {
414        let Pdf {
415            metadata,
416            pages,
417            outline,
418            attachments,
419            page_labels,
420            viewer,
421            options,
422        } = self;
423        if pages.is_empty() {
424            return Err(Error::Other(
425                "a document needs at least one page".to_string(),
426            ));
427        }
428        let mut w = Writer::new(options);
429        let pages_root = w.reserve();
430        let page_count = pages.len();
431        let page_refs: Vec<ObjRef> = pages.iter().map(|_| w.reserve()).collect();
432        let mut font_cache: Vec<(Standard14, ObjRef)> = Vec::new();
433        for (index, page) in pages.into_iter().enumerate() {
434            let Page {
435                size,
436                rotation,
437                mut canvas,
438                content,
439                mut links,
440            } = page;
441            if rotation % 90 != 0 {
442                return Err(Error::Other(format!(
443                    "page rotation {rotation} is not a multiple of 90"
444                )));
445            }
446            element::lower(content, &mut canvas, &mut links)?;
447            let (width, height) = size.dimensions();
448            let parts = canvas.into_parts();
449            let content_ref = w.put_stream(Dict::new(), serialize_ops(&parts.ops));
450            let mut fonts = Dict::new();
451            for (index, face) in parts.fonts.iter().enumerate() {
452                let font_ref = cached_font(&mut w, &mut font_cache, face);
453                fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
454            }
455            let mut xobjects = Dict::new();
456            for (index, image) in parts.images.iter().enumerate() {
457                let image_ref = image.build_xobject(&mut w);
458                xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
459            }
460            for (index, (group_parts, bbox)) in parts.groups.into_iter().enumerate() {
461                let group_ref = build_form(&mut w, group_parts, bbox, &mut font_cache)?;
462                xobjects.insert(Name(format!("Gp{}", index + 1)), Object::Ref(group_ref));
463            }
464            let mut ext_gstates = Dict::new();
465            for (index, state) in parts.gstates.iter().enumerate() {
466                let gstate_ref = w.put(Object::Dict(state.ext_gstate_dict()));
467                ext_gstates.insert(Name(format!("Gs{}", index + 1)), Object::Ref(gstate_ref));
468            }
469            let mut resources = Dict::new();
470            if !fonts.is_empty() {
471                resources.insert(name("Font"), Object::Dict(fonts));
472            }
473            if !xobjects.is_empty() {
474                resources.insert(name("XObject"), Object::Dict(xobjects));
475            }
476            if !ext_gstates.is_empty() {
477                resources.insert(name("ExtGState"), Object::Dict(ext_gstates));
478            }
479            let mut dict = Dict::new();
480            dict.insert(name("Type"), Object::Name(name("Page")));
481            dict.insert(name("Parent"), Object::Ref(pages_root));
482            dict.insert(
483                name("MediaBox"),
484                Object::Array(vec![
485                    Object::Int(0),
486                    Object::Int(0),
487                    Object::Real(f64::from(width)),
488                    Object::Real(f64::from(height)),
489                ]),
490            );
491            dict.insert(name("Contents"), Object::Ref(content_ref));
492            dict.insert(name("Resources"), Object::Dict(resources));
493            if !links.is_empty() {
494                let mut annots = Vec::with_capacity(links.len());
495                for link in links {
496                    let action = match link.target {
497                        LinkTarget::Uri(uri) => {
498                            let mut action = Dict::new();
499                            action.insert(name("S"), Object::Name(name("URI")));
500                            action.insert(name("URI"), text_string(&uri));
501                            action
502                        }
503                        LinkTarget::Page(target_index) => {
504                            let target = page_refs.get(target_index).copied().ok_or_else(|| {
505                                Error::Other(format!(
506                                    "link target page {target_index} is out of range: the document has {page_count} pages"
507                                ))
508                            })?;
509                            let mut action = Dict::new();
510                            action.insert(name("S"), Object::Name(name("GoTo")));
511                            action.insert(
512                                name("D"),
513                                Object::Array(vec![
514                                    Object::Ref(target),
515                                    Object::Name(name("XYZ")),
516                                    Object::Null,
517                                    Object::Null,
518                                    Object::Null,
519                                ]),
520                            );
521                            action
522                        }
523                    };
524                    let mut annot = Dict::new();
525                    annot.insert(name("Type"), Object::Name(name("Annot")));
526                    annot.insert(name("Subtype"), Object::Name(name("Link")));
527                    annot.insert(
528                        name("Rect"),
529                        Object::Array(
530                            link.rect
531                                .iter()
532                                .map(|v| Object::Real(f64::from(*v)))
533                                .collect(),
534                        ),
535                    );
536                    annot.insert(
537                        name("Border"),
538                        Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)]),
539                    );
540                    annot.insert(name("A"), Object::Dict(action));
541                    annots.push(Object::Ref(w.put(Object::Dict(annot))));
542                }
543                dict.insert(name("Annots"), Object::Array(annots));
544            }
545            if rotation != 0 {
546                dict.insert(name("Rotate"), Object::Int(i64::from(rotation)));
547            }
548            w.fill(page_refs[index], Object::Dict(dict))?;
549        }
550        let kids: Vec<Object> = page_refs.iter().copied().map(Object::Ref).collect();
551        let mut tree = Dict::new();
552        tree.insert(name("Type"), Object::Name(name("Pages")));
553        tree.insert(name("Count"), Object::Int(kids.len() as i64));
554        tree.insert(name("Kids"), Object::Array(kids));
555        w.fill(pages_root, Object::Dict(tree))?;
556        let xmp_ref = match metadata {
557            Some(meta) => {
558                let packet = crate::xmp::packet(&meta);
559                if let Some(info) = info_dict(meta) {
560                    let info_ref = w.put(Object::Dict(info));
561                    w.set_info(info_ref);
562                }
563                let mut xmp_dict = Dict::new();
564                xmp_dict.insert(name("Type"), Object::Name(name("Metadata")));
565                xmp_dict.insert(name("Subtype"), Object::Name(name("XML")));
566                Some(w.put_stream_raw(xmp_dict, packet))
567            }
568            None => None,
569        };
570        let outline_ref = match outline {
571            Some(outline) if !outline.bookmarks.is_empty() => {
572                let root_ref = w.reserve();
573                let refs = reserve_bookmarks(&mut w, &outline.bookmarks);
574                let (first, last, count) = fill_bookmarks(
575                    &mut w,
576                    outline.bookmarks,
577                    &refs,
578                    root_ref,
579                    &page_refs,
580                    page_count,
581                )?;
582                let mut dict = Dict::new();
583                dict.insert(name("Type"), Object::Name(name("Outlines")));
584                dict.insert(name("First"), Object::Ref(first));
585                dict.insert(name("Last"), Object::Ref(last));
586                dict.insert(name("Count"), Object::Int(count));
587                w.fill(root_ref, Object::Dict(dict))?;
588                Some(root_ref)
589            }
590            _ => None,
591        };
592        let names = embedded_files_dict(&mut w, attachments)?;
593        let page_labels_entry = page_labels_dict(page_labels)?;
594        let mut catalog = Dict::new();
595        catalog.insert(name("Type"), Object::Name(name("Catalog")));
596        catalog.insert(name("Pages"), Object::Ref(pages_root));
597        if let Some(outline_ref) = outline_ref {
598            catalog.insert(name("Outlines"), Object::Ref(outline_ref));
599        }
600        if let Some(xmp_ref) = xmp_ref {
601            catalog.insert(name("Metadata"), Object::Ref(xmp_ref));
602        }
603        if let Some(names) = names {
604            catalog.insert(name("Names"), Object::Dict(names));
605        }
606        if let Some(page_labels_entry) = page_labels_entry {
607            catalog.insert(name("PageLabels"), Object::Dict(page_labels_entry));
608        }
609        if let Some(viewer) = viewer {
610            let Viewer {
611                layout,
612                mode,
613                open_to,
614            } = viewer;
615            if let Some(layout) = layout {
616                catalog.insert(
617                    name("PageLayout"),
618                    Object::Name(name(page_layout_name(layout))),
619                );
620            }
621            if let Some(mode) = mode {
622                catalog.insert(name("PageMode"), Object::Name(name(page_mode_name(mode))));
623            }
624            if let Some(open_to) = open_to {
625                let target = page_refs.get(open_to).copied().ok_or_else(|| {
626                    Error::Other(format!(
627                        "open_to target page {open_to} is out of range: the document has {page_count} pages"
628                    ))
629                })?;
630                catalog.insert(
631                    name("OpenAction"),
632                    Object::Array(vec![
633                        Object::Ref(target),
634                        Object::Name(name("XYZ")),
635                        Object::Null,
636                        Object::Null,
637                        Object::Null,
638                    ]),
639                );
640            }
641        }
642        let root = w.put(Object::Dict(catalog));
643        Ok((w, root))
644    }
645
646    /// Serializes and writes the document to `path`.
647    pub fn save(self, path: impl AsRef<Path>) -> Result<()> {
648        let path = path.as_ref();
649        let bytes = self.to_bytes()?;
650        std::fs::write(path, bytes)?;
651        Ok(())
652    }
653}
654
655/// A `Name` from a string literal.
656fn name(text: &str) -> Name {
657    Name(text.to_string())
658}
659
660/// Builds the `/Info` dictionary, or `None` when every field is `None`.
661fn info_dict(meta: Metadata) -> Option<Dict> {
662    let mut dict = Dict::new();
663    let texts = [
664        ("Title", meta.title),
665        ("Author", meta.author),
666        ("Subject", meta.subject),
667        ("Keywords", meta.keywords),
668        ("Creator", meta.creator),
669        ("Producer", meta.producer),
670    ];
671    for (key, value) in texts {
672        if let Some(value) = value {
673            dict.insert(name(key), text_string(&value));
674        }
675    }
676    let dates = [
677        ("CreationDate", meta.creation_date),
678        ("ModDate", meta.modification_date),
679    ];
680    for (key, value) in dates {
681        if let Some(date) = value {
682            dict.insert(name(key), Object::String(date.to_pdf_string().into_bytes()));
683        }
684    }
685    if dict.is_empty() {
686        return None;
687    }
688    Some(dict)
689}
690
691/// Default MIME type for an [`Attachment`] whose `mime` is `None`.
692const DEFAULT_ATTACHMENT_MIME: &str = "application/octet-stream";
693
694/// Builds the catalog's `/Names` dictionary from `attachments`, or `None`
695/// when there are none. Attachments are reordered by `name`, bytewise, to
696/// satisfy the name tree's sorted-key requirement — string comparison in
697/// Rust is already a byte comparison, so sorting `String` values sorts
698/// their bytes. A repeated name is an error naming the duplicate.
699fn embedded_files_dict(w: &mut Writer, mut attachments: Vec<Attachment>) -> Result<Option<Dict>> {
700    if attachments.is_empty() {
701        return Ok(None);
702    }
703    attachments.sort_by(|a, b| a.name.cmp(&b.name));
704    for pair in attachments.windows(2) {
705        if pair[0].name == pair[1].name {
706            return Err(Error::Other(format!(
707                "duplicate attachment name: {:?}",
708                pair[0].name
709            )));
710        }
711    }
712    let mut entries = Vec::with_capacity(attachments.len() * 2);
713    for attachment in attachments {
714        let Attachment {
715            name: file_name,
716            data,
717            mime,
718            modified,
719            description,
720        } = attachment;
721        let mime = mime.unwrap_or_else(|| DEFAULT_ATTACHMENT_MIME.to_string());
722
723        let mut params = Dict::new();
724        params.insert(name("Size"), Object::Int(data.len() as i64));
725        if let Some(modified) = modified {
726            params.insert(
727                name("ModDate"),
728                Object::String(modified.to_pdf_string().into_bytes()),
729            );
730        }
731        let mut stream_dict = Dict::new();
732        stream_dict.insert(name("Type"), Object::Name(name("EmbeddedFile")));
733        stream_dict.insert(name("Subtype"), Object::Name(Name(mime)));
734        stream_dict.insert(name("Params"), Object::Dict(params));
735        let stream_ref = w.put_stream(stream_dict, data);
736
737        let mut ef = Dict::new();
738        ef.insert(name("F"), Object::Ref(stream_ref));
739
740        let mut filespec = Dict::new();
741        filespec.insert(name("Type"), Object::Name(name("Filespec")));
742        filespec.insert(name("F"), text_string(&file_name));
743        filespec.insert(name("UF"), text_string(&file_name));
744        if let Some(description) = description {
745            filespec.insert(name("Desc"), text_string(&description));
746        }
747        filespec.insert(name("EF"), Object::Dict(ef));
748        let filespec_ref = w.put(Object::Dict(filespec));
749
750        entries.push(text_string(&file_name));
751        entries.push(Object::Ref(filespec_ref));
752    }
753    let mut name_tree = Dict::new();
754    name_tree.insert(name("Names"), Object::Array(entries));
755    let mut embedded_files = Dict::new();
756    embedded_files.insert(name("EmbeddedFiles"), Object::Dict(name_tree));
757    Ok(Some(embedded_files))
758}
759
760/// Builds the catalog's `/PageLabels` dictionary from `labels`, or `None`
761/// when there are none. Ranges are reordered by `first_page` to satisfy
762/// the number tree's sorted-key requirement. A non-empty set must include
763/// a range starting at page 0; a repeated `first_page` is an error naming
764/// the page; a `start_at` of 0 is an error naming the page, since ISO
765/// 32000 page-label numbering starts at 1.
766fn page_labels_dict(mut labels: Vec<PageLabel>) -> Result<Option<Dict>> {
767    if labels.is_empty() {
768        return Ok(None);
769    }
770    for label in &labels {
771        if label.start_at == 0 {
772            return Err(Error::Other(format!(
773                "page label at page {} has start_at 0: numbering starts at 1",
774                label.first_page
775            )));
776        }
777    }
778    labels.sort_by_key(|label| label.first_page);
779    if labels[0].first_page != 0 {
780        return Err(Error::Other("page labels must start at page 0".to_string()));
781    }
782    for pair in labels.windows(2) {
783        if pair[0].first_page == pair[1].first_page {
784            return Err(Error::Other(format!(
785                "duplicate page label at page {}",
786                pair[0].first_page
787            )));
788        }
789    }
790    let mut nums = Vec::with_capacity(labels.len() * 2);
791    for label in labels {
792        let PageLabel {
793            first_page,
794            style,
795            prefix,
796            start_at,
797        } = label;
798        let mut range = Dict::new();
799        if let Some(style) = style {
800            range.insert(name("S"), Object::Name(name(label_style_name(style))));
801        }
802        if let Some(prefix) = prefix {
803            range.insert(name("P"), text_string(&prefix));
804        }
805        if start_at != 1 {
806            range.insert(name("St"), Object::Int(i64::from(start_at)));
807        }
808        nums.push(Object::Int(first_page as i64));
809        nums.push(Object::Dict(range));
810    }
811    let mut dict = Dict::new();
812    dict.insert(name("Nums"), Object::Array(nums));
813    Ok(Some(dict))
814}
815
816/// The `/S` name for a [`LabelStyle`].
817fn label_style_name(style: LabelStyle) -> &'static str {
818    match style {
819        LabelStyle::Decimal => "D",
820        LabelStyle::RomanUpper => "R",
821        LabelStyle::RomanLower => "r",
822        LabelStyle::LettersUpper => "A",
823        LabelStyle::LettersLower => "a",
824    }
825}
826
827/// The `/PageLayout` name for a [`PageLayout`].
828fn page_layout_name(layout: PageLayout) -> &'static str {
829    match layout {
830        PageLayout::SinglePage => "SinglePage",
831        PageLayout::OneColumn => "OneColumn",
832        PageLayout::TwoColumnLeft => "TwoColumnLeft",
833        PageLayout::TwoColumnRight => "TwoColumnRight",
834        PageLayout::TwoPageLeft => "TwoPageLeft",
835        PageLayout::TwoPageRight => "TwoPageRight",
836    }
837}
838
839/// The `/PageMode` name for a [`PageMode`].
840fn page_mode_name(mode: PageMode) -> &'static str {
841    match mode {
842        PageMode::UseNone => "UseNone",
843        PageMode::UseOutlines => "UseOutlines",
844        PageMode::UseThumbs => "UseThumbs",
845        PageMode::FullScreen => "FullScreen",
846    }
847}
848
849/// One bookmark's reserved object number, mirroring the tree shape so the
850/// fill pass can wire parent, sibling and child refs before any of their
851/// dictionary bodies exist.
852struct BookmarkRef {
853    r: ObjRef,
854    children: Vec<BookmarkRef>,
855}
856
857/// Reserves an object number for every node in `bookmarks`, recursively —
858/// the reserve half of the reserve/fill idiom: an outline item's `/Parent`,
859/// `/Prev`, `/Next`, `/First` and `/Last` may all point at nodes that do
860/// not have a body yet.
861fn reserve_bookmarks(w: &mut Writer, bookmarks: &[Bookmark]) -> Vec<BookmarkRef> {
862    bookmarks
863        .iter()
864        .map(|bookmark| BookmarkRef {
865            r: w.reserve(),
866            children: reserve_bookmarks(w, &bookmark.children),
867        })
868        .collect()
869}
870
871/// Fills one sibling chain of outline items against their already-reserved
872/// refs: `/Title`, `/Parent`, the `/Prev`/`/Next` chain, `/Dest`, and — for
873/// any bookmark with children — `/First`/`/Last`/`/Count` from a recursive
874/// fill of that subtree. Returns the chain's first and last refs and the
875/// total number of items in the whole subtree, which doubles as `/Count`
876/// wherever the caller needs it (every bookmark is open).
877fn fill_bookmarks(
878    w: &mut Writer,
879    bookmarks: Vec<Bookmark>,
880    refs: &[BookmarkRef],
881    parent: ObjRef,
882    page_refs: &[ObjRef],
883    page_count: usize,
884) -> Result<(ObjRef, ObjRef, i64)> {
885    let last_index = bookmarks.len() - 1;
886    let mut total = 0i64;
887    for (index, bookmark) in bookmarks.into_iter().enumerate() {
888        let Bookmark {
889            title,
890            page,
891            children,
892        } = bookmark;
893        let dest = page_refs.get(page).copied().ok_or_else(|| {
894            Error::Other(format!(
895                "bookmark target page {page} is out of range: the document has {page_count} pages"
896            ))
897        })?;
898        let mut dict = Dict::new();
899        dict.insert(name("Title"), text_string(&title));
900        dict.insert(name("Parent"), Object::Ref(parent));
901        if index > 0 {
902            dict.insert(name("Prev"), Object::Ref(refs[index - 1].r));
903        }
904        if index < last_index {
905            dict.insert(name("Next"), Object::Ref(refs[index + 1].r));
906        }
907        dict.insert(
908            name("Dest"),
909            Object::Array(vec![
910                Object::Ref(dest),
911                Object::Name(name("XYZ")),
912                Object::Null,
913                Object::Null,
914                Object::Null,
915            ]),
916        );
917        let mut subtree_count = 0i64;
918        if !children.is_empty() {
919            let (first, last, count) = fill_bookmarks(
920                w,
921                children,
922                &refs[index].children,
923                refs[index].r,
924                page_refs,
925                page_count,
926            )?;
927            dict.insert(name("First"), Object::Ref(first));
928            dict.insert(name("Last"), Object::Ref(last));
929            dict.insert(name("Count"), Object::Int(count));
930            subtree_count = count;
931        }
932        w.fill(refs[index].r, Object::Dict(dict))?;
933        total += 1 + subtree_count;
934    }
935    Ok((refs[0].r, refs[last_index].r, total))
936}
937
938/// The document-wide font object for `face`: an existing entry from
939/// `font_cache` when one matches, otherwise a freshly built one that is
940/// cached for the next lookup. Shared by the page loop and every nested
941/// [`build_form`] so a face used both on a page and inside a group still
942/// gets exactly one font object.
943fn cached_font(
944    w: &mut Writer,
945    font_cache: &mut Vec<(Standard14, ObjRef)>,
946    face: &Standard14,
947) -> ObjRef {
948    if let Some((_, r)) = font_cache.iter().find(|(seen, _)| seen == face) {
949        return *r;
950    }
951    let r = w.put(Object::Dict(face.font_dict()));
952    font_cache.push((*face, r));
953    r
954}
955
956/// Builds one Form XObject from a registered group's parts: `/Type
957/// /XObject /Subtype /Form /BBox /Resources`, with the sub-canvas's
958/// operators as its content stream. Recurses for groups nested inside
959/// groups, and shares `font_cache` with the page loop so nested forms
960/// never duplicate a font already emitted elsewhere in the document.
961fn build_form(
962    w: &mut Writer,
963    parts: CanvasParts,
964    bbox: [f32; 4],
965    font_cache: &mut Vec<(Standard14, ObjRef)>,
966) -> Result<ObjRef> {
967    let content = serialize_ops(&parts.ops);
968    let mut fonts = Dict::new();
969    for (index, face) in parts.fonts.iter().enumerate() {
970        let font_ref = cached_font(w, font_cache, face);
971        fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
972    }
973    let mut xobjects = Dict::new();
974    for (index, image) in parts.images.iter().enumerate() {
975        let image_ref = image.build_xobject(w);
976        xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
977    }
978    for (index, (group_parts, group_bbox)) in parts.groups.into_iter().enumerate() {
979        let group_ref = build_form(w, group_parts, group_bbox, font_cache)?;
980        xobjects.insert(Name(format!("Gp{}", index + 1)), Object::Ref(group_ref));
981    }
982    let mut ext_gstates = Dict::new();
983    for (index, state) in parts.gstates.iter().enumerate() {
984        let gstate_ref = w.put(Object::Dict(state.ext_gstate_dict()));
985        ext_gstates.insert(Name(format!("Gs{}", index + 1)), Object::Ref(gstate_ref));
986    }
987    let mut resources = Dict::new();
988    if !fonts.is_empty() {
989        resources.insert(name("Font"), Object::Dict(fonts));
990    }
991    if !xobjects.is_empty() {
992        resources.insert(name("XObject"), Object::Dict(xobjects));
993    }
994    if !ext_gstates.is_empty() {
995        resources.insert(name("ExtGState"), Object::Dict(ext_gstates));
996    }
997    let mut dict = Dict::new();
998    dict.insert(name("Type"), Object::Name(name("XObject")));
999    dict.insert(name("Subtype"), Object::Name(name("Form")));
1000    dict.insert(
1001        name("BBox"),
1002        Object::Array(bbox.iter().map(|v| Object::Real(f64::from(*v))).collect()),
1003    );
1004    dict.insert(name("Resources"), Object::Dict(resources));
1005    Ok(w.put_stream(dict, content))
1006}
1007
1008/// Encodes a text string (ISO 32000 §7.9.2.2): pure ASCII passes through
1009/// as its own bytes, anything else becomes UTF-16BE with a `FE FF` byte
1010/// order mark.
1011fn text_string(value: &str) -> Object {
1012    if value.is_ascii() {
1013        return Object::String(value.as_bytes().to_vec());
1014    }
1015    let mut bytes = vec![0xFE, 0xFF];
1016    for unit in value.encode_utf16() {
1017        bytes.extend_from_slice(&unit.to_be_bytes());
1018    }
1019    Object::String(bytes)
1020}
1021
1022#[cfg(test)]
1023mod tests {
1024    use super::*;
1025
1026    #[test]
1027    fn dimensions_match_the_contract() {
1028        assert_eq!(PageSize::A3.dimensions(), (841.89, 1190.55));
1029        assert_eq!(PageSize::A4.dimensions(), (595.28, 841.89));
1030        assert_eq!(PageSize::A5.dimensions(), (419.53, 595.28));
1031        assert_eq!(PageSize::Letter.dimensions(), (612.0, 792.0));
1032        assert_eq!(PageSize::Legal.dimensions(), (612.0, 1008.0));
1033        assert_eq!(
1034            PageSize::Custom {
1035                width: 10.0,
1036                height: 20.0
1037            }
1038            .dimensions(),
1039            (10.0, 20.0)
1040        );
1041    }
1042
1043    #[test]
1044    fn by_name_parses_the_five_named_sizes_case_insensitively() {
1045        for (name, expected) in [
1046            ("a3", PageSize::A3),
1047            ("A4", PageSize::A4),
1048            ("a5", PageSize::A5),
1049            ("Letter", PageSize::Letter),
1050            ("LEGAL", PageSize::Legal),
1051        ] {
1052            assert_eq!(PageSize::by_name(name), Some(expected), "{name}");
1053        }
1054    }
1055
1056    #[test]
1057    fn by_name_rejects_anything_else() {
1058        assert_eq!(PageSize::by_name("tabloid"), None);
1059        assert_eq!(PageSize::by_name(""), None);
1060    }
1061
1062    #[test]
1063    fn landscape_swaps_into_custom() {
1064        assert_eq!(
1065            PageSize::A4.landscape(),
1066            PageSize::Custom {
1067                width: 841.89,
1068                height: 595.28
1069            }
1070        );
1071        assert_eq!(
1072            PageSize::Custom {
1073                width: 1.0,
1074                height: 2.0
1075            }
1076            .landscape(),
1077            PageSize::Custom {
1078                width: 2.0,
1079                height: 1.0
1080            }
1081        );
1082        assert_eq!(PageSize::Letter.landscape().dimensions(), (792.0, 612.0));
1083    }
1084
1085    #[test]
1086    fn date_utc_formats_with_z() {
1087        let date = Date {
1088            year: 2026,
1089            month: 8,
1090            day: 27,
1091            hour: 12,
1092            minute: 30,
1093            second: 15,
1094            utc_offset_minutes: 0,
1095        };
1096        assert_eq!(date.to_pdf_string(), "D:20260827123015Z");
1097    }
1098
1099    #[test]
1100    fn date_positive_offset_pads_single_digits() {
1101        let date = Date {
1102            year: 987,
1103            month: 1,
1104            day: 2,
1105            hour: 3,
1106            minute: 4,
1107            second: 5,
1108            utc_offset_minutes: 120,
1109        };
1110        assert_eq!(date.to_pdf_string(), "D:09870102030405+02'00");
1111    }
1112
1113    #[test]
1114    fn date_negative_offset_keeps_minutes() {
1115        let date = Date {
1116            year: 1999,
1117            month: 12,
1118            day: 31,
1119            hour: 23,
1120            minute: 59,
1121            second: 58,
1122            utc_offset_minutes: -330,
1123        };
1124        assert_eq!(date.to_pdf_string(), "D:19991231235958-05'30");
1125    }
1126
1127    #[test]
1128    fn iso8601_utc_formats_with_z() {
1129        let date = Date {
1130            year: 2026,
1131            month: 8,
1132            day: 27,
1133            hour: 12,
1134            minute: 30,
1135            second: 15,
1136            utc_offset_minutes: 0,
1137        };
1138        assert_eq!(date.to_iso8601(), "2026-08-27T12:30:15Z");
1139    }
1140
1141    #[test]
1142    fn iso8601_positive_offset_pads_single_digits() {
1143        let date = Date {
1144            year: 987,
1145            month: 1,
1146            day: 2,
1147            hour: 3,
1148            minute: 4,
1149            second: 5,
1150            utc_offset_minutes: 120,
1151        };
1152        assert_eq!(date.to_iso8601(), "0987-01-02T03:04:05+02:00");
1153    }
1154
1155    #[test]
1156    fn iso8601_negative_offset_keeps_minutes() {
1157        let date = Date {
1158            year: 1999,
1159            month: 12,
1160            day: 31,
1161            hour: 23,
1162            minute: 59,
1163            second: 58,
1164            utc_offset_minutes: -330,
1165        };
1166        assert_eq!(date.to_iso8601(), "1999-12-31T23:59:58-05:30");
1167    }
1168
1169    /// Two pages with text and an image — enough to exercise fonts,
1170    /// XObjects and the reserved page tree through every write path.
1171    fn two_page_doc() -> Pdf {
1172        let mut first = Page::new(PageSize::A4);
1173        first
1174            .canvas
1175            .text("Streamed parity", 72.0, 720.0, Standard14::Helvetica, 14.0)
1176            .expect("ASCII encodes");
1177        let image = crate::image::ImageData::gray8(2, 2, vec![0, 85, 170, 255])
1178            .expect("2x2 grayscale builds");
1179        let handle = first.canvas.add_image(image);
1180        first.canvas.draw_image(handle, 72.0, 400.0, 144.0, 144.0);
1181        let mut second = Page::new(PageSize::Letter);
1182        second
1183            .canvas
1184            .text("Page two", 72.0, 700.0, Standard14::TimesRoman, 12.0)
1185            .expect("ASCII encodes");
1186        Pdf {
1187            pages: vec![first, second],
1188            ..Pdf::default()
1189        }
1190    }
1191
1192    /// The three write paths are one assembly and one emission: identical
1193    /// bytes whether buffered, streamed into an `io::Write`, or streamed
1194    /// into an async sink.
1195    #[test]
1196    fn write_into_and_write_into_with_match_to_bytes() {
1197        let bytes = two_page_doc().to_bytes().expect("to_bytes succeeds");
1198        let mut via_io = Vec::new();
1199        two_page_doc()
1200            .write_into(&mut via_io)
1201            .expect("write_into succeeds");
1202        assert_eq!(via_io, bytes);
1203        let via_sink = pdfboss_core::block_on(two_page_doc().write_into_with(Vec::new()))
1204            .expect("write_into_with succeeds");
1205        assert_eq!(via_sink, bytes);
1206    }
1207
1208    #[test]
1209    fn zero_page_document_is_an_error() {
1210        let err = Pdf::default()
1211            .to_bytes()
1212            .expect_err("a page-less document must not serialize");
1213        assert!(err.to_string().contains("at least one page"), "{err}");
1214    }
1215}