1use std::collections::HashSet;
8
9use crate::pdf::document::types::{PdfDocument, PdfInfo, PdfPage};
10use fop_types::Length;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum BuiltinFont {
17 Helvetica,
19 HelveticaBold,
21 HelveticaOblique,
23 HelveticaBoldOblique,
25 TimesRoman,
27 TimesBold,
29 TimesItalic,
31 TimesBoldItalic,
33 Courier,
35 CourierBold,
37 CourierOblique,
39 CourierBoldOblique,
41 Symbol,
43 ZapfDingbats,
45}
46
47impl BuiltinFont {
48 fn resource_name(self) -> &'static str {
50 match self {
51 Self::Helvetica => "F1",
52 Self::HelveticaBold => "F2",
53 Self::HelveticaOblique => "F3",
54 Self::HelveticaBoldOblique => "F4",
55 Self::TimesRoman => "F5",
56 Self::TimesBold => "F6",
57 Self::TimesItalic => "F7",
58 Self::TimesBoldItalic => "F8",
59 Self::Courier => "F9",
60 Self::CourierBold => "F10",
61 Self::CourierOblique => "F11",
62 Self::CourierBoldOblique => "F12",
63 Self::Symbol => "F13",
64 Self::ZapfDingbats => "F14",
65 }
66 }
67
68 fn base_font_name(self) -> &'static str {
70 match self {
71 Self::Helvetica => "Helvetica",
72 Self::HelveticaBold => "Helvetica-Bold",
73 Self::HelveticaOblique => "Helvetica-Oblique",
74 Self::HelveticaBoldOblique => "Helvetica-BoldOblique",
75 Self::TimesRoman => "Times-Roman",
76 Self::TimesBold => "Times-Bold",
77 Self::TimesItalic => "Times-Italic",
78 Self::TimesBoldItalic => "Times-BoldItalic",
79 Self::Courier => "Courier",
80 Self::CourierBold => "Courier-Bold",
81 Self::CourierOblique => "Courier-Oblique",
82 Self::CourierBoldOblique => "Courier-BoldOblique",
83 Self::Symbol => "Symbol",
84 Self::ZapfDingbats => "ZapfDingbats",
85 }
86 }
87
88 fn all() -> &'static [BuiltinFont] {
90 &[
91 Self::Helvetica,
92 Self::HelveticaBold,
93 Self::HelveticaOblique,
94 Self::HelveticaBoldOblique,
95 Self::TimesRoman,
96 Self::TimesBold,
97 Self::TimesItalic,
98 Self::TimesBoldItalic,
99 Self::Courier,
100 Self::CourierBold,
101 Self::CourierOblique,
102 Self::CourierBoldOblique,
103 Self::Symbol,
104 Self::ZapfDingbats,
105 ]
106 }
107}
108
109#[inline]
111fn mm_to_pt(mm: f32) -> f32 {
112 mm * 72.0 / 25.4
113}
114
115fn escape_pdf_string(s: &str) -> String {
119 let mut out = String::with_capacity(s.len());
120 for ch in s.chars() {
121 match ch {
122 '(' => out.push_str("\\("),
123 ')' => out.push_str("\\)"),
124 '\\' => out.push_str("\\\\"),
125 '\r' => out.push_str("\\r"),
126 '\n' => out.push_str("\\n"),
127 '\t' => out.push_str("\\t"),
128 c if c.is_ascii() => out.push(c),
129 c => {
131 let mut buf = [0u8; 4];
132 let encoded = c.encode_utf8(&mut buf);
133 for byte in encoded.bytes() {
134 out.push_str(&format!("\\{:03o}", byte));
135 }
136 }
137 }
138 }
139 out
140}
141
142struct PageState {
144 content: Vec<u8>,
145}
146
147impl PageState {
148 fn new() -> Self {
149 Self {
150 content: Vec::new(),
151 }
152 }
153
154 fn add_text(&mut self, text: &str, size_pt: f32, x_pt: f32, y_pt: f32, font: BuiltinFont) {
156 let escaped = escape_pdf_string(text);
157 let op = format!(
158 "BT\n/{} {} Tf\n{} {} Td\n({}) Tj\nET\n",
159 font.resource_name(),
160 size_pt,
161 x_pt,
162 y_pt,
163 escaped,
164 );
165 self.content.extend_from_slice(op.as_bytes());
166 }
167}
168
169pub struct SimpleDocumentBuilder {
184 title: String,
185 author: Option<String>,
186 subject: Option<String>,
187 creation_date: Option<String>,
188 lang: Option<String>,
189 xmp_metadata: Option<String>,
190 completed_pages: Vec<PageState>,
192 current_page: PageState,
194 used_fonts: HashSet<BuiltinFont>,
196}
197
198impl SimpleDocumentBuilder {
199 pub fn new(title: impl Into<String>) -> Self {
201 Self {
202 title: title.into(),
203 author: None,
204 subject: None,
205 creation_date: None,
206 lang: None,
207 xmp_metadata: None,
208 completed_pages: Vec::new(),
209 current_page: PageState::new(),
210 used_fonts: HashSet::new(),
211 }
212 }
213
214 pub fn set_author(&mut self, s: impl Into<String>) -> &mut Self {
216 self.author = Some(s.into());
217 self
218 }
219
220 pub fn set_subject(&mut self, s: impl Into<String>) -> &mut Self {
222 self.subject = Some(s.into());
223 self
224 }
225
226 pub fn set_creation_date(&mut self, s: impl Into<String>) -> &mut Self {
228 self.creation_date = Some(s.into());
229 self
230 }
231
232 pub fn set_lang(&mut self, s: impl Into<String>) -> &mut Self {
234 self.lang = Some(s.into());
235 self
236 }
237
238 pub fn set_xmp_metadata(&mut self, s: impl Into<String>) -> &mut Self {
244 self.xmp_metadata = Some(s.into());
245 self
246 }
247
248 pub fn text(&mut self, text: &str, size_pt: f32, x_mm: f32, y_mm: f32, font: BuiltinFont) {
253 self.used_fonts.insert(font);
254 let x_pt = mm_to_pt(x_mm);
255 let y_pt = mm_to_pt(y_mm);
256 self.current_page.add_text(text, size_pt, x_pt, y_pt, font);
257 }
258
259 pub fn new_page(&mut self) {
264 let finished = std::mem::replace(&mut self.current_page, PageState::new());
265 self.completed_pages.push(finished);
266 }
267
268 pub fn save(mut self) -> Vec<u8> {
274 self.completed_pages.push(self.current_page);
276
277 let SimpleDocumentBuilder {
279 title,
280 author,
281 subject,
282 creation_date,
283 lang,
284 xmp_metadata,
285 completed_pages,
286 current_page: _,
287 used_fonts,
288 } = self;
289
290 let mut doc = PdfDocument::new();
294
295 if !title.is_empty() {
297 doc.info.title = Some(title.clone());
298 }
299 if let Some(ref a) = author {
300 doc.info.author = Some(a.clone());
301 }
302 if let Some(ref s) = subject {
303 doc.info.subject = Some(s.clone());
304 }
305 if let Some(ref d) = creation_date {
306 doc.info.creation_date = Some(d.clone());
307 }
308 if let Some(ref l) = lang {
309 doc.info.lang = Some(l.clone());
310 }
311
312 if let Some(ref packet) = xmp_metadata {
314 merge_dc_into_info(packet, &mut doc.info);
315 doc.set_xmp_metadata(packet.clone());
316 }
317
318 let has_any_metadata = doc.info.title.is_some()
320 || doc.info.author.is_some()
321 || doc.info.subject.is_some()
322 || doc.info.creation_date.is_some()
323 || doc.info.lang.is_some()
324 || xmp_metadata.is_some();
325 if has_any_metadata {
326 let page_count = completed_pages.len();
327 let seed = compute_file_id_seed(&title, page_count, xmp_metadata.as_deref());
328 doc.file_id = Some(crate::pdf::security::generate_file_id(&seed));
329 }
330
331 for page_state in completed_pages {
332 let mut pdf_page = PdfPage::new(Length::from_mm(210.0), Length::from_mm(297.0));
333 pdf_page.content.extend_from_slice(&page_state.content);
335 doc.add_page(pdf_page);
336 }
337
338 let needs_extra_fonts = used_fonts.iter().any(|f| *f != BuiltinFont::Helvetica);
349
350 if needs_extra_fonts {
351 write_minimal_pdf(doc)
354 } else {
355 match doc.to_bytes() {
357 Ok(bytes) => bytes,
358 Err(_) => write_minimal_pdf_fallback(),
359 }
360 }
361 }
362
363 pub fn page_height_mm(&self) -> f32 {
365 297.0
366 }
367}
368
369fn simple_djb2_hash(bytes: &[u8]) -> u64 {
373 let mut h: u64 = 5381;
374 for &b in bytes {
375 h = h.wrapping_mul(33).wrapping_add(b as u64);
376 }
377 h
378}
379
380fn compute_file_id_seed(title: &str, page_count: usize, xmp: Option<&str>) -> String {
382 let xmp_len = xmp.map(|x| x.len()).unwrap_or(0);
383 let xmp_hash = xmp.map(|x| simple_djb2_hash(x.as_bytes())).unwrap_or(0);
384 format!(
385 "{}|pages={}|xmp_len={}|xmp_hash={:x}",
386 title, page_count, xmp_len, xmp_hash
387 )
388}
389
390fn merge_dc_into_info(xmp: &str, info: &mut PdfInfo) {
394 let dc = crate::pdf::compliance::extract_dc_fields(xmp);
395 if info.title.is_none() {
396 info.title = dc.title;
397 }
398 if info.author.is_none() {
399 info.author = dc.creator;
400 }
401 if info.subject.is_none() {
402 info.subject = dc.description;
403 }
404}
405
406fn emit_xmp_metadata_object(
408 buf: &mut Vec<u8>,
409 xref_offsets: &mut Vec<usize>,
410 obj_id: usize,
411 packet: &str,
412) {
413 let xmp_content = crate::pdf::compliance::reconcile_xmp(
414 packet,
415 crate::pdf::compliance::PdfCompliance::Standard,
416 );
417 let xmp_bytes = xmp_content.as_bytes();
418 xref_offsets.push(buf.len());
419 buf.extend_from_slice(format!("{} 0 obj\n", obj_id).as_bytes());
420 buf.extend_from_slice(b"<<\n/Type /Metadata\n/Subtype /XML\n");
421 buf.extend_from_slice(format!("/Length {}\n", xmp_bytes.len()).as_bytes());
422 buf.extend_from_slice(b">>\nstream\n");
423 buf.extend_from_slice(xmp_bytes);
424 buf.extend_from_slice(b"\nendstream\nendobj\n");
425}
426
427fn write_minimal_pdf(doc: PdfDocument) -> Vec<u8> {
447 let mut bytes: Vec<u8> = Vec::new();
448 let mut xref_offsets: Vec<usize> = Vec::new();
449
450 bytes.extend_from_slice(b"%PDF-1.4\n");
452 bytes.extend_from_slice(b"%\xE2\xE3\xCF\xD3\n"); xref_offsets.push(0);
456
457 let all_fonts = BuiltinFont::all();
458 let num_fonts = all_fonts.len(); let xmp_obj_id_opt: Option<usize> = if doc.xmp_metadata.is_some() {
462 Some(3 + num_fonts) } else {
464 None
465 };
466
467 let first_page_obj_id = match xmp_obj_id_opt {
469 Some(xmp_id) => xmp_id + 1, None => 3 + num_fonts, };
472
473 xref_offsets.push(bytes.len());
475 bytes.extend_from_slice(b"1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n");
476 if let Some(xmp_id) = xmp_obj_id_opt {
477 bytes.extend_from_slice(format!("/Metadata {} 0 R\n", xmp_id).as_bytes());
478 }
479 if let Some(ref l) = doc.info.lang {
480 bytes.extend_from_slice(format!("/Lang ({})\n", escape_pdf_string(l)).as_bytes());
481 }
482 bytes.extend_from_slice(b">>\nendobj\n");
483
484 xref_offsets.push(bytes.len());
486 bytes.extend_from_slice(b"2 0 obj\n<<\n/Type /Pages\n/Kids [");
487 let page_count = doc.pages.len();
488 for i in 0..page_count {
489 let page_id = first_page_obj_id + i * 2;
490 bytes.extend_from_slice(format!("{} 0 R ", page_id).as_bytes());
491 }
492 bytes.extend_from_slice(format!("]\n/Count {}\n>>\nendobj\n", page_count).as_bytes());
493
494 for (idx, font) in all_fonts.iter().enumerate() {
496 let obj_id = 3 + idx;
497 xref_offsets.push(bytes.len());
498 bytes.extend_from_slice(format!("{} 0 obj\n", obj_id).as_bytes());
499 bytes.extend_from_slice(b"<<\n/Type /Font\n/Subtype /Type1\n");
500 bytes.extend_from_slice(format!("/BaseFont /{}\n", font.base_font_name()).as_bytes());
501 bytes.extend_from_slice(b">>\nendobj\n");
502 }
503
504 if let Some(xmp_id) = xmp_obj_id_opt {
506 if let Some(ref packet) = doc.xmp_metadata {
507 emit_xmp_metadata_object(&mut bytes, &mut xref_offsets, xmp_id, packet);
508 }
509 }
510
511 let mut font_resources = String::from("/Font <<\n");
513 for (idx, font) in all_fonts.iter().enumerate() {
514 let obj_id = 3 + idx;
515 font_resources.push_str(&format!(" /{} {} 0 R\n", font.resource_name(), obj_id));
516 }
517 font_resources.push_str(">>\n");
518
519 for (page_idx, page) in doc.pages.iter().enumerate() {
521 let page_obj_id = first_page_obj_id + page_idx * 2;
522 let content_obj_id = page_obj_id + 1;
523
524 xref_offsets.push(bytes.len());
526 bytes.extend_from_slice(format!("{} 0 obj\n", page_obj_id).as_bytes());
527 bytes.extend_from_slice(b"<<\n/Type /Page\n/Parent 2 0 R\n");
528 bytes.extend_from_slice(
529 format!(
530 "/MediaBox [0 0 {} {}]\n",
531 page.width.to_pt(),
532 page.height.to_pt()
533 )
534 .as_bytes(),
535 );
536 bytes.extend_from_slice(b"/Resources <<\n");
537 bytes.extend_from_slice(font_resources.as_bytes());
538 bytes.extend_from_slice(b">>\n");
539 bytes.extend_from_slice(format!("/Contents {} 0 R\n", content_obj_id).as_bytes());
540 bytes.extend_from_slice(b">>\nendobj\n");
541
542 xref_offsets.push(bytes.len());
544 bytes.extend_from_slice(format!("{} 0 obj\n", content_obj_id).as_bytes());
545 bytes.extend_from_slice(
546 format!("<<\n/Length {}\n>>\nstream\n", page.content.len()).as_bytes(),
547 );
548 bytes.extend_from_slice(&page.content);
549 bytes.extend_from_slice(b"\nendstream\nendobj\n");
550 }
551
552 let has_title = doc
554 .info
555 .title
556 .as_ref()
557 .map(|t| !t.is_empty())
558 .unwrap_or(false);
559 let has_info = has_title
560 || doc.info.author.is_some()
561 || doc.info.subject.is_some()
562 || doc.info.creation_date.is_some();
563
564 let info_obj_id = first_page_obj_id + page_count * 2;
565 if has_info {
566 xref_offsets.push(bytes.len());
567 bytes.extend_from_slice(format!("{} 0 obj\n<<\n", info_obj_id).as_bytes());
568 if let Some(ref t) = doc.info.title {
569 if !t.is_empty() {
570 bytes.extend_from_slice(format!("/Title ({})\n", escape_pdf_string(t)).as_bytes());
571 }
572 }
573 if let Some(ref a) = doc.info.author {
574 bytes.extend_from_slice(format!("/Author ({})\n", escape_pdf_string(a)).as_bytes());
575 }
576 if let Some(ref s) = doc.info.subject {
577 bytes.extend_from_slice(format!("/Subject ({})\n", escape_pdf_string(s)).as_bytes());
578 }
579 if let Some(ref d) = doc.info.creation_date {
580 bytes.extend_from_slice(
581 format!("/CreationDate ({})\n", escape_pdf_string(d)).as_bytes(),
582 );
583 }
584 bytes.extend_from_slice(b">>\nendobj\n");
585 }
586
587 let xref_offset = bytes.len();
589 bytes.extend_from_slice(b"xref\n");
590 bytes.extend_from_slice(format!("0 {}\n", xref_offsets.len()).as_bytes());
591 bytes.extend_from_slice(b"0000000000 65535 f \n");
592 for offset in xref_offsets.iter().skip(1) {
593 bytes.extend_from_slice(format!("{:010} 00000 n \n", offset).as_bytes());
594 }
595
596 bytes.extend_from_slice(b"trailer\n<<\n");
598 bytes.extend_from_slice(format!("/Size {}\n", xref_offsets.len()).as_bytes());
599 bytes.extend_from_slice(b"/Root 1 0 R\n");
600 if has_info {
601 bytes.extend_from_slice(format!("/Info {} 0 R\n", info_obj_id).as_bytes());
602 }
603 if let Some(ref fid) = doc.file_id {
605 let hex: String = fid.iter().map(|b| format!("{:02X}", b)).collect();
606 bytes.extend_from_slice(format!("/ID [<{}> <{}>]\n", hex, hex).as_bytes());
607 }
608 bytes.extend_from_slice(b">>\nstartxref\n");
609 bytes.extend_from_slice(format!("{}\n", xref_offset).as_bytes());
610 bytes.extend_from_slice(b"%%EOF\n");
611
612 bytes
613}
614
615fn write_minimal_pdf_fallback() -> Vec<u8> {
617 b"%PDF-1.4\n1 0 obj\n<<\n/Type /Catalog\n/Pages 2 0 R\n>>\nendobj\n\
6182 0 obj\n<<\n/Type /Pages\n/Kids []\n/Count 0\n>>\nendobj\n\
619xref\n0 3\n0000000000 65535 f \n0000000009 00000 n \n0000000058 00000 n \n\
620trailer\n<<\n/Size 3\n/Root 1 0 R\n>>\nstartxref\n113\n%%EOF\n"
621 .to_vec()
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 #[test]
629 fn test_simple_builder_produces_pdf_header() {
630 let builder = SimpleDocumentBuilder::new("Test");
631 let bytes = builder.save();
632 assert!(bytes.starts_with(b"%PDF-"), "output must start with %PDF-");
633 }
634
635 #[test]
636 fn test_simple_builder_contains_eof() {
637 let builder = SimpleDocumentBuilder::new("Test");
638 let bytes = builder.save();
639 let content = String::from_utf8_lossy(&bytes);
640 assert!(content.contains("%%EOF"), "output must contain %%EOF");
641 }
642
643 #[test]
644 fn test_simple_builder_text_appears_in_output() {
645 let mut builder = SimpleDocumentBuilder::new("Test");
646 builder.text("Hello World", 12.0, 20.0, 280.0, BuiltinFont::Helvetica);
647 let bytes = builder.save();
648 let content = String::from_utf8_lossy(&bytes);
649 assert!(
650 content.contains("Hello World"),
651 "text must appear in PDF bytes"
652 );
653 }
654
655 #[test]
656 fn test_simple_builder_bold_font_in_output() {
657 let mut builder = SimpleDocumentBuilder::new("Bold Test");
658 builder.text("Bold Title", 18.0, 20.0, 280.0, BuiltinFont::HelveticaBold);
659 let bytes = builder.save();
660 let content = String::from_utf8_lossy(&bytes);
661 assert!(
663 content.contains("F2"),
664 "HelveticaBold must be referenced as F2"
665 );
666 assert!(
667 content.contains("Helvetica-Bold"),
668 "Helvetica-Bold font must appear in resources"
669 );
670 }
671
672 #[test]
673 fn test_simple_builder_page_height() {
674 let builder = SimpleDocumentBuilder::new("Test");
675 assert!((builder.page_height_mm() - 297.0).abs() < f32::EPSILON);
676 }
677
678 #[test]
679 fn test_simple_builder_new_page_creates_multiple_pages() {
680 let mut builder = SimpleDocumentBuilder::new("Multi-page");
681 builder.text("Page 1", 12.0, 20.0, 280.0, BuiltinFont::Helvetica);
682 builder.new_page();
683 builder.text("Page 2", 12.0, 20.0, 280.0, BuiltinFont::Helvetica);
684 let bytes = builder.save();
685 let content = String::from_utf8_lossy(&bytes);
686 assert!(content.contains("Page 1"), "page 1 text must appear");
687 assert!(content.contains("Page 2"), "page 2 text must appear");
688 assert!(content.contains("/Count 2"), "PDF must report 2 pages");
690 }
691
692 #[test]
693 fn test_mm_to_pt_conversion() {
694 let pt = mm_to_pt(25.4);
696 assert!((pt - 72.0).abs() < 0.001);
697 }
698
699 #[test]
700 fn test_escape_pdf_string_parens() {
701 let escaped = escape_pdf_string("(hello)");
702 assert_eq!(escaped, "\\(hello\\)");
703 }
704
705 #[test]
706 fn test_escape_pdf_string_backslash() {
707 let escaped = escape_pdf_string("back\\slash");
708 assert_eq!(escaped, "back\\\\slash");
709 }
710
711 #[test]
712 fn test_simple_builder_empty_document_is_valid_pdf() {
713 let builder = SimpleDocumentBuilder::new("Empty");
714 let bytes = builder.save();
715 let content = String::from_utf8_lossy(&bytes);
717 assert!(content.contains("%PDF-"));
718 assert!(content.contains("xref"));
719 assert!(content.contains("startxref"));
720 assert!(content.contains("%%EOF"));
721 }
722
723 #[test]
726 fn test_simple_builder_xmp_emits_metadata_stream_fast_path() {
727 let mut b = SimpleDocumentBuilder::new("XMP Fast");
729 let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/></x:xmpmeta>"#;
730 b.set_xmp_metadata(xmp);
731 b.text("hello", 12.0, 100.0, 700.0, BuiltinFont::Helvetica);
732 let bytes = b.save();
733 let output = String::from_utf8_lossy(&bytes);
734 assert!(
735 output.contains("/Type /Metadata"),
736 "should have /Type /Metadata"
737 );
738 assert!(
739 output.contains("/Subtype /XML"),
740 "should have /Subtype /XML"
741 );
742 }
743
744 #[test]
745 fn test_simple_builder_xmp_emits_metadata_stream_slow_path() {
746 let mut b = SimpleDocumentBuilder::new("XMP Slow");
748 let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/></x:xmpmeta>"#;
749 b.set_xmp_metadata(xmp);
750 b.text("hello", 12.0, 100.0, 700.0, BuiltinFont::HelveticaBold);
751 let bytes = b.save();
752 let output = String::from_utf8_lossy(&bytes);
753 assert!(
754 output.contains("/Type /Metadata"),
755 "should have /Type /Metadata"
756 );
757 assert!(
758 output.contains("/Subtype /XML"),
759 "should have /Subtype /XML"
760 );
761 }
762
763 #[test]
764 fn test_simple_builder_xmp_syncs_dc_creator_to_author() {
765 let mut b = SimpleDocumentBuilder::new("DC Test");
766 let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:RDF><rdf:Description rdf:about=""><dc:creator><rdf:Bag><rdf:li>Alice</rdf:li></rdf:Bag></dc:creator></rdf:Description></rdf:RDF></x:xmpmeta>"#;
767 b.set_xmp_metadata(xmp);
768 b.text("x", 12.0, 100.0, 700.0, BuiltinFont::HelveticaBold); let bytes = b.save();
770 let output = String::from_utf8_lossy(&bytes);
771 assert!(
772 output.contains("/Author"),
773 "should have /Author from DC creator"
774 );
775 assert!(
776 output.contains("Alice"),
777 "should contain Alice from dc:creator"
778 );
779 }
780
781 #[test]
782 fn test_simple_builder_caller_author_wins_over_dc() {
783 let mut b = SimpleDocumentBuilder::new("Priority Test");
784 let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"><rdf:RDF><rdf:Description rdf:about=""><dc:creator><rdf:Bag><rdf:li>Alice</rdf:li></rdf:Bag></dc:creator></rdf:Description></rdf:RDF></x:xmpmeta>"#;
785 b.set_xmp_metadata(xmp);
786 b.set_author("Bob");
787 b.text("x", 12.0, 100.0, 700.0, BuiltinFont::HelveticaBold); let bytes = b.save();
789 let output = String::from_utf8_lossy(&bytes);
790 assert!(output.contains("Bob"), "Bob should be in output");
791 let info_author_bob = output.contains("/Author (Bob)");
795 let info_author_alice = output.contains("/Author (Alice)");
796 assert!(info_author_bob, "Bob should be /Author value");
797 assert!(
798 !info_author_alice,
799 "Alice from DC should not be /Author (Bob wins)"
800 );
801 }
802
803 #[test]
804 fn test_simple_builder_emits_id_trailer_when_metadata_present() {
805 let mut b = SimpleDocumentBuilder::new("ID Test");
806 b.set_author("Someone");
807 b.text("x", 12.0, 100.0, 700.0, BuiltinFont::HelveticaBold); let bytes = b.save();
809 let output = String::from_utf8_lossy(&bytes);
810 let trailer_pos = output.rfind("trailer").expect("should have trailer");
812 let trailer_section = &output[trailer_pos..];
813 assert!(
814 trailer_section.contains("/ID [<"),
815 "trailer should contain /ID [<..."
816 );
817 }
818
819 #[test]
820 fn test_simple_builder_lang_in_catalog() {
821 let mut b = SimpleDocumentBuilder::new("Lang Test");
822 b.set_lang("en-US");
823 b.text("x", 12.0, 100.0, 700.0, BuiltinFont::HelveticaBold); let bytes = b.save();
825 let output = String::from_utf8_lossy(&bytes);
826 assert!(output.contains("/Lang"), "should have /Lang in catalog");
827 assert!(output.contains("en-US"), "should contain en-US");
828 }
829
830 #[test]
831 fn test_simple_builder_escapes_parens_in_info() {
832 let mut b = SimpleDocumentBuilder::new("Paren Test");
833 b.set_author("(parenthesised)");
834 b.text("x", 12.0, 100.0, 700.0, BuiltinFont::HelveticaBold); let bytes = b.save();
836 let output = String::from_utf8_lossy(&bytes);
837 assert!(
839 output.contains(r"\(parenthesised\)"),
840 "parentheses in /Author must be escaped; output snippet: {:?}",
841 &output[output.find("/Author").unwrap_or(0)
842 ..std::cmp::min(output.len(), output.find("/Author").unwrap_or(0) + 60)]
843 );
844 }
845}