1use std::path::Path;
6
7use pdfboss_core::{Dict, Name, ObjRef, Object};
8
9use crate::canvas::{Canvas, CanvasParts};
10use crate::content::serialize_ops;
11use crate::element::{self, Content};
12use crate::error::{Error, Result};
13use crate::font::Standard14;
14use crate::sink::AsyncByteSink;
15use crate::writer::{WriteOptions, Writer};
16
17#[derive(Debug, Clone, Copy, PartialEq, Default)]
19pub enum PageSize {
20 A3,
22 #[default]
24 A4,
25 A5,
27 Letter,
29 Legal,
31 Custom {
33 width: f32,
35 height: f32,
37 },
38}
39
40impl PageSize {
41 pub fn dimensions(self) -> (f32, f32) {
43 match self {
44 PageSize::A3 => (841.89, 1190.55),
45 PageSize::A4 => (595.28, 841.89),
46 PageSize::A5 => (419.53, 595.28),
47 PageSize::Letter => (612.0, 792.0),
48 PageSize::Legal => (612.0, 1008.0),
49 PageSize::Custom { width, height } => (width, height),
50 }
51 }
52
53 pub fn landscape(self) -> PageSize {
55 let (width, height) = self.dimensions();
56 PageSize::Custom {
57 width: height,
58 height: width,
59 }
60 }
61
62 pub fn by_name(name: &str) -> Option<PageSize> {
66 match name.to_ascii_lowercase().as_str() {
67 "a3" => Some(PageSize::A3),
68 "a4" => Some(PageSize::A4),
69 "a5" => Some(PageSize::A5),
70 "letter" => Some(PageSize::Letter),
71 "legal" => Some(PageSize::Legal),
72 _ => None,
73 }
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct Date {
82 pub year: u16,
84 pub month: u8,
86 pub day: u8,
88 pub hour: u8,
90 pub minute: u8,
92 pub second: u8,
94 pub utc_offset_minutes: i16,
96}
97
98impl Date {
99 pub fn to_pdf_string(self) -> String {
102 let Date {
103 year,
104 month,
105 day,
106 hour,
107 minute,
108 second,
109 utc_offset_minutes,
110 } = self;
111 let mut out = format!("D:{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}");
112 if utc_offset_minutes == 0 {
113 out.push('Z');
114 return out;
115 }
116 let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
117 let magnitude = utc_offset_minutes.unsigned_abs();
118 out.push_str(&format!(
119 "{sign}{:02}'{:02}",
120 magnitude / 60,
121 magnitude % 60
122 ));
123 out
124 }
125
126 pub(crate) fn to_iso8601(self) -> String {
130 let Date {
131 year,
132 month,
133 day,
134 hour,
135 minute,
136 second,
137 utc_offset_minutes,
138 } = self;
139 let mut out = format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}");
140 if utc_offset_minutes == 0 {
141 out.push('Z');
142 return out;
143 }
144 let sign = if utc_offset_minutes < 0 { '-' } else { '+' };
145 let magnitude = utc_offset_minutes.unsigned_abs();
146 out.push_str(&format!(
147 "{sign}{:02}:{:02}",
148 magnitude / 60,
149 magnitude % 60
150 ));
151 out
152 }
153
154 pub fn parse_pdf(s: &str) -> Option<Date> {
161 let bytes = s.strip_prefix("D:").unwrap_or(s).as_bytes();
162 let mut pos = 0;
163 let year = take_required_digits(bytes, &mut pos, 4)? as u16;
164 let month = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(1) as u8;
165 let day = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(1) as u8;
166 let hour = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0) as u8;
167 let minute = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0) as u8;
168 let second = take_optional_digits(bytes, &mut pos, 2)?.unwrap_or(0) as u8;
169 let utc_offset_minutes = parse_pdf_offset(bytes, pos)?;
170 Some(Date {
171 year,
172 month,
173 day,
174 hour,
175 minute,
176 second,
177 utc_offset_minutes,
178 })
179 }
180}
181
182fn take_required_digits(bytes: &[u8], pos: &mut usize, width: usize) -> Option<u32> {
186 let end = *pos + width;
187 let digits = bytes.get(*pos..end)?;
188 if !digits.iter().all(|d| d.is_ascii_digit()) {
189 return None;
190 }
191 *pos = end;
192 Some(
193 digits
194 .iter()
195 .fold(0u32, |acc, &d| acc * 10 + u32::from(d - b'0')),
196 )
197}
198
199fn take_optional_digits(bytes: &[u8], pos: &mut usize, width: usize) -> Option<Option<u32>> {
202 if *pos >= bytes.len() {
203 return Some(None);
204 }
205 take_required_digits(bytes, pos, width).map(Some)
206}
207
208fn parse_pdf_offset(bytes: &[u8], pos: usize) -> Option<i16> {
213 if pos >= bytes.len() || bytes[pos] == b'Z' {
214 return Some(0);
215 }
216 let sign = match bytes[pos] {
217 b'+' => 1,
218 b'-' => -1,
219 _ => return None,
220 };
221 let mut cursor = pos + 1;
222 let hours = take_required_digits(bytes, &mut cursor, 2)?;
223 let minutes = if bytes.get(cursor) == Some(&b'\'') {
224 cursor += 1;
225 take_optional_digits(bytes, &mut cursor, 2)?.unwrap_or(0)
226 } else {
227 0
228 };
229 let magnitude = i16::try_from(hours * 60 + minutes).ok()?;
230 Some(sign * magnitude)
231}
232
233#[derive(Debug, Clone, Default, PartialEq)]
236pub struct Metadata {
237 pub title: Option<String>,
239 pub author: Option<String>,
241 pub subject: Option<String>,
243 pub keywords: Option<String>,
245 pub creator: Option<String>,
247 pub producer: Option<String>,
249 pub creation_date: Option<Date>,
251 pub modification_date: Option<Date>,
253}
254
255#[derive(Debug, Default)]
257pub struct Page {
258 pub size: PageSize,
260 pub rotation: i32,
262 pub canvas: Canvas,
264 pub content: Vec<Content>,
267 pub links: Vec<LinkAnnotation>,
269}
270
271#[derive(Debug, Clone, PartialEq)]
275pub struct LinkAnnotation {
276 pub rect: [f32; 4],
278 pub target: LinkTarget,
280}
281
282#[derive(Debug, Clone, PartialEq)]
284pub enum LinkTarget {
285 Uri(String),
287 Page(usize),
290}
291
292impl Page {
293 pub fn new(size: PageSize) -> Page {
295 Page {
296 size,
297 ..Page::default()
298 }
299 }
300}
301
302#[derive(Debug, Clone, Default, PartialEq)]
305pub struct Outline {
306 pub bookmarks: Vec<Bookmark>,
308}
309
310#[derive(Debug, Clone, Default, PartialEq)]
312pub struct Bookmark {
313 pub title: String,
315 pub page: usize,
318 pub children: Vec<Bookmark>,
320}
321
322impl Bookmark {
323 pub fn new(title: impl Into<String>, page: usize) -> Bookmark {
325 Bookmark {
326 title: title.into(),
327 page,
328 children: Vec::new(),
329 }
330 }
331}
332
333#[derive(Debug, Clone, PartialEq)]
338pub struct Attachment {
339 pub name: String,
342 pub data: Vec<u8>,
345 pub mime: Option<String>,
348 pub modified: Option<Date>,
350 pub description: Option<String>,
352}
353
354#[derive(Debug, Clone, Copy, PartialEq)]
357pub enum LabelStyle {
358 Decimal,
360 RomanUpper,
362 RomanLower,
364 LettersUpper,
366 LettersLower,
368}
369
370#[derive(Debug, Clone, PartialEq)]
375pub struct PageLabel {
376 pub first_page: usize,
378 pub style: Option<LabelStyle>,
381 pub prefix: Option<String>,
383 pub start_at: u32,
386}
387
388#[derive(Debug, Clone, Copy, PartialEq)]
391pub enum PageLayout {
392 SinglePage,
394 OneColumn,
396 TwoColumnLeft,
398 TwoColumnRight,
400 TwoPageLeft,
402 TwoPageRight,
404}
405
406#[derive(Debug, Clone, Copy, PartialEq)]
409pub enum PageMode {
410 UseNone,
412 UseOutlines,
414 UseThumbs,
416 FullScreen,
418}
419
420#[derive(Debug, Clone, Default, PartialEq)]
423pub struct Viewer {
424 pub layout: Option<PageLayout>,
426 pub mode: Option<PageMode>,
428 pub open_to: Option<usize>,
431}
432
433#[derive(Debug, Default)]
436pub struct Pdf {
437 pub metadata: Option<Metadata>,
439 pub pages: Vec<Page>,
441 pub outline: Option<Outline>,
443 pub attachments: Vec<Attachment>,
447 pub page_labels: Vec<PageLabel>,
452 pub viewer: Option<Viewer>,
454 pub options: WriteOptions,
456}
457
458impl Pdf {
459 pub fn to_bytes(self) -> Result<Vec<u8>> {
468 let (w, root) = self.assemble()?;
469 w.finish(root)
470 }
471
472 pub fn write_into(self, out: impl std::io::Write) -> Result<()> {
477 let (w, root) = self.assemble()?;
478 w.finish_into(root, out)
479 }
480
481 pub async fn write_into_with<S: AsyncByteSink>(self, sink: S) -> Result<S> {
485 let (w, root) = self.assemble()?;
486 w.finish_into_with(root, sink).await
487 }
488
489 fn assemble(self) -> Result<(Writer, ObjRef)> {
492 let Pdf {
493 metadata,
494 pages,
495 outline,
496 attachments,
497 page_labels,
498 viewer,
499 options,
500 } = self;
501 if pages.is_empty() {
502 return Err(Error::Other(
503 "a document needs at least one page".to_string(),
504 ));
505 }
506 let mut w = Writer::new(options);
507 let pages_root = w.reserve();
508 let page_count = pages.len();
509 let page_refs: Vec<ObjRef> = pages.iter().map(|_| w.reserve()).collect();
510 let mut font_cache: Vec<(Standard14, ObjRef)> = Vec::new();
511 for (index, page) in pages.into_iter().enumerate() {
512 let Page {
513 size,
514 rotation,
515 mut canvas,
516 content,
517 mut links,
518 } = page;
519 if rotation % 90 != 0 {
520 return Err(Error::Other(format!(
521 "page rotation {rotation} is not a multiple of 90"
522 )));
523 }
524 element::lower(content, &mut canvas, &mut links)?;
525 let (width, height) = size.dimensions();
526 let parts = canvas.into_parts();
527 let (content, resources) = lower_canvas(&mut w, parts, &mut font_cache)?;
528 let content_ref = w.put_stream(Dict::new(), content);
529 let mut dict = Dict::new();
530 dict.insert(name("Type"), Object::Name(name("Page")));
531 dict.insert(name("Parent"), Object::Ref(pages_root));
532 dict.insert(
533 name("MediaBox"),
534 Object::Array(vec![
535 Object::Int(0),
536 Object::Int(0),
537 Object::Real(f64::from(width)),
538 Object::Real(f64::from(height)),
539 ]),
540 );
541 dict.insert(name("Contents"), Object::Ref(content_ref));
542 dict.insert(name("Resources"), Object::Dict(resources));
543 if !links.is_empty() {
544 let mut annots = Vec::with_capacity(links.len());
545 for link in links {
546 let action = match link.target {
547 LinkTarget::Uri(uri) => {
548 let mut action = Dict::new();
549 action.insert(name("S"), Object::Name(name("URI")));
550 action.insert(name("URI"), text_string(&uri));
551 action
552 }
553 LinkTarget::Page(target_index) => {
554 let target = page_refs.get(target_index).copied().ok_or_else(|| {
555 Error::Other(format!(
556 "link target page {target_index} is out of range: the document has {page_count} pages"
557 ))
558 })?;
559 let mut action = Dict::new();
560 action.insert(name("S"), Object::Name(name("GoTo")));
561 action.insert(
562 name("D"),
563 Object::Array(vec![
564 Object::Ref(target),
565 Object::Name(name("XYZ")),
566 Object::Null,
567 Object::Null,
568 Object::Null,
569 ]),
570 );
571 action
572 }
573 };
574 let mut annot = Dict::new();
575 annot.insert(name("Type"), Object::Name(name("Annot")));
576 annot.insert(name("Subtype"), Object::Name(name("Link")));
577 annot.insert(
578 name("Rect"),
579 Object::Array(
580 link.rect
581 .iter()
582 .map(|v| Object::Real(f64::from(*v)))
583 .collect(),
584 ),
585 );
586 annot.insert(
587 name("Border"),
588 Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(0)]),
589 );
590 annot.insert(name("A"), Object::Dict(action));
591 annots.push(Object::Ref(w.put(Object::Dict(annot))));
592 }
593 dict.insert(name("Annots"), Object::Array(annots));
594 }
595 if rotation != 0 {
596 dict.insert(name("Rotate"), Object::Int(i64::from(rotation)));
597 }
598 w.fill(page_refs[index], Object::Dict(dict))?;
599 }
600 let kids: Vec<Object> = page_refs.iter().copied().map(Object::Ref).collect();
601 let mut tree = Dict::new();
602 tree.insert(name("Type"), Object::Name(name("Pages")));
603 tree.insert(name("Count"), Object::Int(kids.len() as i64));
604 tree.insert(name("Kids"), Object::Array(kids));
605 w.fill(pages_root, Object::Dict(tree))?;
606 let xmp_ref = match metadata {
607 Some(meta) => {
608 let packet = crate::xmp::packet(&meta);
609 if let Some(info) = info_dict(meta) {
610 let info_ref = w.put(Object::Dict(info));
611 w.set_info(info_ref);
612 }
613 let mut xmp_dict = Dict::new();
614 xmp_dict.insert(name("Type"), Object::Name(name("Metadata")));
615 xmp_dict.insert(name("Subtype"), Object::Name(name("XML")));
616 Some(w.put_stream_raw(xmp_dict, packet))
617 }
618 None => None,
619 };
620 let outline_ref = match outline {
621 Some(outline) if !outline.bookmarks.is_empty() => {
622 let root_ref = w.reserve();
623 let refs = reserve_bookmarks(&mut w, &outline.bookmarks);
624 let (first, last, count) = fill_bookmarks(
625 &mut w,
626 outline.bookmarks,
627 &refs,
628 root_ref,
629 &page_refs,
630 page_count,
631 )?;
632 let mut dict = Dict::new();
633 dict.insert(name("Type"), Object::Name(name("Outlines")));
634 dict.insert(name("First"), Object::Ref(first));
635 dict.insert(name("Last"), Object::Ref(last));
636 dict.insert(name("Count"), Object::Int(count));
637 w.fill(root_ref, Object::Dict(dict))?;
638 Some(root_ref)
639 }
640 _ => None,
641 };
642 let names = embedded_files_dict(&mut w, attachments)?;
643 let page_labels_entry = page_labels_dict(page_labels)?;
644 let mut catalog = Dict::new();
645 catalog.insert(name("Type"), Object::Name(name("Catalog")));
646 catalog.insert(name("Pages"), Object::Ref(pages_root));
647 if let Some(outline_ref) = outline_ref {
648 catalog.insert(name("Outlines"), Object::Ref(outline_ref));
649 }
650 if let Some(xmp_ref) = xmp_ref {
651 catalog.insert(name("Metadata"), Object::Ref(xmp_ref));
652 }
653 if let Some(names) = names {
654 catalog.insert(name("Names"), Object::Dict(names));
655 }
656 if let Some(page_labels_entry) = page_labels_entry {
657 catalog.insert(name("PageLabels"), Object::Dict(page_labels_entry));
658 }
659 if let Some(viewer) = viewer {
660 let Viewer {
661 layout,
662 mode,
663 open_to,
664 } = viewer;
665 if let Some(layout) = layout {
666 catalog.insert(
667 name("PageLayout"),
668 Object::Name(name(page_layout_name(layout))),
669 );
670 }
671 if let Some(mode) = mode {
672 catalog.insert(name("PageMode"), Object::Name(name(page_mode_name(mode))));
673 }
674 if let Some(open_to) = open_to {
675 let target = page_refs.get(open_to).copied().ok_or_else(|| {
676 Error::Other(format!(
677 "open_to target page {open_to} is out of range: the document has {page_count} pages"
678 ))
679 })?;
680 catalog.insert(
681 name("OpenAction"),
682 Object::Array(vec![
683 Object::Ref(target),
684 Object::Name(name("XYZ")),
685 Object::Null,
686 Object::Null,
687 Object::Null,
688 ]),
689 );
690 }
691 }
692 let root = w.put(Object::Dict(catalog));
693 Ok((w, root))
694 }
695
696 pub fn save(self, path: impl AsRef<Path>) -> Result<()> {
698 let path = path.as_ref();
699 let bytes = self.to_bytes()?;
700 std::fs::write(path, bytes)?;
701 Ok(())
702 }
703}
704
705fn name(text: &str) -> Name {
707 Name(text.to_string())
708}
709
710fn info_dict(meta: Metadata) -> Option<Dict> {
712 let mut dict = Dict::new();
713 let texts = [
714 ("Title", meta.title),
715 ("Author", meta.author),
716 ("Subject", meta.subject),
717 ("Keywords", meta.keywords),
718 ("Creator", meta.creator),
719 ("Producer", meta.producer),
720 ];
721 for (key, value) in texts {
722 if let Some(value) = value {
723 dict.insert(name(key), text_string(&value));
724 }
725 }
726 let dates = [
727 ("CreationDate", meta.creation_date),
728 ("ModDate", meta.modification_date),
729 ];
730 for (key, value) in dates {
731 if let Some(date) = value {
732 dict.insert(name(key), Object::String(date.to_pdf_string().into_bytes()));
733 }
734 }
735 if dict.is_empty() {
736 return None;
737 }
738 Some(dict)
739}
740
741const DEFAULT_ATTACHMENT_MIME: &str = "application/octet-stream";
743
744fn embedded_files_dict(w: &mut Writer, mut attachments: Vec<Attachment>) -> Result<Option<Dict>> {
750 if attachments.is_empty() {
751 return Ok(None);
752 }
753 attachments.sort_by(|a, b| a.name.cmp(&b.name));
754 for pair in attachments.windows(2) {
755 if pair[0].name == pair[1].name {
756 return Err(Error::Other(format!(
757 "duplicate attachment name: {:?}",
758 pair[0].name
759 )));
760 }
761 }
762 let mut entries = Vec::with_capacity(attachments.len() * 2);
763 for attachment in attachments {
764 let Attachment {
765 name: file_name,
766 data,
767 mime,
768 modified,
769 description,
770 } = attachment;
771 let mime = mime.unwrap_or_else(|| DEFAULT_ATTACHMENT_MIME.to_string());
772
773 let mut params = Dict::new();
774 params.insert(name("Size"), Object::Int(data.len() as i64));
775 if let Some(modified) = modified {
776 params.insert(
777 name("ModDate"),
778 Object::String(modified.to_pdf_string().into_bytes()),
779 );
780 }
781 let mut stream_dict = Dict::new();
782 stream_dict.insert(name("Type"), Object::Name(name("EmbeddedFile")));
783 stream_dict.insert(name("Subtype"), Object::Name(Name(mime)));
784 stream_dict.insert(name("Params"), Object::Dict(params));
785 let stream_ref = w.put_stream(stream_dict, data);
786
787 let mut ef = Dict::new();
788 ef.insert(name("F"), Object::Ref(stream_ref));
789
790 let mut filespec = Dict::new();
791 filespec.insert(name("Type"), Object::Name(name("Filespec")));
792 filespec.insert(name("F"), text_string(&file_name));
793 filespec.insert(name("UF"), text_string(&file_name));
794 if let Some(description) = description {
795 filespec.insert(name("Desc"), text_string(&description));
796 }
797 filespec.insert(name("EF"), Object::Dict(ef));
798 let filespec_ref = w.put(Object::Dict(filespec));
799
800 entries.push(text_string(&file_name));
801 entries.push(Object::Ref(filespec_ref));
802 }
803 let mut name_tree = Dict::new();
804 name_tree.insert(name("Names"), Object::Array(entries));
805 let mut embedded_files = Dict::new();
806 embedded_files.insert(name("EmbeddedFiles"), Object::Dict(name_tree));
807 Ok(Some(embedded_files))
808}
809
810fn page_labels_dict(mut labels: Vec<PageLabel>) -> Result<Option<Dict>> {
817 if labels.is_empty() {
818 return Ok(None);
819 }
820 for label in &labels {
821 if label.start_at == 0 {
822 return Err(Error::Other(format!(
823 "page label at page {} has start_at 0: numbering starts at 1",
824 label.first_page
825 )));
826 }
827 }
828 labels.sort_by_key(|label| label.first_page);
829 if labels[0].first_page != 0 {
830 return Err(Error::Other("page labels must start at page 0".to_string()));
831 }
832 for pair in labels.windows(2) {
833 if pair[0].first_page == pair[1].first_page {
834 return Err(Error::Other(format!(
835 "duplicate page label at page {}",
836 pair[0].first_page
837 )));
838 }
839 }
840 let mut nums = Vec::with_capacity(labels.len() * 2);
841 for label in labels {
842 let PageLabel {
843 first_page,
844 style,
845 prefix,
846 start_at,
847 } = label;
848 let mut range = Dict::new();
849 if let Some(style) = style {
850 range.insert(name("S"), Object::Name(name(label_style_name(style))));
851 }
852 if let Some(prefix) = prefix {
853 range.insert(name("P"), text_string(&prefix));
854 }
855 if start_at != 1 {
856 range.insert(name("St"), Object::Int(i64::from(start_at)));
857 }
858 nums.push(Object::Int(first_page as i64));
859 nums.push(Object::Dict(range));
860 }
861 let mut dict = Dict::new();
862 dict.insert(name("Nums"), Object::Array(nums));
863 Ok(Some(dict))
864}
865
866fn label_style_name(style: LabelStyle) -> &'static str {
868 match style {
869 LabelStyle::Decimal => "D",
870 LabelStyle::RomanUpper => "R",
871 LabelStyle::RomanLower => "r",
872 LabelStyle::LettersUpper => "A",
873 LabelStyle::LettersLower => "a",
874 }
875}
876
877fn page_layout_name(layout: PageLayout) -> &'static str {
879 match layout {
880 PageLayout::SinglePage => "SinglePage",
881 PageLayout::OneColumn => "OneColumn",
882 PageLayout::TwoColumnLeft => "TwoColumnLeft",
883 PageLayout::TwoColumnRight => "TwoColumnRight",
884 PageLayout::TwoPageLeft => "TwoPageLeft",
885 PageLayout::TwoPageRight => "TwoPageRight",
886 }
887}
888
889fn page_mode_name(mode: PageMode) -> &'static str {
891 match mode {
892 PageMode::UseNone => "UseNone",
893 PageMode::UseOutlines => "UseOutlines",
894 PageMode::UseThumbs => "UseThumbs",
895 PageMode::FullScreen => "FullScreen",
896 }
897}
898
899struct BookmarkRef {
903 r: ObjRef,
904 children: Vec<BookmarkRef>,
905}
906
907fn reserve_bookmarks(w: &mut Writer, bookmarks: &[Bookmark]) -> Vec<BookmarkRef> {
912 bookmarks
913 .iter()
914 .map(|bookmark| BookmarkRef {
915 r: w.reserve(),
916 children: reserve_bookmarks(w, &bookmark.children),
917 })
918 .collect()
919}
920
921fn fill_bookmarks(
928 w: &mut Writer,
929 bookmarks: Vec<Bookmark>,
930 refs: &[BookmarkRef],
931 parent: ObjRef,
932 page_refs: &[ObjRef],
933 page_count: usize,
934) -> Result<(ObjRef, ObjRef, i64)> {
935 let last_index = bookmarks.len() - 1;
936 let mut total = 0i64;
937 for (index, bookmark) in bookmarks.into_iter().enumerate() {
938 let Bookmark {
939 title,
940 page,
941 children,
942 } = bookmark;
943 let dest = page_refs.get(page).copied().ok_or_else(|| {
944 Error::Other(format!(
945 "bookmark target page {page} is out of range: the document has {page_count} pages"
946 ))
947 })?;
948 let mut dict = Dict::new();
949 dict.insert(name("Title"), text_string(&title));
950 dict.insert(name("Parent"), Object::Ref(parent));
951 if index > 0 {
952 dict.insert(name("Prev"), Object::Ref(refs[index - 1].r));
953 }
954 if index < last_index {
955 dict.insert(name("Next"), Object::Ref(refs[index + 1].r));
956 }
957 dict.insert(
958 name("Dest"),
959 Object::Array(vec![
960 Object::Ref(dest),
961 Object::Name(name("XYZ")),
962 Object::Null,
963 Object::Null,
964 Object::Null,
965 ]),
966 );
967 let mut subtree_count = 0i64;
968 if !children.is_empty() {
969 let (first, last, count) = fill_bookmarks(
970 w,
971 children,
972 &refs[index].children,
973 refs[index].r,
974 page_refs,
975 page_count,
976 )?;
977 dict.insert(name("First"), Object::Ref(first));
978 dict.insert(name("Last"), Object::Ref(last));
979 dict.insert(name("Count"), Object::Int(count));
980 subtree_count = count;
981 }
982 w.fill(refs[index].r, Object::Dict(dict))?;
983 total += 1 + subtree_count;
984 }
985 Ok((refs[0].r, refs[last_index].r, total))
986}
987
988fn cached_font(
994 w: &mut Writer,
995 font_cache: &mut Vec<(Standard14, ObjRef)>,
996 face: &Standard14,
997) -> ObjRef {
998 if let Some((_, r)) = font_cache.iter().find(|(seen, _)| seen == face) {
999 return *r;
1000 }
1001 let r = w.put(Object::Dict(face.font_dict()));
1002 font_cache.push((*face, r));
1003 r
1004}
1005
1006fn lower_canvas(
1012 w: &mut Writer,
1013 parts: CanvasParts,
1014 font_cache: &mut Vec<(Standard14, ObjRef)>,
1015) -> Result<(Vec<u8>, Dict)> {
1016 let content = serialize_ops(&parts.ops);
1017 let mut fonts = Dict::new();
1018 for (index, face) in parts.fonts.iter().enumerate() {
1019 let font_ref = cached_font(w, font_cache, face);
1020 fonts.insert(Name(format!("F{}", index + 1)), Object::Ref(font_ref));
1021 }
1022 let mut xobjects = Dict::new();
1023 for (index, image) in parts.images.iter().enumerate() {
1024 let image_ref = image.build_xobject(w);
1025 xobjects.insert(Name(format!("Im{}", index + 1)), Object::Ref(image_ref));
1026 }
1027 for (index, (group_parts, group_bbox)) in parts.groups.into_iter().enumerate() {
1028 let group_ref = build_form(w, group_parts, group_bbox, font_cache)?;
1029 xobjects.insert(Name(format!("Gp{}", index + 1)), Object::Ref(group_ref));
1030 }
1031 let mut ext_gstates = Dict::new();
1032 for (index, state) in parts.gstates.iter().enumerate() {
1033 let gstate_ref = w.put(Object::Dict(state.ext_gstate_dict()));
1034 ext_gstates.insert(Name(format!("Gs{}", index + 1)), Object::Ref(gstate_ref));
1035 }
1036 let mut resources = Dict::new();
1037 if !fonts.is_empty() {
1038 resources.insert(name("Font"), Object::Dict(fonts));
1039 }
1040 if !xobjects.is_empty() {
1041 resources.insert(name("XObject"), Object::Dict(xobjects));
1042 }
1043 if !ext_gstates.is_empty() {
1044 resources.insert(name("ExtGState"), Object::Dict(ext_gstates));
1045 }
1046 Ok((content, resources))
1047}
1048
1049fn build_form(
1055 w: &mut Writer,
1056 parts: CanvasParts,
1057 bbox: [f32; 4],
1058 font_cache: &mut Vec<(Standard14, ObjRef)>,
1059) -> Result<ObjRef> {
1060 let (content, resources) = lower_canvas(w, parts, font_cache)?;
1061 let mut dict = Dict::new();
1062 dict.insert(name("Type"), Object::Name(name("XObject")));
1063 dict.insert(name("Subtype"), Object::Name(name("Form")));
1064 dict.insert(
1065 name("BBox"),
1066 Object::Array(bbox.iter().map(|v| Object::Real(f64::from(*v))).collect()),
1067 );
1068 dict.insert(name("Resources"), Object::Dict(resources));
1069 Ok(w.put_stream(dict, content))
1070}
1071
1072pub(crate) fn text_string(value: &str) -> Object {
1076 if value.is_ascii() {
1077 return Object::String(value.as_bytes().to_vec());
1078 }
1079 let mut bytes = vec![0xFE, 0xFF];
1080 for unit in value.encode_utf16() {
1081 bytes.extend_from_slice(&unit.to_be_bytes());
1082 }
1083 Object::String(bytes)
1084}
1085
1086#[cfg(test)]
1087mod tests {
1088 use super::*;
1089
1090 #[test]
1091 fn dimensions_match_the_contract() {
1092 assert_eq!(PageSize::A3.dimensions(), (841.89, 1190.55));
1093 assert_eq!(PageSize::A4.dimensions(), (595.28, 841.89));
1094 assert_eq!(PageSize::A5.dimensions(), (419.53, 595.28));
1095 assert_eq!(PageSize::Letter.dimensions(), (612.0, 792.0));
1096 assert_eq!(PageSize::Legal.dimensions(), (612.0, 1008.0));
1097 assert_eq!(
1098 PageSize::Custom {
1099 width: 10.0,
1100 height: 20.0
1101 }
1102 .dimensions(),
1103 (10.0, 20.0)
1104 );
1105 }
1106
1107 #[test]
1108 fn by_name_parses_the_five_named_sizes_case_insensitively() {
1109 for (name, expected) in [
1110 ("a3", PageSize::A3),
1111 ("A4", PageSize::A4),
1112 ("a5", PageSize::A5),
1113 ("Letter", PageSize::Letter),
1114 ("LEGAL", PageSize::Legal),
1115 ] {
1116 assert_eq!(PageSize::by_name(name), Some(expected), "{name}");
1117 }
1118 }
1119
1120 #[test]
1121 fn by_name_rejects_anything_else() {
1122 assert_eq!(PageSize::by_name("tabloid"), None);
1123 assert_eq!(PageSize::by_name(""), None);
1124 }
1125
1126 #[test]
1127 fn landscape_swaps_into_custom() {
1128 assert_eq!(
1129 PageSize::A4.landscape(),
1130 PageSize::Custom {
1131 width: 841.89,
1132 height: 595.28
1133 }
1134 );
1135 assert_eq!(
1136 PageSize::Custom {
1137 width: 1.0,
1138 height: 2.0
1139 }
1140 .landscape(),
1141 PageSize::Custom {
1142 width: 2.0,
1143 height: 1.0
1144 }
1145 );
1146 assert_eq!(PageSize::Letter.landscape().dimensions(), (792.0, 612.0));
1147 }
1148
1149 #[test]
1150 fn date_utc_formats_with_z() {
1151 let date = Date {
1152 year: 2026,
1153 month: 8,
1154 day: 27,
1155 hour: 12,
1156 minute: 30,
1157 second: 15,
1158 utc_offset_minutes: 0,
1159 };
1160 assert_eq!(date.to_pdf_string(), "D:20260827123015Z");
1161 }
1162
1163 #[test]
1164 fn date_positive_offset_pads_single_digits() {
1165 let date = Date {
1166 year: 987,
1167 month: 1,
1168 day: 2,
1169 hour: 3,
1170 minute: 4,
1171 second: 5,
1172 utc_offset_minutes: 120,
1173 };
1174 assert_eq!(date.to_pdf_string(), "D:09870102030405+02'00");
1175 }
1176
1177 #[test]
1178 fn date_negative_offset_keeps_minutes() {
1179 let date = Date {
1180 year: 1999,
1181 month: 12,
1182 day: 31,
1183 hour: 23,
1184 minute: 59,
1185 second: 58,
1186 utc_offset_minutes: -330,
1187 };
1188 assert_eq!(date.to_pdf_string(), "D:19991231235958-05'30");
1189 }
1190
1191 #[test]
1192 fn iso8601_utc_formats_with_z() {
1193 let date = Date {
1194 year: 2026,
1195 month: 8,
1196 day: 27,
1197 hour: 12,
1198 minute: 30,
1199 second: 15,
1200 utc_offset_minutes: 0,
1201 };
1202 assert_eq!(date.to_iso8601(), "2026-08-27T12:30:15Z");
1203 }
1204
1205 #[test]
1206 fn iso8601_positive_offset_pads_single_digits() {
1207 let date = Date {
1208 year: 987,
1209 month: 1,
1210 day: 2,
1211 hour: 3,
1212 minute: 4,
1213 second: 5,
1214 utc_offset_minutes: 120,
1215 };
1216 assert_eq!(date.to_iso8601(), "0987-01-02T03:04:05+02:00");
1217 }
1218
1219 #[test]
1220 fn iso8601_negative_offset_keeps_minutes() {
1221 let date = Date {
1222 year: 1999,
1223 month: 12,
1224 day: 31,
1225 hour: 23,
1226 minute: 59,
1227 second: 58,
1228 utc_offset_minutes: -330,
1229 };
1230 assert_eq!(date.to_iso8601(), "1999-12-31T23:59:58-05:30");
1231 }
1232
1233 #[test]
1234 fn parse_pdf_full() {
1235 assert!(Date::parse_pdf("D:20260901120000+02'00").is_some());
1236 }
1237
1238 #[test]
1239 fn parse_pdf_date_only_defaults_time() {
1240 let date = Date::parse_pdf("D:20260901").expect("a date-only string parses");
1241 assert_eq!(date.year, 2026);
1242 assert_eq!(date.month, 9);
1243 assert_eq!(date.day, 1);
1244 assert_eq!(date.hour, 0);
1245 assert_eq!(date.minute, 0);
1246 assert_eq!(date.second, 0);
1247 assert_eq!(date.utc_offset_minutes, 0);
1248 }
1249
1250 #[test]
1251 fn parse_pdf_rejects_garbage() {
1252 assert!(Date::parse_pdf("yesterday").is_none());
1253 }
1254
1255 #[test]
1256 fn parse_roundtrips_to_pdf_string() {
1257 let date = Date {
1258 year: 2026,
1259 month: 8,
1260 day: 27,
1261 hour: 12,
1262 minute: 30,
1263 second: 15,
1264 utc_offset_minutes: -330,
1265 };
1266 assert_eq!(Date::parse_pdf(&date.to_pdf_string()), Some(date));
1267 }
1268
1269 fn two_page_doc() -> Pdf {
1272 let mut first = Page::new(PageSize::A4);
1273 first
1274 .canvas
1275 .text("Streamed parity", 72.0, 720.0, Standard14::Helvetica, 14.0)
1276 .expect("ASCII encodes");
1277 let image = crate::image::ImageData::gray8(2, 2, vec![0, 85, 170, 255])
1278 .expect("2x2 grayscale builds");
1279 let handle = first.canvas.add_image(image);
1280 first.canvas.draw_image(handle, 72.0, 400.0, 144.0, 144.0);
1281 let mut second = Page::new(PageSize::Letter);
1282 second
1283 .canvas
1284 .text("Page two", 72.0, 700.0, Standard14::TimesRoman, 12.0)
1285 .expect("ASCII encodes");
1286 Pdf {
1287 pages: vec![first, second],
1288 ..Pdf::default()
1289 }
1290 }
1291
1292 #[test]
1296 fn write_into_and_write_into_with_match_to_bytes() {
1297 let bytes = two_page_doc().to_bytes().expect("to_bytes succeeds");
1298 let mut via_io = Vec::new();
1299 two_page_doc()
1300 .write_into(&mut via_io)
1301 .expect("write_into succeeds");
1302 assert_eq!(via_io, bytes);
1303 let via_sink = pdfboss_core::block_on(two_page_doc().write_into_with(Vec::new()))
1304 .expect("write_into_with succeeds");
1305 assert_eq!(via_sink, bytes);
1306 }
1307
1308 #[test]
1309 fn zero_page_document_is_an_error() {
1310 let err = Pdf::default()
1311 .to_bytes()
1312 .expect_err("a page-less document must not serialize");
1313 assert!(err.to_string().contains("at least one page"), "{err}");
1314 }
1315}