1use std::collections::HashSet;
24
25use zpdf_core::{ObjectId, PdfDict, PdfObject};
26use zpdf_parser::PdfFile;
27
28use crate::forms::pdf_string_to_unicode;
29
30const MAX_NAME_TREE_DEPTH: usize = 64;
34
35const MAX_EMBEDDED_FILES: usize = 16_384;
38
39const MAX_AF_ENTRIES: usize = 8_192;
41
42const FILE_NAME_KEYS: [&str; 5] = ["UF", "F", "Unix", "DOS", "Mac"];
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub enum EmbeddedSource {
51 NameTree,
53 AssociatedFile,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash)]
63pub struct EmbeddedFile {
64 pub name: String,
68 pub description: Option<String>,
70 pub relationship: Option<String>,
75 pub subtype: Option<String>,
79 pub size: Option<i64>,
82 pub creation_date: Option<String>,
84 pub mod_date: Option<String>,
86 pub checksum: Option<Vec<u8>>,
89 pub stream: Option<ObjectId>,
92 pub source: EmbeddedSource,
94}
95
96impl EmbeddedFile {
97 pub fn is_embedded(&self) -> bool {
100 self.stream.is_some()
101 }
102}
103
104pub fn parse_embedded_files(file: &PdfFile) -> Vec<EmbeddedFile> {
107 let Some(root) = catalog_dict(file) else {
108 return Vec::new();
109 };
110 let Some(names) = resolve_dict(file, root.get("Names")) else {
112 return Vec::new();
113 };
114
115 let mut out = Vec::new();
116 let mut visited = HashSet::new();
117 if let Some(PdfObject::Ref(r)) = names.get("EmbeddedFiles") {
120 visited.insert(*r);
121 }
122 let Some(tree_root) = resolve_dict(file, names.get("EmbeddedFiles")) else {
123 return Vec::new();
124 };
125 walk_name_tree(file, &tree_root, &mut out, &mut visited, 0);
126 out
127}
128
129pub fn parse_associated_files(file: &PdfFile) -> Vec<EmbeddedFile> {
131 let Some(root) = catalog_dict(file) else {
132 return Vec::new();
133 };
134 collect_af_array(file, root.get("AF"))
135}
136
137pub fn parse_page_associated_files(file: &PdfFile, page_dict: &PdfDict) -> Vec<EmbeddedFile> {
141 collect_af_array(file, page_dict.get("AF"))
142}
143
144fn walk_name_tree(
148 file: &PdfFile,
149 node: &PdfDict,
150 out: &mut Vec<EmbeddedFile>,
151 visited: &mut HashSet<ObjectId>,
152 depth: usize,
153) {
154 if depth > MAX_NAME_TREE_DEPTH || out.len() >= MAX_EMBEDDED_FILES {
155 return;
156 }
157
158 if let Some(names) = resolve_array(file, node.get("Names")) {
160 let mut i = 0;
161 while i + 1 < names.len() {
162 if out.len() >= MAX_EMBEDDED_FILES {
163 return;
164 }
165 let key = match &names[i] {
166 PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
167 _ => None,
168 };
169 if let Some(ef) = parse_file_spec(file, &names[i + 1], key, EmbeddedSource::NameTree) {
170 out.push(ef);
171 }
172 i += 2;
173 }
174 }
175
176 if let Some(kids) = resolve_array(file, node.get("Kids")) {
178 for kid in &kids {
179 let kid_dict = match kid {
180 PdfObject::Ref(r) => {
181 if !visited.insert(*r) {
183 continue;
184 }
185 resolve_dict(file, Some(kid))
186 }
187 PdfObject::Dict(_) => resolve_dict(file, Some(kid)),
188 _ => None,
189 };
190 if let Some(d) = kid_dict {
191 walk_name_tree(file, &d, out, visited, depth + 1);
192 }
193 }
194 }
195}
196
197fn collect_af_array(file: &PdfFile, obj: Option<&PdfObject>) -> Vec<EmbeddedFile> {
200 let Some(arr) = resolve_array(file, obj) else {
201 return Vec::new();
202 };
203 let mut out = Vec::new();
204 for elem in arr.iter().take(MAX_AF_ENTRIES) {
205 if let Some(ef) = parse_file_spec(file, elem, None, EmbeddedSource::AssociatedFile) {
206 out.push(ef);
207 }
208 }
209 out
210}
211
212fn parse_file_spec(
217 file: &PdfFile,
218 obj: &PdfObject,
219 tree_key: Option<String>,
220 source: EmbeddedSource,
221) -> Option<EmbeddedFile> {
222 let resolved = match obj {
223 PdfObject::Ref(r) => file.resolve(*r).ok()?,
224 other => other.clone(),
225 };
226 match resolved {
227 PdfObject::Dict(d) => Some(parse_file_spec_dict(file, &d, tree_key, source)),
228 PdfObject::String(s) => {
229 let name = non_empty(pdf_string_to_unicode(s.as_bytes()))
231 .or(tree_key)
232 .unwrap_or_default();
233 Some(EmbeddedFile {
234 name,
235 description: None,
236 relationship: None,
237 subtype: None,
238 size: None,
239 creation_date: None,
240 mod_date: None,
241 checksum: None,
242 stream: None,
243 source,
244 })
245 }
246 _ => None,
247 }
248}
249
250fn parse_file_spec_dict(
251 file: &PdfFile,
252 dict: &PdfDict,
253 tree_key: Option<String>,
254 source: EmbeddedSource,
255) -> EmbeddedFile {
256 let name = file_spec_name(file, dict).or(tree_key).unwrap_or_default();
257 let description = text(file, dict, "Desc");
258 let relationship = name_value(file, dict, "AFRelationship");
259
260 let stream = resolve_dict(file, dict.get("EF")).and_then(|ef| pick_ef_stream(&ef));
263
264 let mut subtype = None;
267 let mut size = None;
268 let mut creation_date = None;
269 let mut mod_date = None;
270 let mut checksum = None;
271 if let Some(stream_dict) = stream
272 .and_then(|id| file.resolve(id).ok())
273 .and_then(|o| o.as_stream().ok().map(|s| s.dict.clone()))
274 {
275 subtype = name_value(file, &stream_dict, "Subtype");
276 if let Some(params) = resolve_dict(file, stream_dict.get("Params")) {
277 size = integer(file, ¶ms, "Size");
278 creation_date = text(file, ¶ms, "CreationDate");
279 mod_date = text(file, ¶ms, "ModDate");
280 checksum = string_bytes(file, ¶ms, "CheckSum");
281 }
282 }
283
284 EmbeddedFile {
285 name,
286 description,
287 relationship,
288 subtype,
289 size,
290 creation_date,
291 mod_date,
292 checksum,
293 stream,
294 source,
295 }
296}
297
298fn file_spec_name(file: &PdfFile, dict: &PdfDict) -> Option<String> {
301 FILE_NAME_KEYS
302 .iter()
303 .find_map(|k| text(file, dict, k).and_then(non_empty))
304}
305
306fn pick_ef_stream(ef: &PdfDict) -> Option<ObjectId> {
309 FILE_NAME_KEYS.iter().find_map(|k| ef.get_ref(k).ok())
310}
311
312fn catalog_dict(file: &PdfFile) -> Option<PdfDict> {
314 let root_ref = file.trailer.get_ref("Root").ok()?;
315 file.resolve(root_ref).ok()?.as_dict().ok().cloned()
316}
317
318fn resolve_dict(file: &PdfFile, obj: Option<&PdfObject>) -> Option<PdfDict> {
320 match obj? {
321 PdfObject::Dict(d) => Some(d.clone()),
322 PdfObject::Stream(s) => Some(s.dict.clone()),
327 PdfObject::Ref(r) => match file.resolve(*r).ok()? {
328 PdfObject::Dict(d) => Some(d),
329 PdfObject::Stream(s) => Some(s.dict),
330 _ => None,
331 },
332 _ => None,
333 }
334}
335
336fn resolve_array(file: &PdfFile, obj: Option<&PdfObject>) -> Option<Vec<PdfObject>> {
338 match obj? {
339 PdfObject::Array(a) => Some(a.clone()),
340 PdfObject::Ref(r) => match file.resolve(*r).ok()? {
341 PdfObject::Array(a) => Some(a),
342 _ => None,
343 },
344 _ => None,
345 }
346}
347
348fn text(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
351 let value = match dict.get(key)? {
352 PdfObject::String(s) => return Some(pdf_string_to_unicode(s.as_bytes())),
353 PdfObject::Ref(r) => file.resolve(*r).ok()?,
354 _ => return None,
355 };
356 match value {
357 PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
358 _ => None,
359 }
360}
361
362fn name_value(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<String> {
366 let value = match dict.get(key)? {
367 PdfObject::Name(n) => return Some(n.as_str().to_string()),
368 PdfObject::Ref(r) => file.resolve(*r).ok()?,
369 _ => return None,
370 };
371 match value {
372 PdfObject::Name(n) => Some(n.as_str().to_string()),
373 _ => None,
374 }
375}
376
377fn integer(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<i64> {
380 let value = match dict.get(key)? {
381 PdfObject::Ref(r) => file.resolve(*r).ok()?,
382 other => other.clone(),
383 };
384 match value {
385 PdfObject::Integer(n) => Some(n),
386 PdfObject::Real(r) if r.is_finite() && r.fract() == 0.0 => Some(r as i64),
387 _ => None,
388 }
389}
390
391fn string_bytes(file: &PdfFile, dict: &PdfDict, key: &str) -> Option<Vec<u8>> {
394 let value = match dict.get(key)? {
395 PdfObject::String(s) => return Some(s.0.clone()),
396 PdfObject::Ref(r) => file.resolve(*r).ok()?,
397 _ => return None,
398 };
399 match value {
400 PdfObject::String(s) => Some(s.0),
401 _ => None,
402 }
403}
404
405fn non_empty(s: String) -> Option<String> {
406 if s.is_empty() {
407 None
408 } else {
409 Some(s)
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::test_util::build_pdf;
417 use crate::PdfDocument;
418
419 fn open(objects: &[&str]) -> PdfDocument {
420 PdfDocument::open(build_pdf(objects)).expect("open pdf")
421 }
422
423 const PAGES: &str = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
425 const PAGE: &str = "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >>";
426
427 #[test]
428 fn name_tree_single_leaf_with_stream() {
429 let doc = open(&[
430 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
431 PAGES,
432 PAGE,
433 "<< /Names [ (hello.txt) 5 0 R ] >>",
434 "<< /Type /Filespec /F (hello.txt) /UF (hello.txt) /Desc (greeting) \
435 /EF << /F 6 0 R >> >>",
436 "<< /Type /EmbeddedFile /Subtype /text#2Fplain /Params << /Size 5 >> /Length 5 >>\n\
437 stream\nHello\nendstream",
438 ]);
439 let efs = doc.embedded_files();
440 assert_eq!(efs.len(), 1);
441 let ef = &efs[0];
442 assert_eq!(ef.name, "hello.txt");
443 assert_eq!(ef.description.as_deref(), Some("greeting"));
444 assert_eq!(ef.subtype.as_deref(), Some("text/plain"));
445 assert_eq!(ef.size, Some(5));
446 assert_eq!(ef.stream, Some(ObjectId(6, 0)));
447 assert_eq!(ef.source, EmbeddedSource::NameTree);
448 assert!(ef.is_embedded());
449 let bytes = doc.embedded_file_bytes(ef).expect("extract");
451 assert_eq!(bytes, b"Hello");
452 }
453
454 #[test]
455 fn name_tree_interior_kids_node() {
456 let doc = open(&[
457 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
458 PAGES,
459 PAGE,
460 "<< /Kids [5 0 R] >>",
461 "<< /Limits [(a.txt) (a.txt)] /Names [ (a.txt) 6 0 R ] >>",
462 "<< /Type /Filespec /UF (a.txt) /EF << /F 7 0 R >> >>",
463 "<< /Type /EmbeddedFile /Length 1 >>\nstream\nx\nendstream",
464 ]);
465 let efs = doc.embedded_files();
466 assert_eq!(efs.len(), 1);
467 assert_eq!(efs[0].name, "a.txt");
468 }
469
470 #[test]
471 fn associated_file_with_relationship() {
472 let doc = open(&[
473 "<< /Type /Catalog /Pages 2 0 R /AF [4 0 R] >>",
474 PAGES,
475 PAGE,
476 "<< /Type /Filespec /F (invoice.xml) /UF (invoice.xml) \
477 /AFRelationship /Data /EF << /F 5 0 R >> >>",
478 "<< /Type /EmbeddedFile /Subtype /application#2Fxml /Length 7 >>\n\
479 stream\n<x></x>\nendstream",
480 ]);
481 let afs = doc.associated_files();
482 assert_eq!(afs.len(), 1);
483 assert_eq!(afs[0].name, "invoice.xml");
484 assert_eq!(afs[0].relationship.as_deref(), Some("Data"));
485 assert_eq!(afs[0].subtype.as_deref(), Some("application/xml"));
486 assert_eq!(afs[0].source, EmbeddedSource::AssociatedFile);
487 assert!(
488 doc.embedded_files().is_empty(),
489 "AF is not in the name tree here"
490 );
491 }
492
493 #[test]
494 fn page_level_associated_file() {
495 let doc = open(&[
496 "<< /Type /Catalog /Pages 2 0 R >>",
497 PAGES,
498 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /AF [4 0 R] >>",
499 "<< /Type /Filespec /F (page-data.bin) /AFRelationship /Supplement \
500 /EF << /F 5 0 R >> >>",
501 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
502 ]);
503 let page = doc.page(0).expect("page");
504 let afs = doc.page_associated_files(&page);
505 assert_eq!(afs.len(), 1);
506 assert_eq!(afs[0].name, "page-data.bin");
507 assert_eq!(afs[0].relationship.as_deref(), Some("Supplement"));
508 assert!(doc.associated_files().is_empty());
510 }
511
512 #[test]
513 fn utf16be_unicode_name_decodes() {
514 let doc = open(&[
516 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
517 PAGES,
518 PAGE,
519 "<< /Names [ (k) 5 0 R ] >>",
520 "<< /Type /Filespec /F (fallback.txt) \
521 /UF <FEFF00660069006C0065002E007400780074> /EF << /F 6 0 R >> >>",
522 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
523 ]);
524 let efs = doc.embedded_files();
525 assert_eq!(efs.len(), 1);
526 assert_eq!(efs[0].name, "file.txt");
528 }
529
530 #[test]
531 fn name_tree_self_cycle_terminates() {
532 let doc = open(&[
534 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
535 PAGES,
536 PAGE,
537 "<< /Kids [4 0 R] /Names [ (a) 5 0 R ] >>",
538 "<< /Type /Filespec /UF (a) /EF << /F 6 0 R >> >>",
539 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
540 ]);
541 let efs = doc.embedded_files();
542 assert_eq!(efs.len(), 1);
544 }
545
546 #[test]
547 fn filespec_without_ef_is_listed_but_not_embedded() {
548 let doc = open(&[
549 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
550 PAGES,
551 PAGE,
552 "<< /Names [ (ext) 5 0 R ] >>",
553 "<< /Type /Filespec /F (external.txt) >>",
554 ]);
555 let efs = doc.embedded_files();
556 assert_eq!(efs.len(), 1);
557 assert_eq!(efs[0].name, "external.txt");
558 assert_eq!(efs[0].stream, None);
559 assert!(!efs[0].is_embedded());
560 assert!(doc.embedded_file_bytes(&efs[0]).is_err());
562 }
563
564 #[test]
565 fn no_names_dict_is_empty() {
566 let doc = open(&["<< /Type /Catalog /Pages 2 0 R >>", PAGES, PAGE]);
567 assert!(doc.embedded_files().is_empty());
568 assert!(doc.associated_files().is_empty());
569 }
570
571 #[test]
572 fn params_dates_and_checksum_parsed() {
573 let doc = open(&[
574 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
575 PAGES,
576 PAGE,
577 "<< /Names [ (d) 5 0 R ] >>",
578 "<< /Type /Filespec /UF (d.bin) /EF << /F 6 0 R >> >>",
579 "<< /Type /EmbeddedFile /Length 0 \
580 /Params << /Size 42 /CreationDate (D:20240101000000Z) /ModDate (D:20240102000000Z) \
581 /CheckSum <00112233445566778899aabbccddeeff> >> >>\nstream\n\nendstream",
582 ]);
583 let ef = &doc.embedded_files()[0];
584 assert_eq!(ef.size, Some(42));
585 assert_eq!(ef.creation_date.as_deref(), Some("D:20240101000000Z"));
586 assert_eq!(ef.mod_date.as_deref(), Some("D:20240102000000Z"));
587 assert_eq!(ef.checksum.as_ref().map(|c| c.len()), Some(16));
588 }
589
590 #[test]
591 fn multi_pair_leaf_collects_all_in_order() {
592 let doc = open(&[
594 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
595 PAGES,
596 PAGE,
597 "<< /Names [ (a) 5 0 R (b) 6 0 R (c) 7 0 R ] >>",
598 "<< /Type /Filespec /UF (a.txt) /EF << /F 8 0 R >> >>",
599 "<< /Type /Filespec /UF (b.txt) /EF << /F 8 0 R >> >>",
600 "<< /Type /Filespec /UF (c.txt) /EF << /F 8 0 R >> >>",
601 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
602 ]);
603 let names: Vec<_> = doc.embedded_files().into_iter().map(|e| e.name).collect();
604 assert_eq!(names, ["a.txt", "b.txt", "c.txt"]);
605 }
606
607 #[test]
608 fn two_sibling_kids_each_contribute() {
609 let doc = open(&[
610 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
611 PAGES,
612 PAGE,
613 "<< /Kids [5 0 R 6 0 R] >>",
614 "<< /Names [ (a) 7 0 R ] >>",
615 "<< /Names [ (b) 8 0 R ] >>",
616 "<< /Type /Filespec /UF (a.txt) /EF << /F 9 0 R >> >>",
617 "<< /Type /Filespec /UF (b.txt) /EF << /F 9 0 R >> >>",
618 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
619 ]);
620 assert_eq!(doc.embedded_files().len(), 2);
621 }
622
623 #[test]
624 fn odd_length_names_drops_trailing_key() {
625 let doc = open(&[
627 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
628 PAGES,
629 PAGE,
630 "<< /Names [ (a) 5 0 R (orphan) ] >>",
631 "<< /Type /Filespec /UF (a.txt) /EF << /F 6 0 R >> >>",
632 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
633 ]);
634 let efs = doc.embedded_files();
635 assert_eq!(efs.len(), 1);
636 assert_eq!(efs[0].name, "a.txt");
637 }
638
639 #[test]
640 fn inline_dict_filespec_value() {
641 let doc = open(&[
643 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
644 PAGES,
645 PAGE,
646 "<< /Names [ (x) << /Type /Filespec /UF (x.txt) /EF << /F 5 0 R >> >> ] >>",
647 "<< /Type /EmbeddedFile /Length 2 >>\nstream\nhi\nendstream",
648 ]);
649 let efs = doc.embedded_files();
650 assert_eq!(efs.len(), 1);
651 assert_eq!(efs[0].name, "x.txt");
652 assert_eq!(efs[0].stream, Some(ObjectId(5, 0)));
653 assert_eq!(doc.embedded_file_bytes(&efs[0]).expect("bytes"), b"hi");
654 }
655
656 #[test]
657 fn inline_dict_kid_node() {
658 let doc = open(&[
660 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
661 PAGES,
662 PAGE,
663 "<< /Kids [ << /Names [ (a.txt) 5 0 R ] >> ] >>",
664 "<< /Type /Filespec /UF (a.txt) /EF << /F 6 0 R >> >>",
665 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
666 ]);
667 assert_eq!(doc.embedded_files().len(), 1);
668 }
669
670 #[test]
671 fn bare_string_external_af_has_no_stream() {
672 let doc = open(&[
673 "<< /Type /Catalog /Pages 2 0 R /AF [ (../external.dat) ] >>",
674 PAGES,
675 PAGE,
676 ]);
677 let afs = doc.associated_files();
678 assert_eq!(afs.len(), 1);
679 assert_eq!(afs[0].name, "../external.dat");
680 assert_eq!(afs[0].stream, None);
681 assert!(!afs[0].is_embedded());
682 assert!(doc.embedded_file_bytes(&afs[0]).is_err());
683 }
684
685 #[test]
686 fn tree_key_is_name_fallback_when_filespec_unnamed() {
687 let doc = open(&[
689 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
690 PAGES,
691 PAGE,
692 "<< /Names [ (keyname.txt) 5 0 R ] >>",
693 "<< /Type /Filespec /EF << /F 6 0 R >> >>",
694 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
695 ]);
696 assert_eq!(doc.embedded_files()[0].name, "keyname.txt");
697 }
698
699 #[test]
700 fn names_dict_without_embeddedfiles_is_empty() {
701 let doc = open(&[
704 "<< /Type /Catalog /Pages 2 0 R /Names << /Dests 4 0 R >> >>",
705 PAGES,
706 PAGE,
707 "<< /Names [ (foo) (bar) ] >>",
708 ]);
709 assert!(doc.embedded_files().is_empty());
710 assert!(doc.associated_files().is_empty());
711 }
712
713 #[test]
714 fn indirect_uf_name_and_desc_resolve() {
715 let doc = open(&[
717 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
718 PAGES,
719 PAGE,
720 "<< /Names [ (k) 5 0 R ] >>",
721 "<< /Type /Filespec /UF 7 0 R /F (fallback.txt) /Desc 8 0 R /EF << /F 6 0 R >> >>",
722 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
723 "(indirect.txt)",
724 "(indirect desc)",
725 ]);
726 let ef = &doc.embedded_files()[0];
727 assert_eq!(ef.name, "indirect.txt"); assert_eq!(ef.description.as_deref(), Some("indirect desc"));
729 }
730
731 #[test]
732 fn indirect_subtype_and_relationship_resolve() {
733 let doc = open(&[
735 "<< /Type /Catalog /Pages 2 0 R /AF [4 0 R] >>",
736 PAGES,
737 PAGE,
738 "<< /Type /Filespec /F (i.xml) /AFRelationship 6 0 R /EF << /F 5 0 R >> >>",
739 "<< /Type /EmbeddedFile /Subtype 7 0 R /Length 0 >>\nstream\n\nendstream",
740 "/Data",
741 "/application#2Fxml",
742 ]);
743 let af = &doc.associated_files()[0];
744 assert_eq!(af.relationship.as_deref(), Some("Data"));
745 assert_eq!(af.subtype.as_deref(), Some("application/xml"));
746 }
747
748 #[test]
749 fn params_size_accepts_real_and_indirect() {
750 let real = open(&[
752 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
753 PAGES,
754 PAGE,
755 "<< /Names [ (r) 5 0 R ] >>",
756 "<< /Type /Filespec /UF (r.bin) /EF << /F 6 0 R >> >>",
757 "<< /Type /EmbeddedFile /Length 0 /Params << /Size 42.0 >> >>\nstream\n\nendstream",
758 ]);
759 assert_eq!(real.embedded_files()[0].size, Some(42));
760
761 let indirect = open(&[
763 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
764 PAGES,
765 PAGE,
766 "<< /Names [ (r) 5 0 R ] >>",
767 "<< /Type /Filespec /UF (r.bin) /EF << /F 6 0 R >> >>",
768 "<< /Type /EmbeddedFile /Length 0 /Params << /Size 7 0 R >> >>\nstream\n\nendstream",
769 "99",
770 ]);
771 assert_eq!(indirect.embedded_files()[0].size, Some(99));
772 }
773
774 #[test]
775 fn name_tree_root_is_a_stream_object() {
776 let doc = open(&[
779 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
780 PAGES,
781 PAGE,
782 "<< /Names [ (s.bin) 5 0 R ] /Length 0 >>\nstream\n\nendstream",
783 "<< /Type /Filespec /UF (s.bin) /EF << /F 6 0 R >> >>",
784 "<< /Type /EmbeddedFile /Length 1 >>\nstream\nx\nendstream",
785 ]);
786 let efs = doc.embedded_files();
787 assert_eq!(efs.len(), 1);
788 assert_eq!(efs[0].name, "s.bin");
789 assert_eq!(efs[0].stream, Some(ObjectId(6, 0)));
790 }
791
792 #[test]
793 fn mid_tree_reference_cycle_terminates() {
794 let doc = open(&[
796 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>",
797 PAGES,
798 PAGE,
799 "<< /Kids [5 0 R] >>",
800 "<< /Kids [6 0 R] >>",
801 "<< /Kids [5 0 R] /Names [ (a) 7 0 R ] >>",
802 "<< /Type /Filespec /UF (a.txt) /EF << /F 8 0 R >> >>",
803 "<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream",
804 ]);
805 assert_eq!(doc.embedded_files().len(), 1);
806 }
807
808 #[test]
809 fn over_deep_name_tree_is_pruned() {
810 let depth = MAX_NAME_TREE_DEPTH + 5;
813 let mut objs: Vec<String> = vec![
814 "<< /Type /Catalog /Pages 2 0 R /Names << /EmbeddedFiles 4 0 R >> >>".into(),
815 PAGES.into(),
816 PAGE.into(),
817 ];
818 for k in 0..depth {
819 objs.push(format!("<< /Kids [{} 0 R] >>", 4 + k + 1));
820 }
821 let leaf = 4 + depth;
822 objs.push(format!("<< /Names [ (deep) {} 0 R ] >>", leaf + 1));
823 objs.push(format!(
824 "<< /Type /Filespec /UF (deep) /EF << /F {} 0 R >> >>",
825 leaf + 2
826 ));
827 objs.push("<< /Type /EmbeddedFile /Length 0 >>\nstream\n\nendstream".into());
828 let refs: Vec<&str> = objs.iter().map(|s| s.as_str()).collect();
829 let doc = PdfDocument::open(build_pdf(&refs)).expect("open");
830 assert!(doc.embedded_files().is_empty());
831 }
832}