1use crate::writer::{fmt_num, PdfWriter, Ref};
2
3const PRODUCER: &str = concat!("lightweight-pdf ", env!("CARGO_PKG_VERSION"));
7
8pub struct CidFont {
14 pub base_font: String,
15 pub subset_bytes: Vec<u8>,
17 pub widths: Vec<f32>,
20 pub ascent: f32,
21 pub descent: f32,
22 pub cap_height: f32,
23 pub italic_angle: f32,
24 pub bbox: (f32, f32, f32, f32),
25 pub is_italic: bool,
26 pub is_bold: bool,
27 pub to_unicode: Vec<(u16, char)>,
30}
31
32#[derive(Clone, Copy, PartialEq, Eq, Debug)]
33pub enum ColorSpace {
34 DeviceGray,
35 DeviceRgb,
36}
37
38impl ColorSpace {
39 fn as_pdf_name(self) -> &'static str {
40 match self {
41 ColorSpace::DeviceGray => "DeviceGray",
42 ColorSpace::DeviceRgb => "DeviceRGB",
43 }
44 }
45}
46
47#[derive(Clone, Copy, PartialEq, Eq, Debug)]
48pub enum ImageDataFilter {
49 None,
52 DctDecode,
55}
56
57pub struct ImageXObject {
66 pub width_px: u32,
67 pub height_px: u32,
68 pub color_space: ColorSpace,
69 pub bits_per_component: u8,
70 pub filter: ImageDataFilter,
71 pub bytes: Vec<u8>,
72 pub smask: Option<Box<ImageXObject>>,
73}
74
75#[derive(Clone, Debug)]
76pub enum PdfLinkAction {
77 Uri(String),
79 GoTo { page_index: usize, y: f32 },
84}
85
86#[derive(Clone, Debug)]
87pub struct PdfLinkAnnotation {
88 pub rect: (f32, f32, f32, f32),
89 pub action: PdfLinkAction,
90}
91
92#[derive(Default)]
93pub struct PdfPage {
94 pub width: f32,
95 pub height: f32,
96 pub content: Vec<u8>,
97 pub annotations: Vec<PdfLinkAnnotation>,
98}
99
100#[derive(Clone, Debug)]
104pub struct PdfOutlineNode {
105 pub title: String,
106 pub page_index: usize,
107 pub y: f32,
108 pub children: Vec<PdfOutlineNode>,
109}
110
111#[derive(Clone, Debug, Default)]
112pub struct PdfMetadata {
113 pub title: Option<String>,
114 pub author: Option<String>,
115 pub subject: Option<String>,
116 pub keywords: Option<String>,
117 pub creator: Option<String>,
118 pub creation_date: Option<String>,
122 pub mod_date: Option<String>,
123 #[cfg(feature = "pdf-a")]
129 pub xmp_creation_date: Option<String>,
130 #[cfg(feature = "pdf-a")]
131 pub xmp_mod_date: Option<String>,
132}
133
134#[derive(Default)]
135pub struct PdfDocument {
136 fonts: Vec<CidFont>,
137 images: Vec<ImageXObject>,
138 pages: Vec<PdfPage>,
139 pub metadata: PdfMetadata,
140 pub outline: Vec<PdfOutlineNode>,
144 #[cfg(feature = "pdf-a")]
148 pub pdf_a3b: bool,
149 #[cfg(feature = "zugferd")]
152 pub zugferd_xml: Option<Vec<u8>>,
153 pub lang: Option<String>,
157 #[cfg(feature = "tagged-pdf")]
162 pub pdf_ua: bool,
163 #[cfg(feature = "tagged-pdf")]
168 pub struct_tree: Option<crate::struct_tree::PdfStructNode>,
169}
170
171impl PdfDocument {
172 pub fn new() -> Self {
173 Self::default()
174 }
175
176 pub fn add_font(&mut self, font: CidFont) -> usize {
180 self.fonts.push(font);
181 self.fonts.len() - 1
182 }
183
184 pub fn font_resource_name(index: usize) -> String {
185 format!("F{}", index + 1)
186 }
187
188 pub fn add_image(&mut self, image: ImageXObject) -> usize {
191 self.images.push(image);
192 self.images.len() - 1
193 }
194
195 pub fn image_resource_name(index: usize) -> String {
196 format!("Im{}", index + 1)
197 }
198
199 pub fn add_page(&mut self, page: PdfPage) {
200 self.pages.push(page);
201 }
202
203 #[cfg(feature = "pdf-a")]
207 fn is_pdf_a3b(&self) -> bool {
208 self.pdf_a3b
209 }
210
211 #[cfg(not(feature = "pdf-a"))]
212 fn is_pdf_a3b(&self) -> bool {
213 false
214 }
215
216 fn descriptor_flags(font: &CidFont) -> u32 {
219 let mut flags = 32u32;
220 if font.is_italic {
221 flags |= 64;
222 }
223 flags
224 }
225
226 fn to_unicode_cmap(font: &CidFont) -> Vec<u8> {
231 let mut body = String::new();
232 body.push_str("/CIDInit /ProcSet findresource begin\n");
233 body.push_str("12 dict begin\n");
234 body.push_str("begincmap\n");
235 body.push_str("/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n");
236 body.push_str("/CMapName /Adobe-Identity-UCS def\n");
237 body.push_str("/CMapType 2 def\n");
238 body.push_str("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n");
239 for chunk in font.to_unicode.chunks(100) {
240 body.push_str(&format!("{} beginbfchar\n", chunk.len()));
241 for &(cid, ch) in chunk {
242 let utf16: Vec<u16> = ch.encode_utf16(&mut [0u16; 2]).to_vec();
243 let hex: String = utf16.iter().map(|u| format!("{u:04X}")).collect();
244 body.push_str(&format!("<{cid:04X}> <{hex}>\n"));
245 }
246 body.push_str("endbfchar\n");
247 }
248 body.push_str("endcmap\n");
249 body.push_str("CMapType findresource /CMap defineresource pop\n");
250 body.push_str("end\n");
251 body.push_str("end");
252 body.into_bytes()
253 }
254
255 #[cfg(feature = "pdf-a")]
262 const SRGB_ICC_PROFILE: &[u8] = include_bytes!("../assets/sRGB2014.icc");
263
264 #[cfg(feature = "pdf-a")]
268 fn write_output_intent(w: &mut PdfWriter) -> Ref {
269 let profile_ref = w.alloc();
270 w.compressed_stream(profile_ref, "/N 3", Self::SRGB_ICC_PROFILE);
271 let intent_ref = w.alloc();
272 w.object(
273 intent_ref,
274 &format!(
275 "<< /Type /OutputIntent /S /GTS_PDFA1 /OutputConditionIdentifier (sRGB IEC61966-2.1) /Info (sRGB IEC61966-2.1) /DestOutputProfile {} >>",
276 profile_ref.write()
277 ),
278 );
279 intent_ref
280 }
281
282 #[cfg(feature = "pdf-a")]
287 fn write_xmp_metadata(w: &mut PdfWriter, metadata: &PdfMetadata, zugferd: bool, pdf_ua: bool) -> Ref {
288 let xmp = build_xmp_packet(metadata, zugferd, pdf_ua);
289 let id = w.alloc();
290 w.stream(id, "/Type /Metadata /Subtype /XML", xmp.as_bytes());
291 id
292 }
293
294 #[cfg(feature = "zugferd")]
298 fn is_zugferd(&self) -> bool {
299 self.zugferd_xml.is_some()
300 }
301
302 #[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
303 fn is_zugferd(&self) -> bool {
304 false
305 }
306
307 #[cfg(feature = "tagged-pdf")]
310 fn is_pdf_ua(&self) -> bool {
311 self.pdf_ua
312 }
313
314 #[cfg(not(feature = "tagged-pdf"))]
315 fn is_pdf_ua(&self) -> bool {
316 false
317 }
318
319 #[cfg(feature = "zugferd")]
328 fn write_zugferd_attachment(w: &mut PdfWriter, xml: &[u8]) -> Ref {
329 const FILENAME: &str = "factur-x.xml";
330 let file_ref = w.alloc();
331 w.compressed_stream(file_ref, "/Type /EmbeddedFile /Subtype /text#2Fxml", xml);
332 let filespec_ref = w.alloc();
333 let name = format_pdf_string(FILENAME);
334 w.object(
335 filespec_ref,
336 &format!(
337 "<< /Type /Filespec /F {name} /UF {name} /AFRelationship /Alternative /EF << /F {file} /UF {file} >> >>",
338 file = file_ref.write(),
339 ),
340 );
341 filespec_ref
342 }
343
344 #[cfg(feature = "zugferd")]
350 fn write_zugferd_catalog_entry(&self, w: &mut PdfWriter) -> String {
351 match self.zugferd_xml.as_deref() {
352 Some(xml) => {
353 let filespec_ref = Self::write_zugferd_attachment(w, xml);
354 format!(
355 " /AF [{fs}] /Names << /EmbeddedFiles << /Names [(factur-x.xml) {fs}] >> >>",
356 fs = filespec_ref.write()
357 )
358 }
359 None => String::new(),
360 }
361 }
362
363 #[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
364 fn write_zugferd_catalog_entry(&self, _w: &mut PdfWriter) -> String {
365 String::new()
366 }
367
368 fn write_image(w: &mut PdfWriter, image: &ImageXObject) -> Ref {
371 let smask_ref = image.smask.as_deref().map(|m| Self::write_image(w, m));
372 let image_ref = w.alloc();
373 let filter = match image.filter {
374 ImageDataFilter::None => String::new(),
375 ImageDataFilter::DctDecode => " /Filter /DCTDecode".to_string(),
376 };
377 let smask_entry = match smask_ref {
380 Some(r) => format!(" /SMask {}", r.write()),
381 None => String::new(),
382 };
383 let dict = format!(
384 "/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /{cs} /BitsPerComponent {bpc}{filter}{smask}",
385 w = image.width_px,
386 h = image.height_px,
387 cs = image.color_space.as_pdf_name(),
388 bpc = image.bits_per_component,
389 filter = filter,
390 smask = smask_entry,
391 );
392 match image.filter {
396 ImageDataFilter::None => w.compressed_stream(image_ref, &dict, &image.bytes),
397 ImageDataFilter::DctDecode => w.stream(image_ref, &dict, &image.bytes),
398 }
399 image_ref
400 }
401
402 fn join_with_space<T>(items: &[T], f: impl Fn(&T) -> String) -> String {
406 items.iter().map(f).collect::<Vec<_>>().join(" ")
407 }
408
409 fn resource_entries(refs: &[Ref], name_fn: impl Fn(usize) -> String) -> String {
413 refs.iter()
414 .enumerate()
415 .map(|(i, r)| format!("/{} {}", name_fn(i), r.write()))
416 .collect::<Vec<_>>()
417 .join(" ")
418 }
419
420 fn write_fonts(w: &mut PdfWriter, fonts: &[CidFont]) -> String {
426 let font_refs: Vec<(Ref, Ref, Ref, Ref)> = fonts.iter().map(|_| (w.alloc(), w.alloc(), w.alloc(), w.alloc())).collect();
427
428 for (font, &(type0_ref, cid_ref, descriptor_ref, file_ref)) in fonts.iter().zip(&font_refs) {
429 let to_unicode_ref = w.alloc();
430
431 let widths_str = Self::join_with_space(&font.widths, |w| fmt_num(*w));
432
433 w.object(
434 type0_ref,
435 &format!(
436 "<< /Type /Font /Subtype /Type0 /BaseFont /{base} /Encoding /Identity-H /DescendantFonts [{cid}] /ToUnicode {tu} >>",
437 base = font.base_font,
438 cid = cid_ref.write(),
439 tu = to_unicode_ref.write(),
440 ),
441 );
442 w.object(
443 cid_ref,
444 &format!(
445 "<< /Type /Font /Subtype /CIDFontType2 /BaseFont /{base} /CIDSystemInfo << /Registry (Adobe) /Ordering (Identity) /Supplement 0 >> /FontDescriptor {desc} /DW 1000 /W [0 [{widths}]] /CIDToGIDMap /Identity >>",
446 base = font.base_font,
447 desc = descriptor_ref.write(),
448 widths = widths_str,
449 ),
450 );
451 w.object(
452 descriptor_ref,
453 &format!(
454 "<< /Type /FontDescriptor /FontName /{base} /Flags {flags} /FontBBox [{bx0} {by0} {bx1} {by1}] /ItalicAngle {italic} /Ascent {ascent} /Descent {descent} /CapHeight {cap} /StemV {stemv} /FontFile2 {file} >>",
455 base = font.base_font,
456 flags = Self::descriptor_flags(font),
457 bx0 = fmt_num(font.bbox.0),
458 by0 = fmt_num(font.bbox.1),
459 bx1 = fmt_num(font.bbox.2),
460 by1 = fmt_num(font.bbox.3),
461 italic = fmt_num(font.italic_angle),
462 ascent = fmt_num(font.ascent),
463 descent = fmt_num(font.descent),
464 cap = fmt_num(font.cap_height),
465 stemv = if font.is_bold { 120 } else { 80 },
466 file = file_ref.write(),
467 ),
468 );
469 w.compressed_stream(file_ref, &format!("/Length1 {}", font.subset_bytes.len()), &font.subset_bytes);
470 w.compressed_stream(to_unicode_ref, "", &Self::to_unicode_cmap(font));
471 }
472
473 let type0_refs: Vec<Ref> = font_refs.iter().map(|&(t, ..)| t).collect();
474 Self::resource_entries(&type0_refs, Self::font_resource_name)
475 }
476
477 fn write_pages(
483 w: &mut PdfWriter,
484 pages: &[PdfPage],
485 pages_ref: Ref,
486 font_resources: &str,
487 image_resources: &str,
488 pdf_a3b: bool,
489 pdf_ua: bool,
490 ) -> Vec<Ref> {
491 let page_refs: Vec<Ref> = (0..pages.len()).map(|_| w.alloc()).collect();
492 let content_refs: Vec<Ref> = (0..pages.len()).map(|_| w.alloc()).collect();
493
494 for (page_index, ((page, &page_ref), &content_ref)) in pages.iter().zip(&page_refs).zip(&content_refs).enumerate() {
495 let mut annot_refs = Vec::new();
496 for annot in &page.annotations {
497 let id = w.alloc();
498 let action = match &annot.action {
499 PdfLinkAction::Uri(uri) => format!("/A << /S /URI /URI {} >>", format_pdf_string(uri)),
500 PdfLinkAction::GoTo { page_index, y } => {
501 let target = page_refs.get(*page_index).copied().unwrap_or(page_ref);
505 format!("/Dest [{} /XYZ null {} null]", target.write(), fmt_num(*y))
506 }
507 };
508 let flags_entry = if pdf_a3b { " /F 4" } else { "" };
512 w.object(
513 id,
514 &format!(
515 "<< /Type /Annot /Subtype /Link /Rect [{x0} {y0} {x1} {y1}] /Border [0 0 0]{flags} {action} >>",
516 x0 = fmt_num(annot.rect.0),
517 y0 = fmt_num(annot.rect.1),
518 x1 = fmt_num(annot.rect.2),
519 y1 = fmt_num(annot.rect.3),
520 flags = flags_entry,
521 ),
522 );
523 annot_refs.push(id);
524 }
525
526 let annots_entry = if !annot_refs.is_empty() {
527 let refs = Self::join_with_space(&annot_refs, |r| r.write());
528 format!(" /Annots [{refs}]")
529 } else {
530 String::new()
531 };
532
533 let group_entry = if pdf_a3b {
539 " /Group << /Type /Group /S /Transparency /CS /DeviceRGB >>"
540 } else {
541 ""
542 };
543
544 let struct_parents_entry = if pdf_ua {
549 format!(" /StructParents {page_index}")
550 } else {
551 String::new()
552 };
553
554 w.object(
555 page_ref,
556 &format!(
557 "<< /Type /Page /Parent {parent} /MediaBox [0 0 {w} {h}] /Resources << /Font << {fonts} >> /XObject << {images} >> >>{group}{struct_parents} /Contents {content}{annots} >>",
558 parent = pages_ref.write(),
559 w = fmt_num(page.width),
560 h = fmt_num(page.height),
561 fonts = font_resources,
562 images = image_resources,
563 group = group_entry,
564 struct_parents = struct_parents_entry,
565 content = content_ref.write(),
566 annots = annots_entry,
567 ),
568 );
569 w.compressed_stream(content_ref, "", &page.content);
570 }
571
572 page_refs
573 }
574
575 fn write_outline(w: &mut PdfWriter, outline: &[PdfOutlineNode], page_refs: &[Ref]) -> Option<Ref> {
582 if outline.is_empty() {
583 return None;
584 }
585 let outlines_ref = w.alloc();
586 let ref_tree = alloc_outline_refs(w, outline);
587 write_outline_siblings(w, outline, &ref_tree, outlines_ref, page_refs);
588
589 let total_count: i64 = outline.iter().map(|n| 1 + count_descendants(n)).sum();
590 let first = ref_tree.first().map(|t| t.r);
591 let last = ref_tree.last().map(|t| t.r);
592 let mut entries = vec!["/Type /Outlines".to_string(), format!("/Count {total_count}")];
593 if let Some(f) = first {
594 entries.push(format!("/First {}", f.write()));
595 }
596 if let Some(l) = last {
597 entries.push(format!("/Last {}", l.write()));
598 }
599 w.object(outlines_ref, &format!("<< {} >>", entries.join(" ")));
600 Some(outlines_ref)
601 }
602
603 pub fn write(&self) -> Vec<u8> {
608 let mut w = PdfWriter::new();
609
610 let catalog_ref = w.alloc();
611 let pages_ref = w.alloc();
612
613 let image_refs: Vec<Ref> = self.images.iter().map(|img| Self::write_image(&mut w, img)).collect();
614 let image_resources = Self::resource_entries(&image_refs, Self::image_resource_name);
615
616 let pdf_a3b = self.is_pdf_a3b();
617 let pdf_ua = self.is_pdf_ua();
618
619 let font_resources = Self::write_fonts(&mut w, &self.fonts);
620 let page_refs = Self::write_pages(&mut w, &self.pages, pages_ref, &font_resources, &image_resources, pdf_a3b, pdf_ua);
621
622 let kids = Self::join_with_space(&page_refs, |r| r.write());
623 w.object(pages_ref, &format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", self.pages.len()));
624
625 let outlines_entry = match Self::write_outline(&mut w, &self.outline, &page_refs) {
626 Some(outlines_ref) => format!(" /Outlines {}", outlines_ref.write()),
627 None => String::new(),
628 };
629
630 #[cfg(feature = "pdf-a")]
631 let pdf_a_entry = if pdf_a3b {
632 let output_intent_ref = Self::write_output_intent(&mut w);
633 let metadata_ref = Self::write_xmp_metadata(&mut w, &self.metadata, self.is_zugferd(), pdf_ua);
634 let zugferd_entry = self.write_zugferd_catalog_entry(&mut w);
635 format!(
636 " /OutputIntents [{}] /Metadata {}{zugferd_entry}",
637 output_intent_ref.write(),
638 metadata_ref.write()
639 )
640 } else {
641 String::new()
642 };
643 #[cfg(not(feature = "pdf-a"))]
644 let pdf_a_entry = String::new();
645
646 #[cfg(feature = "tagged-pdf")]
647 let tagged_entry = if pdf_ua {
648 use crate::struct_tree::{write_struct_tree, PdfStructNode};
649 let empty_root = PdfStructNode::Elem {
650 tag: "Document",
651 alt: None,
652 attrs: None,
653 children: Vec::new(),
654 };
655 let root = self.struct_tree.as_ref().unwrap_or(&empty_root);
656 let (struct_tree_root_ref, _struct_parents) = write_struct_tree(&mut w, root, &page_refs);
657 format!(
662 " /StructTreeRoot {} /MarkInfo << /Marked true >> /ViewerPreferences << /DisplayDocTitle true >>",
663 struct_tree_root_ref.write()
664 )
665 } else {
666 String::new()
667 };
668 #[cfg(not(feature = "tagged-pdf"))]
669 let tagged_entry = String::new();
670
671 let lang_entry = match &self.lang {
672 Some(lang) => format!(" /Lang {}", format_pdf_string(lang)),
673 None => String::new(),
674 };
675
676 w.object(
677 catalog_ref,
678 &format!(
679 "<< /Type /Catalog /Pages {}{outlines_entry}{pdf_a_entry}{tagged_entry}{lang_entry} >>",
680 pages_ref.write()
681 ),
682 );
683
684 let mut info_entries = Vec::new();
685 if let Some(ref title) = self.metadata.title {
686 info_entries.push(format!("/Title {}", format_pdf_string(title)));
687 }
688 if let Some(ref author) = self.metadata.author {
689 info_entries.push(format!("/Author {}", format_pdf_string(author)));
690 }
691 if let Some(ref subject) = self.metadata.subject {
692 info_entries.push(format!("/Subject {}", format_pdf_string(subject)));
693 }
694 if let Some(ref keywords) = self.metadata.keywords {
695 info_entries.push(format!("/Keywords {}", format_pdf_string(keywords)));
696 }
697 if let Some(ref creator) = self.metadata.creator {
698 info_entries.push(format!("/Creator {}", format_pdf_string(creator)));
699 }
700 if let Some(ref creation_date) = self.metadata.creation_date {
701 info_entries.push(format!("/CreationDate {}", format_pdf_string(creation_date)));
702 }
703 if let Some(ref mod_date) = self.metadata.mod_date {
704 info_entries.push(format!("/ModDate {}", format_pdf_string(mod_date)));
705 }
706 info_entries.push(format!("/Producer {}", format_pdf_string(PRODUCER)));
707
708 let info_ref = {
709 let id = w.alloc();
710 w.object(id, &format!("<< {} >>", info_entries.join(" ")));
711 Some(id)
712 };
713
714 w.finish(catalog_ref, info_ref)
715 }
716}
717
718struct RefTree {
722 r: Ref,
723 children: Vec<RefTree>,
724}
725
726fn alloc_outline_refs(w: &mut PdfWriter, nodes: &[PdfOutlineNode]) -> Vec<RefTree> {
727 nodes
728 .iter()
729 .map(|n| RefTree {
730 r: w.alloc(),
731 children: alloc_outline_refs(w, &n.children),
732 })
733 .collect()
734}
735
736fn count_descendants(node: &PdfOutlineNode) -> i64 {
739 node.children.len() as i64 + node.children.iter().map(count_descendants).sum::<i64>()
740}
741
742fn write_outline_siblings(w: &mut PdfWriter, nodes: &[PdfOutlineNode], ref_nodes: &[RefTree], parent_ref: Ref, page_refs: &[Ref]) {
750 for (i, (node, ref_node)) in nodes.iter().zip(ref_nodes).enumerate() {
751 let prev = (i > 0).then(|| ref_nodes[i - 1].r);
752 let next = (i + 1 < nodes.len()).then(|| ref_nodes[i + 1].r);
753 let first = ref_node.children.first().map(|c| c.r);
754 let last = ref_node.children.last().map(|c| c.r);
755 let count = count_descendants(node);
756 let target_page = page_refs.get(node.page_index).copied().unwrap_or(ref_node.r);
757
758 let mut entries = vec![
759 format!("/Title {}", format_pdf_string(&node.title)),
760 format!("/Parent {}", parent_ref.write()),
761 format!("/Dest [{} /XYZ null {} null]", target_page.write(), fmt_num(node.y)),
762 ];
763 if let Some(p) = prev {
764 entries.push(format!("/Prev {}", p.write()));
765 }
766 if let Some(n) = next {
767 entries.push(format!("/Next {}", n.write()));
768 }
769 if let Some(f) = first {
770 entries.push(format!("/First {}", f.write()));
771 }
772 if let Some(l) = last {
773 entries.push(format!("/Last {}", l.write()));
774 }
775 if count > 0 {
776 entries.push(format!("/Count {count}"));
777 }
778 w.object(ref_node.r, &format!("<< {} >>", entries.join(" ")));
779
780 write_outline_siblings(w, &node.children, &ref_node.children, ref_node.r, page_refs);
781 }
782}
783
784pub(crate) fn format_pdf_string(s: &str) -> String {
785 let escaped = s.replace('\\', "\\\\").replace('(', "\\(").replace(')', "\\)");
786 format!("({escaped})")
787}
788
789#[cfg(feature = "pdf-a")]
794fn xml_escape(s: &str) -> String {
795 s.replace('&', "&").replace('<', "<").replace('>', ">")
796}
797
798#[cfg(feature = "pdf-a")]
806fn build_xmp_packet(metadata: &PdfMetadata, zugferd: bool, pdf_ua: bool) -> String {
807 let mut props = String::new();
808 if let Some(ref title) = metadata.title {
809 props.push_str(&format!(
810 "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:title>",
811 xml_escape(title)
812 ));
813 }
814 if let Some(ref author) = metadata.author {
815 props.push_str(&format!(
816 "<dc:creator><rdf:Seq><rdf:li>{}</rdf:li></rdf:Seq></dc:creator>",
817 xml_escape(author)
818 ));
819 }
820 if let Some(ref subject) = metadata.subject {
821 props.push_str(&format!(
822 "<dc:description><rdf:Alt><rdf:li xml:lang=\"x-default\">{}</rdf:li></rdf:Alt></dc:description>",
823 xml_escape(subject)
824 ));
825 }
826 if let Some(ref keywords) = metadata.keywords {
827 props.push_str(&format!("<pdf:Keywords>{}</pdf:Keywords>", xml_escape(keywords)));
828 }
829 if let Some(ref creator) = metadata.creator {
830 props.push_str(&format!("<xmp:CreatorTool>{}</xmp:CreatorTool>", xml_escape(creator)));
831 }
832 if let Some(ref created) = metadata.xmp_creation_date {
833 props.push_str(&format!("<xmp:CreateDate>{created}</xmp:CreateDate>"));
834 }
835 if let Some(ref modified) = metadata.xmp_mod_date {
836 props.push_str(&format!("<xmp:ModifyDate>{modified}</xmp:ModifyDate>"));
837 }
838 props.push_str("<pdfaid:part>3</pdfaid:part><pdfaid:conformance>B</pdfaid:conformance>");
839 if pdf_ua {
844 props.push_str("<pdfuaid:part>1</pdfuaid:part>");
845 }
846
847 let zugferd_block = if zugferd { ZUGFERD_XMP_EXTENSION } else { "" };
848 let pdfua_block = if pdf_ua { PDFUA_XMP_EXTENSION } else { "" };
857
858 format!(
859 "<?xpacket begin=\"\u{feff}\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\
860<x:xmpmeta xmlns:x=\"adobe:ns:meta/\">\
861<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\">\
862<rdf:Description rdf:about=\"\" \
863xmlns:dc=\"http://purl.org/dc/elements/1.1/\" \
864xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\" \
865xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" \
866xmlns:pdfaid=\"http://www.aiim.org/pdfa/ns/id/\" \
867xmlns:pdfuaid=\"http://www.aiim.org/pdfua/ns/id/\">\
868{props}\
869</rdf:Description>\
870{zugferd_block}\
871{pdfua_block}\
872</rdf:RDF>\
873</x:xmpmeta>\
874<?xpacket end=\"w\"?>"
875 )
876}
877
878#[cfg(all(feature = "pdf-a", not(feature = "tagged-pdf")))]
879const PDFUA_XMP_EXTENSION: &str = "";
880
881#[cfg(feature = "tagged-pdf")]
885const PDFUA_XMP_EXTENSION: &str = "\
886<rdf:Description rdf:about=\"\" \
887xmlns:pdfaExtension=\"http://www.aiim.org/pdfa/ns/extension/\" \
888xmlns:pdfaSchema=\"http://www.aiim.org/pdfa/ns/schema#\" \
889xmlns:pdfaProperty=\"http://www.aiim.org/pdfa/ns/property#\">\
890<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType=\"Resource\">\
891<pdfaSchema:schema>PDF/UA identification schema</pdfaSchema:schema>\
892<pdfaSchema:namespaceURI>http://www.aiim.org/pdfua/ns/id/</pdfaSchema:namespaceURI>\
893<pdfaSchema:prefix>pdfuaid</pdfaSchema:prefix>\
894<pdfaSchema:property><rdf:Seq>\
895<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>part</pdfaProperty:name><pdfaProperty:valueType>Integer</pdfaProperty:valueType><pdfaProperty:category>internal</pdfaProperty:category><pdfaProperty:description>Indicates, as an integer, the part of ISO 14289 to which the file conforms</pdfaProperty:description></rdf:li>\
896</rdf:Seq></pdfaSchema:property>\
897</rdf:li></rdf:Bag></pdfaExtension:schemas>\
898</rdf:Description>";
899
900#[cfg(all(feature = "pdf-a", not(feature = "zugferd")))]
910const ZUGFERD_XMP_EXTENSION: &str = "";
911
912#[cfg(feature = "zugferd")]
913const ZUGFERD_XMP_EXTENSION: &str = "\
914<rdf:Description rdf:about=\"\" xmlns:fx=\"urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#\">\
915<fx:DocumentType>INVOICE</fx:DocumentType>\
916<fx:DocumentFileName>factur-x.xml</fx:DocumentFileName>\
917<fx:Version>1.0</fx:Version>\
918<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>\
919</rdf:Description>\
920<rdf:Description rdf:about=\"\" \
921xmlns:pdfaExtension=\"http://www.aiim.org/pdfa/ns/extension/\" \
922xmlns:pdfaSchema=\"http://www.aiim.org/pdfa/ns/schema#\" \
923xmlns:pdfaProperty=\"http://www.aiim.org/pdfa/ns/property#\">\
924<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType=\"Resource\">\
925<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>\
926<pdfaSchema:namespaceURI>urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#</pdfaSchema:namespaceURI>\
927<pdfaSchema:prefix>fx</pdfaSchema:prefix>\
928<pdfaSchema:property><rdf:Seq>\
929<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>DocumentFileName</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description></rdf:li>\
930<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>DocumentType</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>INVOICE</pdfaProperty:description></rdf:li>\
931<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>Version</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The actual version of the Factur-X XML schema</pdfaProperty:description></rdf:li>\
932<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>ConformanceLevel</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType><pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>The conformance level of the embedded Factur-X data</pdfaProperty:description></rdf:li>\
933</rdf:Seq></pdfaSchema:property>\
934</rdf:li></rdf:Bag></pdfaExtension:schemas>\
935</rdf:Description>";
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940
941 fn tiny_font() -> CidFont {
942 CidFont {
943 base_font: "Test".to_string(),
944 subset_bytes: vec![0u8; 16],
945 widths: vec![0.0, 600.0],
946 ascent: 800.0,
947 descent: -200.0,
948 cap_height: 700.0,
949 italic_angle: 0.0,
950 bbox: (-100.0, -200.0, 900.0, 900.0),
951 is_italic: false,
952 is_bold: false,
953 to_unicode: vec![(1, 'H')],
954 }
955 }
956
957 #[test]
958 fn writes_a_single_empty_page() {
959 let mut doc = PdfDocument::new();
960 doc.add_page(PdfPage {
961 width: 595.0,
962 height: 842.0,
963 content: Vec::new(),
964 annotations: Vec::new(),
965 });
966 let bytes = doc.write();
967 let text = String::from_utf8_lossy(&bytes);
968 assert!(text.contains("/Type /Page"));
969 assert!(text.contains("/MediaBox [0 0 595 842]"));
970 assert!(text.contains("%%EOF"));
971 }
972
973 #[test]
974 fn writes_a_goto_destination_for_an_internal_link_annotation() {
975 let mut doc = PdfDocument::new();
976 doc.add_page(PdfPage {
977 width: 595.0,
978 height: 842.0,
979 content: Vec::new(),
980 annotations: vec![PdfLinkAnnotation {
981 rect: (10.0, 20.0, 100.0, 40.0),
982 action: PdfLinkAction::GoTo { page_index: 1, y: 700.0 },
983 }],
984 });
985 doc.add_page(PdfPage {
986 width: 595.0,
987 height: 842.0,
988 content: Vec::new(),
989 annotations: Vec::new(),
990 });
991 let bytes = doc.write();
992 let text = String::from_utf8_lossy(&bytes);
993 assert!(text.contains("/Subtype /Link"));
994 assert!(text.contains("/Dest ["));
995 assert!(text.contains("/XYZ null 700 null"));
996 assert!(!text.contains("/S /URI"), "a GoTo annotation must not also emit a URI action");
997 }
998
999 #[test]
1000 fn writes_type0_cid_font_structure() {
1001 let mut doc = PdfDocument::new();
1002 doc.add_font(tiny_font());
1003 doc.add_page(PdfPage {
1004 width: 595.0,
1005 height: 842.0,
1006 content: Vec::new(),
1007 annotations: Vec::new(),
1008 });
1009 let bytes = doc.write();
1010 let text = String::from_utf8_lossy(&bytes);
1011 assert!(text.contains("/Subtype /Type0"));
1012 assert!(text.contains("/Encoding /Identity-H"));
1013 assert!(text.contains("/Subtype /CIDFontType2"));
1014 assert!(text.contains("/CIDToGIDMap /Identity"));
1015 assert!(text.contains("/ToUnicode"));
1016 let decoded = stream_bodies_decoded(&bytes);
1020 assert!(decoded.contains("beginbfchar"));
1021 assert!(decoded.contains("<0001> <0048>")); }
1023
1024 fn stream_bodies_decoded(bytes: &[u8]) -> String {
1030 const START: &[u8] = b"stream\n";
1031 const END: &[u8] = b"\nendstream";
1032 let mut bodies = Vec::new();
1033 let mut i = 0;
1034 while let Some(start_rel) = bytes[i..].windows(START.len()).position(|w| w == START) {
1035 let start = i + start_rel + START.len();
1036 let Some(end_rel) = bytes[start..].windows(END.len()).position(|w| w == END) else {
1037 break;
1038 };
1039 let end = start + end_rel;
1040 bodies.push(&bytes[start..end]);
1041 i = end + END.len();
1042 }
1043 bodies.into_iter().map(decode_one_stream_body).collect::<Vec<_>>().join("\n")
1044 }
1045
1046 #[cfg(feature = "compress")]
1047 fn decode_one_stream_body(body: &[u8]) -> String {
1048 match miniz_oxide::inflate::decompress_to_vec_zlib(body) {
1049 Ok(v) => String::from_utf8_lossy(&v).into_owned(),
1050 Err(_) => String::new(), }
1052 }
1053
1054 #[cfg(not(feature = "compress"))]
1055 fn decode_one_stream_body(body: &[u8]) -> String {
1056 String::from_utf8_lossy(body).into_owned()
1057 }
1058
1059 #[test]
1060 fn writes_image_xobject_with_smask() {
1061 let mut doc = PdfDocument::new();
1062 doc.add_image(ImageXObject {
1063 width_px: 4,
1064 height_px: 4,
1065 color_space: ColorSpace::DeviceRgb,
1066 bits_per_component: 8,
1067 filter: ImageDataFilter::None,
1068 bytes: vec![0u8; 4 * 4 * 3],
1069 smask: Some(Box::new(ImageXObject {
1070 width_px: 4,
1071 height_px: 4,
1072 color_space: ColorSpace::DeviceGray,
1073 bits_per_component: 8,
1074 filter: ImageDataFilter::None,
1075 bytes: vec![255u8; 4 * 4],
1076 smask: None,
1077 })),
1078 });
1079 doc.add_page(PdfPage {
1080 width: 200.0,
1081 height: 200.0,
1082 content: Vec::new(),
1083 annotations: Vec::new(),
1084 });
1085 let bytes = doc.write();
1086 let text = String::from_utf8_lossy(&bytes);
1087 assert!(text.contains("/Subtype /Image"));
1088 assert!(text.contains("/ColorSpace /DeviceRGB"));
1089 assert!(text.contains("/ColorSpace /DeviceGray"));
1090 assert!(text.contains("/SMask"));
1091 assert!(text.contains("/XObject << /Im1"));
1092 }
1093
1094 #[test]
1095 fn writes_jpeg_image_with_dct_decode_filter() {
1096 let mut doc = PdfDocument::new();
1097 doc.add_image(ImageXObject {
1098 width_px: 10,
1099 height_px: 10,
1100 color_space: ColorSpace::DeviceRgb,
1101 bits_per_component: 8,
1102 filter: ImageDataFilter::DctDecode,
1103 bytes: vec![0xFF, 0xD8, 0xFF, 0xD9], smask: None,
1105 });
1106 doc.add_page(PdfPage {
1107 width: 200.0,
1108 height: 200.0,
1109 content: Vec::new(),
1110 annotations: Vec::new(),
1111 });
1112 let bytes = doc.write();
1113 let text = String::from_utf8_lossy(&bytes);
1114 assert!(text.contains("/Filter /DCTDecode"));
1115 }
1116
1117 #[cfg(feature = "pdf-a")]
1118 #[test]
1119 fn writes_output_intent_and_xmp_metadata_when_pdf_a3b_is_set() {
1120 let mut doc = PdfDocument::new();
1121 doc.pdf_a3b = true;
1122 doc.metadata.title = Some("Rechnung".to_string());
1123 doc.add_page(PdfPage {
1124 width: 200.0,
1125 height: 200.0,
1126 content: Vec::new(),
1127 annotations: Vec::new(),
1128 });
1129 let bytes = doc.write();
1130 let text = String::from_utf8_lossy(&bytes);
1131 assert!(text.contains("/OutputIntents ["));
1132 assert!(text.contains("/S /GTS_PDFA1"));
1133 assert!(text.contains("/DestOutputProfile"));
1134 assert!(text.contains("/Type /Metadata /Subtype /XML"));
1135 assert!(text.contains("<pdfaid:part>3</pdfaid:part>"));
1136 assert!(text.contains("<pdfaid:conformance>B</pdfaid:conformance>"));
1137 assert!(text.contains("<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">Rechnung</rdf:li></rdf:Alt></dc:title>"));
1138 }
1139
1140 #[cfg(feature = "pdf-a")]
1141 #[test]
1142 fn omits_pdf_a_entries_when_pdf_a3b_is_not_set() {
1143 let mut doc = PdfDocument::new();
1144 doc.add_page(PdfPage {
1145 width: 200.0,
1146 height: 200.0,
1147 content: Vec::new(),
1148 annotations: Vec::new(),
1149 });
1150 let bytes = doc.write();
1151 let text = String::from_utf8_lossy(&bytes);
1152 assert!(!text.contains("/OutputIntents"));
1153 assert!(!text.contains("/Type /Metadata"));
1154 assert!(!text.contains("/Group"));
1155 }
1156
1157 #[cfg(feature = "zugferd")]
1158 #[test]
1159 fn embeds_zugferd_xml_with_af_and_xmp_extension() {
1160 let mut doc = PdfDocument::new();
1161 doc.pdf_a3b = true;
1162 doc.zugferd_xml = Some(b"<CrossIndustryInvoice/>".to_vec());
1163 doc.add_page(PdfPage {
1164 width: 200.0,
1165 height: 200.0,
1166 content: Vec::new(),
1167 annotations: Vec::new(),
1168 });
1169 let bytes = doc.write();
1170 let text = String::from_utf8_lossy(&bytes);
1171 assert!(text.contains("/Type /Filespec"));
1172 assert!(text.contains("/AFRelationship /Alternative"));
1173 assert!(text.contains("/Type /EmbeddedFile /Subtype /text#2Fxml"));
1174 assert!(text.contains("/AF ["));
1175 assert!(text.contains("/Names << /EmbeddedFiles"));
1176 assert!(text.contains("factur-x.xml"));
1177 assert!(text.contains("xmlns:fx=\"urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#\""));
1178 assert!(text.contains("<fx:ConformanceLevel>EN 16931</fx:ConformanceLevel>"));
1179 assert!(text.contains("pdfaSchema:namespaceURI"));
1180 }
1181
1182 #[cfg(feature = "zugferd")]
1183 #[test]
1184 fn omits_zugferd_entries_when_zugferd_xml_is_not_set() {
1185 let mut doc = PdfDocument::new();
1186 doc.pdf_a3b = true;
1187 doc.add_page(PdfPage {
1188 width: 200.0,
1189 height: 200.0,
1190 content: Vec::new(),
1191 annotations: Vec::new(),
1192 });
1193 let bytes = doc.write();
1194 let text = String::from_utf8_lossy(&bytes);
1195 assert!(!text.contains("/Type /Filespec"));
1196 assert!(!text.contains("/AF ["));
1197 assert!(!text.contains("xmlns:fx="));
1198 }
1199
1200 #[cfg(feature = "tagged-pdf")]
1201 #[test]
1202 fn writes_struct_tree_mark_info_and_lang_when_pdf_ua_is_set() {
1203 use crate::struct_tree::PdfStructNode;
1204
1205 let mut doc = PdfDocument::new();
1206 doc.pdf_a3b = true;
1207 doc.pdf_ua = true;
1208 doc.lang = Some("en-US".to_string());
1209 doc.add_page(PdfPage {
1210 width: 200.0,
1211 height: 200.0,
1212 content: Vec::new(),
1213 annotations: Vec::new(),
1214 });
1215 doc.struct_tree = Some(PdfStructNode::Elem {
1216 tag: "Document",
1217 alt: None,
1218 attrs: None,
1219 children: vec![PdfStructNode::Elem {
1220 tag: "H1",
1221 alt: None,
1222 attrs: None,
1223 children: vec![PdfStructNode::ContentRef { page_index: 0, mcid: 0 }],
1224 }],
1225 });
1226 let bytes = doc.write();
1227 let text = String::from_utf8_lossy(&bytes);
1228 assert!(text.contains("/MarkInfo << /Marked true >>"));
1229 assert!(text.contains("/Lang (en-US)"));
1230 assert!(text.contains("/Type /StructTreeRoot"));
1231 assert!(text.contains("/Type /StructElem /S /Document"));
1232 assert!(text.contains("/Type /StructElem /S /H1"));
1233 assert!(text.contains("/Type /MCR /Pg"));
1234 assert!(text.contains("/StructParents 0"));
1235 assert!(text.contains("/Nums ["));
1236 assert!(text.contains("<pdfuaid:part>1</pdfuaid:part>"));
1237 }
1238
1239 #[cfg(feature = "tagged-pdf")]
1240 #[test]
1241 fn omits_struct_tree_entries_when_pdf_ua_is_not_set() {
1242 let mut doc = PdfDocument::new();
1243 doc.add_page(PdfPage {
1244 width: 200.0,
1245 height: 200.0,
1246 content: Vec::new(),
1247 annotations: Vec::new(),
1248 });
1249 let bytes = doc.write();
1250 let text = String::from_utf8_lossy(&bytes);
1251 assert!(!text.contains("/StructTreeRoot"));
1252 assert!(!text.contains("/MarkInfo"));
1253 assert!(!text.contains("/StructParents"));
1254 }
1255}