Skip to main content

paper_age/
builder.rs

1//! PaperAge
2
3use std::io::Write;
4
5use log::{debug, trace};
6use printpdf::{
7    Color, DateTime, Line, LineDashPattern, LinePoint, Mm, Op, PaintMode, ParsedFont, PdfDocument,
8    PdfFontHandle, PdfPage, PdfSaveOptions, Point, Pt, Rect, Rgb, TextItem, WindingOrder,
9};
10
11use crate::page::*;
12
13pub mod qrcode_ops;
14
15/// PaperAge version
16pub const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
17
18/// Font width / height = 3 / 5
19const FONT_RATIO: f32 = 3.0 / 5.0;
20
21const CODE_FONT_BYTES: &[u8] = include_bytes!("assets/fonts/IBMPlexMono-Regular.ttf");
22const TITLE_FONT_BYTES: &[u8] = include_bytes!("assets/fonts/IBMPlexMono-Medium.ttf");
23
24/// Container for all the data required to insert elements into the PDF
25pub struct Document {
26    /// The printpdf PDF document
27    pub doc: PdfDocument,
28
29    /// Operations to perform on the page
30    ops: Vec<Op>,
31
32    /// The medium weight font handle
33    pub title_font: PdfFontHandle,
34
35    /// The regular weight font handle
36    pub code_font: PdfFontHandle,
37
38    /// Page size
39    pub page_size: PageSize,
40
41    /// Document title
42    pub title: String,
43}
44
45impl Document {
46    /// Initialize the PDF with default dimensions and the required fonts. Also
47    /// sets the title and the producer in the PDF metadata.
48    pub fn new(title: String, page_size: PageSize) -> Result<Document, Box<dyn std::error::Error>> {
49        debug!("Initializing PDF");
50
51        let dimensions = page_size.dimensions();
52
53        let mut doc = PdfDocument::new(&title);
54
55        let producer = format!("PaperAge v{}", VERSION.unwrap_or("0.0.0"));
56        let now = DateTime::now();
57        doc.metadata.info.producer = producer;
58        doc.metadata.info.creation_date = now;
59        doc.metadata.info.modification_date = now;
60
61        let mut warnings = Vec::new();
62
63        let code_parsed = ParsedFont::from_bytes(CODE_FONT_BYTES, 0, &mut warnings)
64            .ok_or("Failed to parse code font")?;
65        let code_font_id = doc.add_font(&code_parsed);
66        let code_font = PdfFontHandle::External(code_font_id);
67
68        let title_parsed = ParsedFont::from_bytes(TITLE_FONT_BYTES, 0, &mut warnings)
69            .ok_or("Failed to parse title font")?;
70        let title_font_id = doc.add_font(&title_parsed);
71        let title_font = PdfFontHandle::External(title_font_id);
72
73        let ops = vec![
74            // White background
75            Op::SetFillColor {
76                col: Color::Rgb(Rgb::new(1.0, 1.0, 1.0, None)),
77            },
78            Op::DrawPolygon {
79                polygon: Rect {
80                    x: Pt(0.0),
81                    y: Pt(0.0),
82                    width: dimensions.width.into_pt(),
83                    height: dimensions.height.into_pt(),
84                    mode: Some(PaintMode::Fill),
85                    winding_order: Some(WindingOrder::NonZero),
86                }
87                .to_polygon(),
88            },
89            // Reset fill color to black for text and QR code
90            Op::SetFillColor {
91                col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)),
92            },
93        ];
94
95        Ok(Document {
96            doc,
97            ops,
98            title_font,
99            code_font,
100            page_size,
101            title: title.clone(),
102        })
103    }
104
105    /// Insert the given title at the top of the PDF
106    pub fn insert_title_text(&mut self, title: String) {
107        debug!("Inserting title: {}", title.as_str());
108
109        let font_size = 14.0;
110
111        // Align the title with the QR code if the title is narrower than the QR code
112        let margin = {
113            if title.len() <= 37 {
114                self.page_size.qrcode_left_edge()
115            } else {
116                self.page_size.dimensions().margin
117            }
118        };
119
120        let y = self.page_size.dimensions().height
121            - self.page_size.dimensions().margin
122            - Mm::from(Pt(font_size));
123
124        self.ops.push(Op::StartTextSection);
125        self.ops.push(Op::SetFillColor {
126            col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)),
127        });
128        self.ops.push(Op::SetTextCursor {
129            pos: Point::new(margin, y),
130        });
131        self.ops.push(Op::SetFont {
132            font: self.title_font.clone(),
133            size: Pt(font_size),
134        });
135        self.ops.push(Op::ShowText {
136            items: vec![TextItem::Text(title)],
137        });
138        self.ops.push(Op::EndTextSection);
139    }
140
141    /// Insert the given PEM ciphertext in the bottom half of the page
142    pub fn insert_pem_text(&mut self, pem: String) {
143        debug!("Inserting PEM encoded ciphertext");
144
145        let mut font_size = 13.0;
146        let mut line_height = 15.0;
147
148        // Rudimentary text scaling to get the Ascii Armor text to fit
149        if pem.lines().count() > 42 {
150            font_size = 6.5;
151            line_height = 7.0;
152        } else if pem.lines().count() > 39 {
153            font_size = 7.0;
154            line_height = 8.0;
155        } else if pem.lines().count() > 27 {
156            font_size = 8.0;
157            line_height = 9.0;
158        } else if pem.lines().count() > 22 {
159            font_size = 10.0;
160            line_height = 12.0;
161        }
162
163        self.ops.push(Op::StartTextSection);
164        self.ops.push(Op::SetFillColor {
165            col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)),
166        });
167
168        self.ops.push(Op::SetTextCursor {
169            pos: Point::new(
170                self.page_size.dimensions().margin,
171                (self.page_size.dimensions().height / 2.0)
172                    - Mm::from(Pt(font_size))
173                    - self.page_size.dimensions().margin,
174            ),
175        });
176        self.ops.push(Op::SetLineHeight {
177            lh: Pt(line_height),
178        });
179        self.ops.push(Op::SetFont {
180            font: self.code_font.clone(),
181            size: Pt(font_size),
182        });
183
184        for line in pem.lines() {
185            self.ops.push(Op::ShowText {
186                items: vec![TextItem::Text(line.to_string())],
187            });
188            self.ops.push(Op::AddLineBreak);
189        }
190
191        self.ops.push(Op::EndTextSection);
192    }
193
194    /// Insert the QR code of the PEM encoded ciphertext in the top half of the page
195    pub fn insert_qr_code(&mut self, text: String) -> Result<(), Box<dyn std::error::Error>> {
196        debug!("Inserting QR code");
197
198        let ops = qrcode_ops::render(text, &self.page_size)?;
199        self.ops.extend(ops);
200
201        Ok(())
202    }
203
204    /// Draw a grid debugging layout issues
205    pub fn draw_grid(&mut self) {
206        debug!("Drawing grid");
207
208        let grid_size = Mm(5.0);
209        let thickness = 0.0;
210
211        let mut x = Mm(0.0);
212        let mut y = self.page_size.dimensions().height;
213        while x < self.page_size.dimensions().width {
214            x += grid_size;
215
216            self.draw_line(
217                vec![
218                    Point::new(x, self.page_size.dimensions().height),
219                    Point::new(x, Mm(0.0)),
220                ],
221                thickness,
222                LineDashPattern::default(),
223            );
224
225            while y > Mm(0.0) {
226                y -= grid_size;
227
228                self.draw_line(
229                    vec![
230                        Point::new(self.page_size.dimensions().width, y),
231                        Point::new(Mm(0.0), y),
232                    ],
233                    thickness,
234                    LineDashPattern::default(),
235                );
236            }
237        }
238    }
239
240    /// Draw a line on the page
241    pub fn draw_line(&mut self, points: Vec<Point>, thickness: f32, dash_pattern: LineDashPattern) {
242        trace!("Drawing line");
243
244        self.ops.push(Op::SetLineDashPattern { dash: dash_pattern });
245
246        let outline_color = Color::Rgb(Rgb::new(0.75, 0.75, 0.75, None));
247        self.ops.push(Op::SetOutlineColor { col: outline_color });
248
249        self.ops.push(Op::SetOutlineThickness { pt: Pt(thickness) });
250
251        let divider = Line {
252            points: points
253                .iter()
254                .map(|p| LinePoint {
255                    p: *p,
256                    bezier: false,
257                })
258                .collect(),
259            is_closed: false,
260        };
261
262        self.ops.push(Op::DrawLine { line: divider });
263    }
264
265    /// Insert the notes field label and placeholder in the PDF
266    pub fn insert_notes_field(&mut self, label: String, skip_line: bool) {
267        debug!("Inserting notes/passphrase placeholder");
268        const MAX_LABEL_LEN: usize = 32;
269
270        let baseline =
271            self.page_size.dimensions().height / 2.0 + self.page_size.dimensions().margin;
272
273        let label_len = label.len();
274
275        let font_size = 13.0;
276
277        self.ops.push(Op::StartTextSection);
278        self.ops.push(Op::SetFillColor {
279            col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)),
280        });
281        self.ops.push(Op::SetTextCursor {
282            pos: Point::new(self.page_size.qrcode_left_edge(), baseline),
283        });
284        self.ops.push(Op::SetFont {
285            font: self.title_font.clone(),
286            size: Pt(font_size),
287        });
288        self.ops.push(Op::ShowText {
289            items: vec![TextItem::Text(label)],
290        });
291        self.ops.push(Op::EndTextSection);
292
293        // If the placeholder line would be ridiculously short, don't draw it
294        if label_len <= MAX_LABEL_LEN && !skip_line {
295            self.draw_line(
296                vec![
297                    Point::new(
298                        self.page_size.qrcode_left_edge()
299                            + Mm::from(Pt(FONT_RATIO * font_size * label_len as f32)),
300                        baseline - Mm(1.0),
301                    ),
302                    Point::new(
303                        self.page_size.qrcode_left_edge() + self.page_size.qrcode_size(),
304                        baseline - Mm(1.0),
305                    ),
306                ],
307                1.0,
308                LineDashPattern::default(),
309            )
310        }
311    }
312
313    /// Add the footer at the bottom of the page
314    pub fn insert_footer(&mut self) {
315        debug!("Inserting footer");
316
317        self.ops.push(Op::StartTextSection);
318        self.ops.push(Op::SetFillColor {
319            col: Color::Rgb(Rgb::new(0.0, 0.0, 0.0, None)),
320        });
321        self.ops.push(Op::SetTextCursor {
322            pos: Point::new(
323                self.page_size.dimensions().margin,
324                self.page_size.dimensions().margin,
325            ),
326        });
327        self.ops.push(Op::SetFont {
328            font: self.title_font.clone(),
329            size: Pt(13.0),
330        });
331        self.ops.push(Op::ShowText {
332            items: vec![TextItem::Text(
333                "Scan QR code and decrypt using Age <https://age-encryption.org>".to_string(),
334            )],
335        });
336        self.ops.push(Op::EndTextSection);
337    }
338
339    /// Build the final PDF and return as bytes
340    pub fn save_to_bytes(mut self) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
341        let dimensions = self.page_size.dimensions();
342        let page = PdfPage::new(dimensions.width, dimensions.height, self.ops);
343        self.doc.pages.push(page);
344
345        let mut warnings = Vec::new();
346        let bytes = self.doc.save(&PdfSaveOptions::default(), &mut warnings);
347        Ok(bytes)
348    }
349
350    /// Build the final PDF and write to a writer
351    pub fn save_to_writer<W: Write>(
352        mut self,
353        writer: &mut W,
354    ) -> Result<(), Box<dyn std::error::Error>> {
355        let dimensions = self.page_size.dimensions();
356        let page = PdfPage::new(dimensions.width, dimensions.height, self.ops);
357        self.doc.pages.push(page);
358
359        let mut warnings = Vec::new();
360        self.doc
361            .save_writer(writer, &PdfSaveOptions::default(), &mut warnings);
362        Ok(())
363    }
364
365    /// Build a PaperAge PDF and return its bytes.
366    ///
367    /// # Arguments
368    /// * `grid` - Whether to draw a debug grid
369    /// * `notes_label` - Label for the notes/passphrase field
370    /// * `skip_notes_line` - Whether to omit the notes placeholder line
371    /// * `encrypted` - The encrypted ciphertext to encode as a QR code and PEM block
372    pub fn create_pdf(
373        mut self,
374        grid: bool,
375        notes_label: String,
376        skip_notes_line: bool,
377        encrypted: String,
378    ) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
379        if grid {
380            self.draw_grid();
381        }
382
383        self.insert_title_text(self.title.clone());
384
385        self.insert_qr_code(encrypted.clone())?;
386
387        self.insert_notes_field(notes_label, skip_notes_line);
388
389        self.draw_line(
390            vec![
391                self.page_size.dimensions().center_left(),
392                self.page_size.dimensions().center_right(),
393            ],
394            1.0,
395            LineDashPattern {
396                dash_1: Some(5),
397                ..LineDashPattern::default()
398            },
399        );
400
401        self.insert_pem_text(encrypted);
402
403        self.insert_footer();
404
405        self.save_to_bytes()
406    }
407}
408
409#[test]
410fn test_paper_dimensions_default() {
411    let default = PageDimensions::default();
412    assert_eq!(default.width, Mm(210.0));
413    assert_eq!(default.height, Mm(297.0));
414}
415
416#[test]
417fn test_new_document() {
418    let title = String::from("Hello World!");
419    let result = Document::new(title, PageSize::A4);
420    assert!(result.is_ok());
421
422    let doc = result.unwrap();
423    assert_eq!(doc.page_size.dimensions(), crate::page::A4_PAGE);
424}
425
426#[test]
427fn test_new_letter_document() {
428    let title = String::from("Hello Letter!");
429    let result = Document::new(title, PageSize::Letter);
430    assert!(result.is_ok());
431
432    let doc = result.unwrap();
433    assert_eq!(doc.page_size.dimensions(), crate::page::LETTER_PAGE);
434}
435
436#[test]
437fn test_qrcode() {
438    let result = Document::new(String::from("QR code"), PageSize::A4);
439    let mut document = result.unwrap();
440    let result = document.insert_qr_code(String::from("payload"));
441    assert!(result.is_ok());
442}
443
444#[test]
445fn test_qrcode_too_large() {
446    let mut document = Document::new(String::from("QR code"), PageSize::A4).unwrap();
447    let result = document.insert_qr_code(String::from(include_str!("../tests/data/too_large.txt")));
448
449    assert!(result.is_err());
450    assert!(result.unwrap_err().is::<qrcode::types::QrError>());
451}