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