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 and painted content.
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}
144
145impl Page {
146    /// An empty page of the given size.
147    pub fn new(size: PageSize) -> Page {
148        Page {
149            size,
150            ..Page::default()
151        }
152    }
153}
154
155/// A document under construction. The fields are the composition:
156/// singleton slots are `Option`s, pages keep the order given.
157#[derive(Debug, Default)]
158pub struct Pdf {
159    /// Document information, if any.
160    pub metadata: Option<Metadata>,
161    /// Pages, in reading order.
162    pub pages: Vec<Page>,
163    /// File-emission options.
164    pub options: WriteOptions,
165}
166
167impl Pdf {
168    /// Serializes the document to complete PDF file bytes.
169    ///
170    /// Fonts are shared document-wide: each distinct [`Standard14`] face
171    /// gets one font object, in first-use order. Images are embedded per
172    /// page with no cross-page deduplication — the same raster drawn on
173    /// two pages is stored twice.
174    pub fn to_bytes(self) -> Result<Vec<u8>> {
175        let (w, root) = self.assemble()?;
176        w.finish(root)
177    }
178
179    /// [`Pdf::to_bytes`] streaming into a [`std::io::Write`]: the same
180    /// bytes, delivered in bounded chunks instead of one buffer. Unlike
181    /// `to_bytes`, an error can leave a prefix of the file already written
182    /// to `out`. No flush is performed.
183    pub fn write_into(self, out: impl std::io::Write) -> Result<()> {
184        let (w, root) = self.assemble()?;
185        w.finish_into(root, out)
186    }
187
188    /// [`Pdf::to_bytes`] streaming into any [`AsyncByteSink`] — the
189    /// asynchronous twin of [`Pdf::write_into`]. An error can leave a
190    /// prefix of the file already written. Hands the sink back unflushed.
191    pub async fn write_into_with<S: AsyncByteSink>(self, sink: S) -> Result<S> {
192        let (w, root) = self.assemble()?;
193        w.finish_into_with(root, sink).await
194    }
195
196    /// Builds the writer every write path finishes: all objects placed,
197    /// the catalog's reference returned alongside.
198    fn assemble(self) -> Result<(Writer, ObjRef)> {
199        let Pdf {
200            metadata,
201            pages,
202            options,
203        } = self;
204        if pages.is_empty() {
205            return Err(Error::Other(
206                "a document needs at least one page".to_string(),
207            ));
208        }
209        let mut w = Writer::new(options);
210        let pages_root = w.reserve();
211        let mut font_cache: Vec<(Standard14, ObjRef)> = Vec::new();
212        let mut kids = Vec::with_capacity(pages.len());
213        for page in pages {
214            if page.rotation % 90 != 0 {
215                return Err(Error::Other(format!(
216                    "page rotation {} is not a multiple of 90",
217                    page.rotation
218                )));
219            }
220            let (width, height) = page.size.dimensions();
221            let rotation = page.rotation;
222            let parts = page.canvas.into_parts();
223            let content = w.put_stream(Dict::new(), serialize_ops(&parts.ops));
224            let mut fonts = Dict::new();
225            for (index, face) in parts.fonts.iter().enumerate() {
226                let cached = font_cache
227                    .iter()
228                    .find(|(seen, _)| seen == face)
229                    .map(|(_, r)| *r);
230                let font_ref = match cached {
231                    Some(r) => r,
232                    None => {
233                        let r = w.put(Object::Dict(face.font_dict()));
234                        font_cache.push((*face, r));
235                        r
236                    }
237                };
238                fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
239            }
240            let mut xobjects = Dict::new();
241            for (index, image) in parts.images.iter().enumerate() {
242                let image_ref = image.build_xobject(&mut w);
243                xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
244            }
245            let mut resources = Dict::new();
246            if !fonts.is_empty() {
247                resources.insert(name("Font"), Object::Dict(fonts));
248            }
249            if !xobjects.is_empty() {
250                resources.insert(name("XObject"), Object::Dict(xobjects));
251            }
252            let mut dict = Dict::new();
253            dict.insert(name("Type"), Object::Name(name("Page")));
254            dict.insert(name("Parent"), Object::Ref(pages_root));
255            dict.insert(
256                name("MediaBox"),
257                Object::Array(vec![
258                    Object::Int(0),
259                    Object::Int(0),
260                    Object::Real(f64::from(width)),
261                    Object::Real(f64::from(height)),
262                ]),
263            );
264            dict.insert(name("Contents"), Object::Ref(content));
265            dict.insert(name("Resources"), Object::Dict(resources));
266            if rotation != 0 {
267                dict.insert(name("Rotate"), Object::Int(i64::from(rotation)));
268            }
269            kids.push(Object::Ref(w.put(Object::Dict(dict))));
270        }
271        let mut tree = Dict::new();
272        tree.insert(name("Type"), Object::Name(name("Pages")));
273        tree.insert(name("Count"), Object::Int(kids.len() as i64));
274        tree.insert(name("Kids"), Object::Array(kids));
275        w.fill(pages_root, Object::Dict(tree))?;
276        if let Some(info) = metadata.and_then(info_dict) {
277            let info_ref = w.put(Object::Dict(info));
278            w.set_info(info_ref);
279        }
280        let mut catalog = Dict::new();
281        catalog.insert(name("Type"), Object::Name(name("Catalog")));
282        catalog.insert(name("Pages"), Object::Ref(pages_root));
283        let root = w.put(Object::Dict(catalog));
284        Ok((w, root))
285    }
286
287    /// Serializes and writes the document to `path`.
288    pub fn save(self, path: impl AsRef<Path>) -> Result<()> {
289        let path = path.as_ref();
290        let bytes = self.to_bytes()?;
291        std::fs::write(path, bytes)?;
292        Ok(())
293    }
294}
295
296/// A `Name` from a string literal.
297fn name(text: &str) -> Name {
298    Name(text.to_string())
299}
300
301/// Builds the `/Info` dictionary, or `None` when every field is `None`.
302fn info_dict(meta: Metadata) -> Option<Dict> {
303    let mut dict = Dict::new();
304    let texts = [
305        ("Title", meta.title),
306        ("Author", meta.author),
307        ("Subject", meta.subject),
308        ("Keywords", meta.keywords),
309        ("Creator", meta.creator),
310        ("Producer", meta.producer),
311    ];
312    for (key, value) in texts {
313        if let Some(value) = value {
314            dict.insert(name(key), text_string(&value));
315        }
316    }
317    let dates = [
318        ("CreationDate", meta.creation_date),
319        ("ModDate", meta.modification_date),
320    ];
321    for (key, value) in dates {
322        if let Some(date) = value {
323            dict.insert(name(key), Object::String(date.to_pdf_string().into_bytes()));
324        }
325    }
326    if dict.is_empty() {
327        return None;
328    }
329    Some(dict)
330}
331
332/// Encodes a text string (ISO 32000 §7.9.2.2): pure ASCII passes through
333/// as its own bytes, anything else becomes UTF-16BE with a `FE FF` byte
334/// order mark.
335fn text_string(value: &str) -> Object {
336    if value.is_ascii() {
337        return Object::String(value.as_bytes().to_vec());
338    }
339    let mut bytes = vec![0xFE, 0xFF];
340    for unit in value.encode_utf16() {
341        bytes.extend_from_slice(&unit.to_be_bytes());
342    }
343    Object::String(bytes)
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn dimensions_match_the_contract() {
352        assert_eq!(PageSize::A3.dimensions(), (841.89, 1190.55));
353        assert_eq!(PageSize::A4.dimensions(), (595.28, 841.89));
354        assert_eq!(PageSize::A5.dimensions(), (419.53, 595.28));
355        assert_eq!(PageSize::Letter.dimensions(), (612.0, 792.0));
356        assert_eq!(PageSize::Legal.dimensions(), (612.0, 1008.0));
357        assert_eq!(
358            PageSize::Custom {
359                width: 10.0,
360                height: 20.0
361            }
362            .dimensions(),
363            (10.0, 20.0)
364        );
365    }
366
367    #[test]
368    fn landscape_swaps_into_custom() {
369        assert_eq!(
370            PageSize::A4.landscape(),
371            PageSize::Custom {
372                width: 841.89,
373                height: 595.28
374            }
375        );
376        assert_eq!(
377            PageSize::Custom {
378                width: 1.0,
379                height: 2.0
380            }
381            .landscape(),
382            PageSize::Custom {
383                width: 2.0,
384                height: 1.0
385            }
386        );
387        assert_eq!(PageSize::Letter.landscape().dimensions(), (792.0, 612.0));
388    }
389
390    #[test]
391    fn date_utc_formats_with_z() {
392        let date = Date {
393            year: 2026,
394            month: 8,
395            day: 27,
396            hour: 12,
397            minute: 30,
398            second: 15,
399            utc_offset_minutes: 0,
400        };
401        assert_eq!(date.to_pdf_string(), "D:20260827123015Z");
402    }
403
404    #[test]
405    fn date_positive_offset_pads_single_digits() {
406        let date = Date {
407            year: 987,
408            month: 1,
409            day: 2,
410            hour: 3,
411            minute: 4,
412            second: 5,
413            utc_offset_minutes: 120,
414        };
415        assert_eq!(date.to_pdf_string(), "D:09870102030405+02'00");
416    }
417
418    #[test]
419    fn date_negative_offset_keeps_minutes() {
420        let date = Date {
421            year: 1999,
422            month: 12,
423            day: 31,
424            hour: 23,
425            minute: 59,
426            second: 58,
427            utc_offset_minutes: -330,
428        };
429        assert_eq!(date.to_pdf_string(), "D:19991231235958-05'30");
430    }
431
432    /// Two pages with text and an image — enough to exercise fonts,
433    /// XObjects and the reserved page tree through every write path.
434    fn two_page_doc() -> Pdf {
435        let mut first = Page::new(PageSize::A4);
436        first
437            .canvas
438            .text("Streamed parity", 72.0, 720.0, Standard14::Helvetica, 14.0)
439            .expect("ASCII encodes");
440        let image = crate::image::ImageData::gray8(2, 2, vec![0, 85, 170, 255])
441            .expect("2x2 grayscale builds");
442        let handle = first.canvas.add_image(image);
443        first.canvas.draw_image(handle, 72.0, 400.0, 144.0, 144.0);
444        let mut second = Page::new(PageSize::Letter);
445        second
446            .canvas
447            .text("Page two", 72.0, 700.0, Standard14::TimesRoman, 12.0)
448            .expect("ASCII encodes");
449        Pdf {
450            pages: vec![first, second],
451            ..Pdf::default()
452        }
453    }
454
455    /// The three write paths are one assembly and one emission: identical
456    /// bytes whether buffered, streamed into an `io::Write`, or streamed
457    /// into an async sink.
458    #[test]
459    fn write_into_and_write_into_with_match_to_bytes() {
460        let bytes = two_page_doc().to_bytes().expect("to_bytes succeeds");
461        let mut via_io = Vec::new();
462        two_page_doc()
463            .write_into(&mut via_io)
464            .expect("write_into succeeds");
465        assert_eq!(via_io, bytes);
466        let via_sink = pdfboss_core::block_on(two_page_doc().write_into_with(Vec::new()))
467            .expect("write_into_with succeeds");
468        assert_eq!(via_sink, bytes);
469    }
470
471    #[test]
472    fn zero_page_document_is_an_error() {
473        let err = Pdf::default()
474            .to_bytes()
475            .expect_err("a page-less document must not serialize");
476        assert!(err.to_string().contains("at least one page"), "{err}");
477    }
478}