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;
10use crate::content::serialize_ops;
11use crate::error::{Error, Result};
12use crate::font::Standard14;
13use crate::sink::AsyncByteSink;
14use crate::writer::{WriteOptions, Writer};
15
16/// A page size, in default user-space units (1/72 inch), portrait.
17#[derive(Debug, Clone, Copy, PartialEq, Default)]
18pub enum PageSize {
19    /// 297 × 420 mm.
20    A3,
21    /// 210 × 297 mm.
22    #[default]
23    A4,
24    /// 148 × 210 mm.
25    A5,
26    /// 8.5 × 11 in.
27    Letter,
28    /// 8.5 × 14 in.
29    Legal,
30    /// Explicit dimensions in user-space units.
31    Custom {
32        /// Width in units.
33        width: f32,
34        /// Height in units.
35        height: f32,
36    },
37}
38
39impl PageSize {
40    /// Width and height in user-space units.
41    pub fn dimensions(self) -> (f32, f32) {
42        match self {
43            PageSize::A3 => (841.89, 1190.55),
44            PageSize::A4 => (595.28, 841.89),
45            PageSize::A5 => (419.53, 595.28),
46            PageSize::Letter => (612.0, 792.0),
47            PageSize::Legal => (612.0, 1008.0),
48            PageSize::Custom { width, height } => (width, height),
49        }
50    }
51
52    /// The same size with width and height swapped.
53    pub fn landscape(self) -> PageSize {
54        let (width, height) = self.dimensions();
55        PageSize::Custom {
56            width: height,
57            height: width,
58        }
59    }
60}
61
62/// A calendar date and time with a UTC offset, for `/CreationDate` and
63/// `/ModDate`. The writer never reads a clock — dates appear in output
64/// only when a caller provides them, keeping builds reproducible.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Date {
67    /// Four-digit year.
68    pub year: u16,
69    /// Month, 1–12.
70    pub month: u8,
71    /// Day of month, 1–31.
72    pub day: u8,
73    /// Hour, 0–23.
74    pub hour: u8,
75    /// Minute, 0–59.
76    pub minute: u8,
77    /// Second, 0–59.
78    pub second: u8,
79    /// Offset from UTC in minutes (positive east).
80    pub utc_offset_minutes: i16,
81}
82
83impl Date {
84    /// Formats as a PDF date string, `D:YYYYMMDDHHmmSSOHH'mm` — with a
85    /// literal `Z` in place of the offset when the date is exactly UTC.
86    pub fn to_pdf_string(self) -> String {
87        let Date {
88            year,
89            month,
90            day,
91            hour,
92            minute,
93            second,
94            utc_offset_minutes,
95        } = self;
96        let mut out = format!("D:{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}");
97        if utc_offset_minutes == 0 {
98            out.push('Z');
99            return out;
100        }
101        let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
102        let magnitude = utc_offset_minutes.unsigned_abs();
103        out.push_str(&format!(
104            "{sign}{:02}'{:02}",
105            magnitude / 60,
106            magnitude % 60
107        ));
108        out
109    }
110}
111
112/// Document information written to the `/Info` dictionary. Every field is
113/// optional; an all-`None` value writes no dictionary at all.
114#[derive(Debug, Clone, Default, PartialEq)]
115pub struct Metadata {
116    /// `/Title`.
117    pub title: Option<String>,
118    /// `/Author`.
119    pub author: Option<String>,
120    /// `/Subject`.
121    pub subject: Option<String>,
122    /// `/Keywords`.
123    pub keywords: Option<String>,
124    /// `/Creator` (the producing application's name).
125    pub creator: Option<String>,
126    /// `/Producer`.
127    pub producer: Option<String>,
128    /// `/CreationDate`.
129    pub creation_date: Option<Date>,
130    /// `/ModDate`.
131    pub modification_date: Option<Date>,
132}
133
134/// One page: its size, rotation, painted content and link annotations.
135#[derive(Debug, Default)]
136pub struct Page {
137    /// Page size (the `/MediaBox`).
138    pub size: PageSize,
139    /// Clockwise view rotation in degrees; must be a multiple of 90.
140    pub rotation: i32,
141    /// The page's painted content.
142    pub canvas: Canvas,
143    /// Clickable link areas, emitted as `/Annots`.
144    pub links: Vec<LinkAnnotation>,
145}
146
147/// A clickable rectangle on a page that opens a URI (a `/Link` annotation
148/// with a `/URI` action; ISO 32000 §12.5.6.5, §12.6.4.7).
149#[derive(Debug, Clone, PartialEq)]
150pub struct LinkAnnotation {
151    /// The clickable area, `[x0, y0, x1, y1]` in the page's user space.
152    pub rect: [f32; 4],
153    /// The URI opened on click.
154    pub uri: String,
155}
156
157impl Page {
158    /// An empty page of the given size.
159    pub fn new(size: PageSize) -> Page {
160        Page {
161            size,
162            ..Page::default()
163        }
164    }
165}
166
167/// A document under construction. The fields are the composition:
168/// singleton slots are `Option`s, pages keep the order given.
169#[derive(Debug, Default)]
170pub struct Pdf {
171    /// Document information, if any.
172    pub metadata: Option<Metadata>,
173    /// Pages, in reading order.
174    pub pages: Vec<Page>,
175    /// File-emission options.
176    pub options: WriteOptions,
177}
178
179impl Pdf {
180    /// Serializes the document to complete PDF file bytes.
181    ///
182    /// Fonts are shared document-wide: each distinct [`Standard14`] face
183    /// gets one font object, in first-use order. Images are embedded per
184    /// page with no cross-page deduplication — the same raster drawn on
185    /// two pages is stored twice.
186    pub fn to_bytes(self) -> Result<Vec<u8>> {
187        let (w, root) = self.assemble()?;
188        w.finish(root)
189    }
190
191    /// [`Pdf::to_bytes`] streaming into a [`std::io::Write`]: the same
192    /// bytes, delivered in bounded chunks instead of one buffer. Unlike
193    /// `to_bytes`, an error can leave a prefix of the file already written
194    /// to `out`. No flush is performed.
195    pub fn write_into(self, out: impl std::io::Write) -> Result<()> {
196        let (w, root) = self.assemble()?;
197        w.finish_into(root, out)
198    }
199
200    /// [`Pdf::to_bytes`] streaming into any [`AsyncByteSink`] — the
201    /// asynchronous twin of [`Pdf::write_into`]. An error can leave a
202    /// prefix of the file already written. Hands the sink back unflushed.
203    pub async fn write_into_with<S: AsyncByteSink>(self, sink: S) -> Result<S> {
204        let (w, root) = self.assemble()?;
205        w.finish_into_with(root, sink).await
206    }
207
208    /// Builds the writer every write path finishes: all objects placed,
209    /// the catalog's reference returned alongside.
210    fn assemble(self) -> Result<(Writer, ObjRef)> {
211        let Pdf {
212            metadata,
213            pages,
214            options,
215        } = self;
216        if pages.is_empty() {
217            return Err(Error::Other(
218                "a document needs at least one page".to_string(),
219            ));
220        }
221        let mut w = Writer::new(options);
222        let pages_root = w.reserve();
223        let mut font_cache: Vec<(Standard14, ObjRef)> = Vec::new();
224        let mut kids = Vec::with_capacity(pages.len());
225        for page in pages {
226            let Page {
227                size,
228                rotation,
229                canvas,
230                links,
231            } = page;
232            if rotation % 90 != 0 {
233                return Err(Error::Other(format!(
234                    "page rotation {rotation} is not a multiple of 90"
235                )));
236            }
237            let (width, height) = size.dimensions();
238            let parts = canvas.into_parts();
239            let content = w.put_stream(Dict::new(), serialize_ops(&parts.ops));
240            let mut fonts = Dict::new();
241            for (index, face) in parts.fonts.iter().enumerate() {
242                let cached = font_cache
243                    .iter()
244                    .find(|(seen, _)| seen == face)
245                    .map(|(_, r)| *r);
246                let font_ref = match cached {
247                    Some(r) => r,
248                    None => {
249                        let r = w.put(Object::Dict(face.font_dict()));
250                        font_cache.push((*face, r));
251                        r
252                    }
253                };
254                fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
255            }
256            let mut xobjects = Dict::new();
257            for (index, image) in parts.images.iter().enumerate() {
258                let image_ref = image.build_xobject(&mut w);
259                xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
260            }
261            let mut resources = Dict::new();
262            if !fonts.is_empty() {
263                resources.insert(name("Font"), Object::Dict(fonts));
264            }
265            if !xobjects.is_empty() {
266                resources.insert(name("XObject"), Object::Dict(xobjects));
267            }
268            let mut dict = Dict::new();
269            dict.insert(name("Type"), Object::Name(name("Page")));
270            dict.insert(name("Parent"), Object::Ref(pages_root));
271            dict.insert(
272                name("MediaBox"),
273                Object::Array(vec![
274                    Object::Int(0),
275                    Object::Int(0),
276                    Object::Real(f64::from(width)),
277                    Object::Real(f64::from(height)),
278                ]),
279            );
280            dict.insert(name("Contents"), Object::Ref(content));
281            dict.insert(name("Resources"), Object::Dict(resources));
282            if !links.is_empty() {
283                let annots = links
284                    .iter()
285                    .map(|link| {
286                        let mut action = Dict::new();
287                        action.insert(name("S"), Object::Name(name("URI")));
288                        action.insert(name("URI"), text_string(&link.uri));
289                        let mut annot = Dict::new();
290                        annot.insert(name("Type"), Object::Name(name("Annot")));
291                        annot.insert(name("Subtype"), Object::Name(name("Link")));
292                        annot.insert(
293                            name("Rect"),
294                            Object::Array(
295                                link.rect
296                                    .iter()
297                                    .map(|v| Object::Real(f64::from(*v)))
298                                    .collect(),
299                            ),
300                        );
301                        annot.insert(
302                            name("Border"),
303                            Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)]),
304                        );
305                        annot.insert(name("A"), Object::Dict(action));
306                        Object::Ref(w.put(Object::Dict(annot)))
307                    })
308                    .collect();
309                dict.insert(name("Annots"), Object::Array(annots));
310            }
311            if rotation != 0 {
312                dict.insert(name("Rotate"), Object::Int(i64::from(rotation)));
313            }
314            kids.push(Object::Ref(w.put(Object::Dict(dict))));
315        }
316        let mut tree = Dict::new();
317        tree.insert(name("Type"), Object::Name(name("Pages")));
318        tree.insert(name("Count"), Object::Int(kids.len() as i64));
319        tree.insert(name("Kids"), Object::Array(kids));
320        w.fill(pages_root, Object::Dict(tree))?;
321        if let Some(info) = metadata.and_then(info_dict) {
322            let info_ref = w.put(Object::Dict(info));
323            w.set_info(info_ref);
324        }
325        let mut catalog = Dict::new();
326        catalog.insert(name("Type"), Object::Name(name("Catalog")));
327        catalog.insert(name("Pages"), Object::Ref(pages_root));
328        let root = w.put(Object::Dict(catalog));
329        Ok((w, root))
330    }
331
332    /// Serializes and writes the document to `path`.
333    pub fn save(self, path: impl AsRef<Path>) -> Result<()> {
334        let path = path.as_ref();
335        let bytes = self.to_bytes()?;
336        std::fs::write(path, bytes)?;
337        Ok(())
338    }
339}
340
341/// A `Name` from a string literal.
342fn name(text: &str) -> Name {
343    Name(text.to_string())
344}
345
346/// Builds the `/Info` dictionary, or `None` when every field is `None`.
347fn info_dict(meta: Metadata) -> Option<Dict> {
348    let mut dict = Dict::new();
349    let texts = [
350        ("Title", meta.title),
351        ("Author", meta.author),
352        ("Subject", meta.subject),
353        ("Keywords", meta.keywords),
354        ("Creator", meta.creator),
355        ("Producer", meta.producer),
356    ];
357    for (key, value) in texts {
358        if let Some(value) = value {
359            dict.insert(name(key), text_string(&value));
360        }
361    }
362    let dates = [
363        ("CreationDate", meta.creation_date),
364        ("ModDate", meta.modification_date),
365    ];
366    for (key, value) in dates {
367        if let Some(date) = value {
368            dict.insert(name(key), Object::String(date.to_pdf_string().into_bytes()));
369        }
370    }
371    if dict.is_empty() {
372        return None;
373    }
374    Some(dict)
375}
376
377/// Encodes a text string (ISO 32000 §7.9.2.2): pure ASCII passes through
378/// as its own bytes, anything else becomes UTF-16BE with a `FE FF` byte
379/// order mark.
380fn text_string(value: &str) -> Object {
381    if value.is_ascii() {
382        return Object::String(value.as_bytes().to_vec());
383    }
384    let mut bytes = vec![0xFE, 0xFF];
385    for unit in value.encode_utf16() {
386        bytes.extend_from_slice(&unit.to_be_bytes());
387    }
388    Object::String(bytes)
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    #[test]
396    fn dimensions_match_the_contract() {
397        assert_eq!(PageSize::A3.dimensions(), (841.89, 1190.55));
398        assert_eq!(PageSize::A4.dimensions(), (595.28, 841.89));
399        assert_eq!(PageSize::A5.dimensions(), (419.53, 595.28));
400        assert_eq!(PageSize::Letter.dimensions(), (612.0, 792.0));
401        assert_eq!(PageSize::Legal.dimensions(), (612.0, 1008.0));
402        assert_eq!(
403            PageSize::Custom {
404                width: 10.0,
405                height: 20.0
406            }
407            .dimensions(),
408            (10.0, 20.0)
409        );
410    }
411
412    #[test]
413    fn landscape_swaps_into_custom() {
414        assert_eq!(
415            PageSize::A4.landscape(),
416            PageSize::Custom {
417                width: 841.89,
418                height: 595.28
419            }
420        );
421        assert_eq!(
422            PageSize::Custom {
423                width: 1.0,
424                height: 2.0
425            }
426            .landscape(),
427            PageSize::Custom {
428                width: 2.0,
429                height: 1.0
430            }
431        );
432        assert_eq!(PageSize::Letter.landscape().dimensions(), (792.0, 612.0));
433    }
434
435    #[test]
436    fn date_utc_formats_with_z() {
437        let date = Date {
438            year: 2026,
439            month: 8,
440            day: 27,
441            hour: 12,
442            minute: 30,
443            second: 15,
444            utc_offset_minutes: 0,
445        };
446        assert_eq!(date.to_pdf_string(), "D:20260827123015Z");
447    }
448
449    #[test]
450    fn date_positive_offset_pads_single_digits() {
451        let date = Date {
452            year: 987,
453            month: 1,
454            day: 2,
455            hour: 3,
456            minute: 4,
457            second: 5,
458            utc_offset_minutes: 120,
459        };
460        assert_eq!(date.to_pdf_string(), "D:09870102030405+02'00");
461    }
462
463    #[test]
464    fn date_negative_offset_keeps_minutes() {
465        let date = Date {
466            year: 1999,
467            month: 12,
468            day: 31,
469            hour: 23,
470            minute: 59,
471            second: 58,
472            utc_offset_minutes: -330,
473        };
474        assert_eq!(date.to_pdf_string(), "D:19991231235958-05'30");
475    }
476
477    /// Two pages with text and an image — enough to exercise fonts,
478    /// XObjects and the reserved page tree through every write path.
479    fn two_page_doc() -> Pdf {
480        let mut first = Page::new(PageSize::A4);
481        first
482            .canvas
483            .text("Streamed parity", 72.0, 720.0, Standard14::Helvetica, 14.0)
484            .expect("ASCII encodes");
485        let image = crate::image::ImageData::gray8(2, 2, vec![0, 85, 170, 255])
486            .expect("2x2 grayscale builds");
487        let handle = first.canvas.add_image(image);
488        first.canvas.draw_image(handle, 72.0, 400.0, 144.0, 144.0);
489        let mut second = Page::new(PageSize::Letter);
490        second
491            .canvas
492            .text("Page two", 72.0, 700.0, Standard14::TimesRoman, 12.0)
493            .expect("ASCII encodes");
494        Pdf {
495            pages: vec![first, second],
496            ..Pdf::default()
497        }
498    }
499
500    /// The three write paths are one assembly and one emission: identical
501    /// bytes whether buffered, streamed into an `io::Write`, or streamed
502    /// into an async sink.
503    #[test]
504    fn write_into_and_write_into_with_match_to_bytes() {
505        let bytes = two_page_doc().to_bytes().expect("to_bytes succeeds");
506        let mut via_io = Vec::new();
507        two_page_doc()
508            .write_into(&mut via_io)
509            .expect("write_into succeeds");
510        assert_eq!(via_io, bytes);
511        let via_sink = pdfboss_core::block_on(two_page_doc().write_into_with(Vec::new()))
512            .expect("write_into_with succeeds");
513        assert_eq!(via_sink, bytes);
514    }
515
516    #[test]
517    fn zero_page_document_is_an_error() {
518        let err = Pdf::default()
519            .to_bytes()
520            .expect_err("a page-less document must not serialize");
521        assert!(err.to_string().contains("at least one page"), "{err}");
522    }
523}