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}
144
145impl Page {
146 pub fn new(size: PageSize) -> Page {
148 Page {
149 size,
150 ..Page::default()
151 }
152 }
153}
154
155#[derive(Debug, Default)]
158pub struct Pdf {
159 pub metadata: Option<Metadata>,
161 pub pages: Vec<Page>,
163 pub options: WriteOptions,
165}
166
167impl Pdf {
168 pub fn to_bytes(self) -> Result<Vec<u8>> {
175 let (w, root) = self.assemble()?;
176 w.finish(root)
177 }
178
179 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 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 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 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
296fn name(text: &str) -> Name {
298 Name(text.to_string())
299}
300
301fn 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
332fn 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 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 #[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}