1use 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#[derive(Debug, Clone, Copy, PartialEq, Default)]
18pub enum PageSize {
19 A3,
21 #[default]
23 A4,
24 A5,
26 Letter,
28 Legal,
30 Custom {
32 width: f32,
34 height: f32,
36 },
37}
38
39impl PageSize {
40 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub struct Date {
67 pub year: u16,
69 pub month: u8,
71 pub day: u8,
73 pub hour: u8,
75 pub minute: u8,
77 pub second: u8,
79 pub utc_offset_minutes: i16,
81}
82
83impl Date {
84 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#[derive(Debug, Clone, Default, PartialEq)]
115pub struct Metadata {
116 pub title: Option<String>,
118 pub author: Option<String>,
120 pub subject: Option<String>,
122 pub keywords: Option<String>,
124 pub creator: Option<String>,
126 pub producer: Option<String>,
128 pub creation_date: Option<Date>,
130 pub modification_date: Option<Date>,
132}
133
134#[derive(Debug, Default)]
136pub struct Page {
137 pub size: PageSize,
139 pub rotation: i32,
141 pub canvas: Canvas,
143 pub links: Vec<LinkAnnotation>,
145}
146
147#[derive(Debug, Clone, PartialEq)]
150pub struct LinkAnnotation {
151 pub rect: [f32; 4],
153 pub uri: String,
155}
156
157impl Page {
158 pub fn new(size: PageSize) -> Page {
160 Page {
161 size,
162 ..Page::default()
163 }
164 }
165}
166
167#[derive(Debug, Default)]
170pub struct Pdf {
171 pub metadata: Option<Metadata>,
173 pub pages: Vec<Page>,
175 pub options: WriteOptions,
177}
178
179impl Pdf {
180 pub fn to_bytes(self) -> Result<Vec<u8>> {
187 let (w, root) = self.assemble()?;
188 w.finish(root)
189 }
190
191 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 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 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 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
341fn name(text: &str) -> Name {
343 Name(text.to_string())
344}
345
346fn 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
377fn 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 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 #[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}