1use crate::article::rectangle;
8use crate::date::Date;
9use crate::destination::{destination_with, named_destination_with, Destination};
10use crate::document::Page;
11use crate::embedded_file::{bytes_entry, file_spec_with, FileSpec};
12use crate::geom::Rect;
13use crate::hash::FastSet;
14use crate::object::{decode_text_string, Dict, ObjRef, Object};
15use crate::source::AsyncObjectSource;
16use crate::tree::{resolved_dict, Entries};
17
18pub const MAX_ACTIONS: usize = 256;
21
22pub const MAX_TARGET_DEPTH: usize = 32;
25
26const MARKUP_SUBTYPES: [&str; 17] = [
29 "Text",
30 "FreeText",
31 "Line",
32 "Square",
33 "Circle",
34 "Polygon",
35 "PolyLine",
36 "Highlight",
37 "Underline",
38 "Squiggly",
39 "StrikeOut",
40 "Stamp",
41 "Caret",
42 "Ink",
43 "FileAttachment",
44 "Sound",
45 "Redact",
46];
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub struct AnnotationFlags(pub u32);
52
53impl AnnotationFlags {
54 pub fn bit(self, position: u32) -> bool {
59 (1..=32).contains(&position) && self.0 & (1 << (position - 1)) != 0
60 }
61
62 pub fn invisible(self) -> bool {
64 self.bit(1)
65 }
66
67 pub fn hidden(self) -> bool {
69 self.bit(2)
70 }
71
72 pub fn print(self) -> bool {
74 self.bit(3)
75 }
76
77 pub fn no_zoom(self) -> bool {
79 self.bit(4)
80 }
81
82 pub fn no_rotate(self) -> bool {
84 self.bit(5)
85 }
86
87 pub fn no_view(self) -> bool {
89 self.bit(6)
90 }
91
92 pub fn read_only(self) -> bool {
94 self.bit(7)
95 }
96
97 pub fn locked(self) -> bool {
99 self.bit(8)
100 }
101
102 pub fn toggle_no_view(self) -> bool {
104 self.bit(9)
105 }
106
107 pub fn locked_contents(self) -> bool {
109 self.bit(10)
110 }
111}
112
113#[derive(Debug, Clone, PartialEq)]
117pub struct Border {
118 pub horizontal_radius: f32,
119 pub vertical_radius: f32,
120 pub width: f32,
121 pub dash: Option<Vec<f32>>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
129pub enum ReplyType {
130 #[default]
132 Reply,
133 Group,
135}
136
137impl ReplyType {
138 fn from_name(name: &str) -> Option<ReplyType> {
139 match name {
140 "R" => Some(ReplyType::Reply),
141 "Group" => Some(ReplyType::Group),
142 _ => None,
143 }
144 }
145}
146
147#[derive(Debug, Clone, PartialEq)]
150pub struct Markup {
151 pub title: Option<String>,
154 pub popup: Option<ObjRef>,
156 pub opacity: f32,
159 pub rich_contents: Option<String>,
162 pub created: Option<Date>,
164 pub in_reply_to: Option<ObjRef>,
166 pub subject: Option<String>,
168 pub reply_type: ReplyType,
170 pub intent: Option<String>,
173}
174
175#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct AnnotationState {
179 pub model: Option<String>,
182 pub state: String,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub enum Trigger {
191 CursorEnter,
193 CursorExit,
195 MouseDown,
197 MouseUp,
199 Focus,
201 Blur,
203 PageOpen,
205 PageClose,
207 PageVisible,
209 PageInvisible,
211 Keystroke,
213 Format,
215 Validate,
217 Calculate,
219 Open,
221 Close,
223 WillClose,
225 WillSave,
227 DidSave,
229 WillPrint,
231 DidPrint,
233}
234
235const ANNOTATION_TRIGGERS: [(&str, Trigger); 14] = [
239 ("E", Trigger::CursorEnter),
240 ("X", Trigger::CursorExit),
241 ("D", Trigger::MouseDown),
242 ("U", Trigger::MouseUp),
243 ("Fo", Trigger::Focus),
244 ("Bl", Trigger::Blur),
245 ("PO", Trigger::PageOpen),
246 ("PC", Trigger::PageClose),
247 ("PV", Trigger::PageVisible),
248 ("PI", Trigger::PageInvisible),
249 ("K", Trigger::Keystroke),
250 ("F", Trigger::Format),
251 ("V", Trigger::Validate),
252 ("C", Trigger::Calculate),
253];
254
255const PAGE_TRIGGERS: [(&str, Trigger); 2] = [("O", Trigger::Open), ("C", Trigger::Close)];
257
258const DOCUMENT_TRIGGERS: [(&str, Trigger); 5] = [
260 ("WC", Trigger::WillClose),
261 ("WS", Trigger::WillSave),
262 ("DS", Trigger::DidSave),
263 ("WP", Trigger::WillPrint),
264 ("DP", Trigger::DidPrint),
265];
266
267#[derive(Debug, Clone, PartialEq)]
270pub struct TriggeredAction {
271 pub trigger: Trigger,
272 pub action: Action,
273}
274
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
278pub enum Relationship {
279 Parent,
280 Child,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq)]
286pub enum TargetPage {
287 Number(u32),
288 Named(Vec<u8>),
289}
290
291#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum TargetAnnotation {
295 Index(u32),
296 Name(String),
297}
298
299#[derive(Debug, Clone, PartialEq)]
302pub struct Target {
303 pub relationship: Relationship,
305 pub name: Option<Vec<u8>>,
307 pub page: Option<TargetPage>,
309 pub annotation: Option<TargetAnnotation>,
311 pub next: Option<Box<Target>>,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct WindowsLaunch {
319 pub file: Vec<u8>,
321 pub directory: Option<Vec<u8>>,
323 pub operation: String,
325 pub parameters: Option<Vec<u8>>,
327}
328
329#[derive(Debug, Clone, PartialEq)]
333pub enum ActionKind {
334 GoTo {
337 destination: Option<Destination>,
338 named_destination: Option<Vec<u8>>,
339 },
340 GoToR {
344 file: Option<FileSpec>,
345 destination: Option<Destination>,
346 named_destination: Option<Vec<u8>>,
347 new_window: Option<bool>,
348 },
349 GoToE {
352 file: Option<FileSpec>,
353 destination: Option<Destination>,
354 named_destination: Option<Vec<u8>>,
355 new_window: Option<bool>,
356 target: Option<Target>,
357 },
358 Launch {
361 file: Option<FileSpec>,
362 windows: Option<WindowsLaunch>,
363 new_window: Option<bool>,
364 },
365 Uri {
367 uri: String,
370 is_map: bool,
372 },
373 Named { name: String },
375 JavaScript { script: Option<String> },
378 Other { kind: String, entries: Dict },
380}
381
382#[derive(Debug, Clone, PartialEq)]
385pub struct Action {
386 pub object: Option<ObjRef>,
388 pub kind: ActionKind,
389 pub next: Vec<Action>,
393}
394
395#[derive(Debug, Clone, PartialEq)]
402pub struct Annotation {
403 pub object: Option<ObjRef>,
406 pub subtype: String,
408 pub rect: Option<Rect>,
410 pub contents: Option<String>,
412 pub page: Option<ObjRef>,
414 pub name: Option<String>,
416 pub modified: Option<String>,
418 pub modified_date: Option<Date>,
420 pub flags: AnnotationFlags,
422 pub has_appearance: bool,
424 pub appearance_state: Option<String>,
426 pub border: Option<Border>,
429 pub color: Option<Vec<f32>>,
432 pub struct_parent: Option<i64>,
435 pub optional_content: Option<ObjRef>,
438 pub markup: Option<Markup>,
440 pub state: Option<AnnotationState>,
443 pub open: Option<bool>,
445 pub icon: Option<String>,
448 pub parent: Option<ObjRef>,
450 pub file: Option<FileSpec>,
452 pub destination: Option<Destination>,
454 pub action: Option<Action>,
457 pub additional_actions: Vec<TriggeredAction>,
460}
461
462pub async fn annotations_with<S: AsyncObjectSource>(
469 src: &S,
470 trailer: &Dict,
471 page: &Page,
472) -> Vec<Annotation> {
473 let Some(entry) = page.dict().get("Annots") else {
474 return Vec::new();
475 };
476 let Ok(Object::Array(items)) = src.resolve(entry).await else {
477 return Vec::new();
478 };
479 let mut annotations = Vec::new();
480 for item in &items {
481 let Some(dict) = resolved_dict(src, item).await else {
482 continue;
483 };
484 let Some(subtype) = dict.get_name("Subtype") else {
485 continue;
486 };
487 let subtype = subtype.0.clone();
488 annotations.push(read_annotation(src, trailer, item.as_ref(), &dict, subtype).await);
489 }
490 annotations
491}
492
493async fn read_annotation<S: AsyncObjectSource>(
498 src: &S,
499 trailer: &Dict,
500 object: Option<ObjRef>,
501 dict: &Dict,
502 subtype: String,
503) -> Annotation {
504 let entries = Entries { src, dict };
505 let modified = entries.text("M").await;
506 let modified_date = modified.as_deref().and_then(Date::parse_pdf);
507 let flags = entries
508 .value("F")
509 .await
510 .and_then(|f| f.as_int())
511 .and_then(|f| u32::try_from(f).ok())
512 .map_or(AnnotationFlags::default(), AnnotationFlags);
513 let has_appearance = match dict.get("AP") {
514 Some(ap) => resolved_dict(src, ap)
515 .await
516 .is_some_and(|ap| ap.get("N").is_some()),
517 None => false,
518 };
519 let appearance_state = entries.named("AS", |n| Some(n.to_string())).await;
520 let markup = if MARKUP_SUBTYPES.contains(&subtype.as_str()) {
521 Some(read_markup(src, dict).await)
522 } else {
523 None
524 };
525 let file = match dict.get("FS") {
526 Some(fs) if subtype == "FileAttachment" => file_spec_with(src, fs).await,
527 _ => None,
528 };
529 let destination = match dict.get("Dest") {
530 Some(dest) if subtype == "Link" => {
531 let (explicit, _) = destination_entry(src, trailer, dest, false).await;
532 explicit
533 }
534 _ => None,
535 };
536 let action = match dict.get("A") {
537 Some(a) => action_with(src, trailer, a).await,
538 None => None,
539 };
540 let additional_actions = triggered_actions(src, trailer, dict, &ANNOTATION_TRIGGERS).await;
541 Annotation {
542 object,
543 subtype,
544 rect: rectangle(src, dict.get("Rect")).await,
545 contents: entries.text("Contents").await,
546 page: dict.get("P").and_then(Object::as_ref),
547 name: entries.text("NM").await,
548 modified,
549 modified_date,
550 flags,
551 has_appearance,
552 appearance_state,
553 border: read_border(src, dict.get("Border")).await,
554 color: number_array(src, dict.get("C")).await,
555 struct_parent: entries.value("StructParent").await.and_then(|v| v.as_int()),
556 optional_content: dict.get("OC").and_then(Object::as_ref),
557 markup,
558 state: read_state(&entries).await,
559 open: entries.flag("Open").await,
560 icon: entries.named("Name", |n| Some(n.to_string())).await,
561 parent: dict.get("Parent").and_then(Object::as_ref),
562 file,
563 destination,
564 action,
565 additional_actions,
566 }
567}
568
569async fn read_markup<S: AsyncObjectSource>(src: &S, dict: &Dict) -> Markup {
573 let entries = Entries { src, dict };
574 let opacity = entries
575 .value("CA")
576 .await
577 .and_then(|ca| ca.as_f64())
578 .filter(|ca| ca.is_finite())
579 .map_or(1.0, |ca| ca as f32);
580 Markup {
581 title: entries.text("T").await,
582 popup: dict.get("Popup").and_then(Object::as_ref),
583 opacity,
584 rich_contents: text_or_stream(src, dict.get("RC")).await,
585 created: match entries.text("CreationDate").await {
586 Some(date) => Date::parse_pdf(&date),
587 None => None,
588 },
589 in_reply_to: dict.get("IRT").and_then(Object::as_ref),
590 subject: entries.text("Subj").await,
591 reply_type: entries
592 .named("RT", ReplyType::from_name)
593 .await
594 .unwrap_or_default(),
595 intent: entries.named("IT", |n| Some(n.to_string())).await,
596 }
597}
598
599async fn read_state<S: AsyncObjectSource>(entries: &Entries<'_, S>) -> Option<AnnotationState> {
604 let model = entries.text("StateModel").await;
605 let state = entries.text("State").await;
606 let state = match (state, model.as_deref()) {
607 (Some(state), _) => state,
608 (None, Some("Marked")) => "Unmarked".to_string(),
609 (None, Some("Review")) => "None".to_string(),
610 (None, _) => return None,
611 };
612 Some(AnnotationState { model, state })
613}
614
615async fn text_or_stream<S: AsyncObjectSource>(src: &S, value: Option<&Object>) -> Option<String> {
618 match src.resolve(value?).await.ok()? {
619 Object::String(bytes) => Some(decode_text_string(&bytes)),
620 Object::Stream(stream) => src
621 .stream_data(&stream)
622 .await
623 .ok()
624 .map(|bytes| decode_text_string(&bytes)),
625 _ => None,
626 }
627}
628
629async fn number_array<S: AsyncObjectSource>(src: &S, value: Option<&Object>) -> Option<Vec<f32>> {
632 let array = src.resolve(value?).await.ok()?;
633 let items = array.as_array()?;
634 let mut numbers = Vec::with_capacity(items.len());
635 for item in items {
636 let number = src.resolve(item).await.ok()?.as_f64()?;
637 if !number.is_finite() {
638 return None;
639 }
640 numbers.push(number as f32);
641 }
642 Some(numbers)
643}
644
645async fn read_border<S: AsyncObjectSource>(src: &S, value: Option<&Object>) -> Option<Border> {
649 let array = src.resolve(value?).await.ok()?;
650 let items = array.as_array()?;
651 if items.len() < 3 {
652 return None;
653 }
654 let mut numbers = [0.0f32; 3];
655 for (slot, item) in numbers.iter_mut().zip(items) {
656 let number = src.resolve(item).await.ok()?.as_f64()?;
657 if !number.is_finite() {
658 return None;
659 }
660 *slot = number as f32;
661 }
662 let dash = match items.get(3) {
663 Some(dash) => Some(number_array(src, Some(dash)).await?),
664 None => None,
665 };
666 Some(Border {
667 horizontal_radius: numbers[0],
668 vertical_radius: numbers[1],
669 width: numbers[2],
670 dash,
671 })
672}
673
674async fn destination_entry<S: AsyncObjectSource>(
680 src: &S,
681 trailer: &Dict,
682 value: &Object,
683 remote: bool,
684) -> (Option<Destination>, Option<Vec<u8>>) {
685 let Ok(resolved) = src.resolve(value).await else {
686 return (None, None);
687 };
688 let name = match &resolved {
689 Object::Array(_) => return (destination_with(src, &resolved).await, None),
690 Object::Name(name) => name.0.as_bytes().to_vec(),
691 Object::String(bytes) => bytes.clone(),
692 _ => return (None, None),
693 };
694 if remote {
695 return (None, Some(name));
696 }
697 let destination = named_destination_with(src, trailer, &name).await;
698 (destination, Some(name))
699}
700
701async fn triggered_actions<S: AsyncObjectSource>(
706 src: &S,
707 trailer: &Dict,
708 dict: &Dict,
709 triggers: &[(&str, Trigger)],
710) -> Vec<TriggeredAction> {
711 let Some(aa) = dict.get("AA") else {
712 return Vec::new();
713 };
714 let Some(aa) = resolved_dict(src, aa).await else {
715 return Vec::new();
716 };
717 let mut actions = Vec::new();
718 for (key, trigger) in triggers {
719 let Some(entry) = aa.get(key) else {
720 continue;
721 };
722 if let Some(action) = action_with(src, trailer, entry).await {
723 actions.push(TriggeredAction {
724 trigger: *trigger,
725 action,
726 });
727 }
728 }
729 actions
730}
731
732pub async fn page_additional_actions_with<S: AsyncObjectSource>(
737 src: &S,
738 trailer: &Dict,
739 page: &Page,
740) -> Vec<TriggeredAction> {
741 triggered_actions(src, trailer, page.dict(), &PAGE_TRIGGERS).await
742}
743
744pub async fn document_additional_actions_with<S: AsyncObjectSource>(
749 src: &S,
750 trailer: &Dict,
751) -> Vec<TriggeredAction> {
752 let Some(root) = trailer.get("Root") else {
753 return Vec::new();
754 };
755 let Some(catalog) = resolved_dict(src, root).await else {
756 return Vec::new();
757 };
758 triggered_actions(src, trailer, &catalog, &DOCUMENT_TRIGGERS).await
759}
760
761struct ActionNode {
764 parent: Option<usize>,
765 action: Action,
766}
767
768pub async fn action_with<S: AsyncObjectSource>(
776 src: &S,
777 trailer: &Dict,
778 value: &Object,
779) -> Option<Action> {
780 let mut visited: FastSet<ObjRef> = FastSet::default();
781 let mut nodes: Vec<ActionNode> = Vec::new();
782 let mut pending: Vec<(Option<usize>, Object)> = vec![(None, value.clone())];
783 while let Some((parent, object)) = pending.pop() {
784 if nodes.len() >= MAX_ACTIONS {
785 break;
786 }
787 let reference = object.as_ref();
788 if let Some(r) = reference {
789 if !visited.insert(r) {
790 continue;
791 }
792 }
793 let Some(dict) = resolved_dict(src, &object).await else {
794 continue;
795 };
796 let Some(kind) = read_action_kind(src, trailer, &dict).await else {
797 continue;
798 };
799 let index = nodes.len();
800 nodes.push(ActionNode {
801 parent,
802 action: Action {
803 object: reference,
804 kind,
805 next: Vec::new(),
806 },
807 });
808 let children = match dict.get("Next") {
810 Some(next) => match src.resolve(next).await {
811 Ok(Object::Array(items)) => items,
812 Ok(Object::Dict(_)) => vec![next.clone()],
813 _ => Vec::new(),
814 },
815 None => Vec::new(),
816 };
817 for child in children.into_iter().rev() {
818 pending.push((Some(index), child));
819 }
820 }
821 while let Some(node) = nodes.pop() {
825 let mut action = node.action;
826 action.next.reverse();
827 match node.parent {
828 Some(parent) => nodes[parent].action.next.push(action),
829 None => return Some(action),
830 }
831 }
832 None
833}
834
835async fn read_action_kind<S: AsyncObjectSource>(
839 src: &S,
840 trailer: &Dict,
841 dict: &Dict,
842) -> Option<ActionKind> {
843 let entries = Entries { src, dict };
844 let kind = entries.value("S").await?.as_name()?.0.clone();
845 let file = |key: &'static str| async move {
846 match dict.get(key) {
847 Some(spec) => file_spec_with(src, spec).await,
848 None => None,
849 }
850 };
851 let destination = |remote: bool| async move {
852 match dict.get("D") {
853 Some(d) => destination_entry(src, trailer, d, remote).await,
854 None => (None, None),
855 }
856 };
857 Some(match kind.as_str() {
858 "GoTo" => {
859 let (destination, named_destination) = destination(false).await;
860 ActionKind::GoTo {
861 destination,
862 named_destination,
863 }
864 }
865 "GoToR" => {
866 let (destination, named_destination) = destination(true).await;
867 ActionKind::GoToR {
868 file: file("F").await,
869 destination,
870 named_destination,
871 new_window: entries.flag("NewWindow").await,
872 }
873 }
874 "GoToE" => {
875 let (destination, named_destination) = destination(true).await;
876 ActionKind::GoToE {
877 file: file("F").await,
878 destination,
879 named_destination,
880 new_window: entries.flag("NewWindow").await,
881 target: read_target(src, dict.get("T")).await,
882 }
883 }
884 "Launch" => ActionKind::Launch {
885 file: file("F").await,
886 windows: read_windows_launch(src, dict.get("Win")).await,
887 new_window: entries.flag("NewWindow").await,
888 },
889 "URI" => ActionKind::Uri {
890 uri: entries.text("URI").await.unwrap_or_default(),
891 is_map: entries.flag("IsMap").await.unwrap_or(false),
892 },
893 "Named" => ActionKind::Named {
894 name: entries
895 .named("N", |n| Some(n.to_string()))
896 .await
897 .unwrap_or_default(),
898 },
899 "JavaScript" => ActionKind::JavaScript {
900 script: text_or_stream(src, dict.get("JS")).await,
901 },
902 _ => ActionKind::Other {
903 kind,
904 entries: dict.clone(),
905 },
906 })
907}
908
909async fn read_target<S: AsyncObjectSource>(src: &S, value: Option<&Object>) -> Option<Target> {
914 let mut steps: Vec<Target> = Vec::new();
915 let mut current = value.cloned();
916 while let Some(object) = current.take() {
917 if steps.len() >= MAX_TARGET_DEPTH {
918 break;
919 }
920 let Some(dict) = resolved_dict(src, &object).await else {
921 break;
922 };
923 let entries = Entries { src, dict: &dict };
924 let relationship = match entries.value("R").await?.as_name()?.0.as_str() {
925 "P" => Relationship::Parent,
926 "C" => Relationship::Child,
927 _ => break,
928 };
929 let page = match entries.value("P").await {
930 Some(Object::Int(n)) => u32::try_from(n).ok().map(TargetPage::Number),
931 Some(Object::String(bytes)) => Some(TargetPage::Named(bytes)),
932 _ => None,
933 };
934 let annotation = match entries.value("A").await {
935 Some(Object::Int(n)) => u32::try_from(n).ok().map(TargetAnnotation::Index),
936 Some(Object::String(bytes)) => Some(TargetAnnotation::Name(decode_text_string(&bytes))),
937 _ => None,
938 };
939 steps.push(Target {
940 relationship,
941 name: entries
942 .value("N")
943 .await
944 .and_then(|n| n.as_str_bytes().map(<[u8]>::to_vec)),
945 page,
946 annotation,
947 next: None,
948 });
949 current = dict.get("T").cloned();
950 }
951 steps.into_iter().rev().fold(None, |next, mut step| {
952 step.next = next.map(Box::new);
953 Some(step)
954 })
955}
956
957async fn read_windows_launch<S: AsyncObjectSource>(
961 src: &S,
962 value: Option<&Object>,
963) -> Option<WindowsLaunch> {
964 let dict = resolved_dict(src, value?).await?;
965 Some(WindowsLaunch {
966 file: bytes_entry(src, &dict, "F").await?,
967 directory: bytes_entry(src, &dict, "D").await,
968 operation: match bytes_entry(src, &dict, "O").await {
969 Some(operation) => String::from_utf8_lossy(&operation).into_owned(),
970 None => "open".to_string(),
971 },
972 parameters: bytes_entry(src, &dict, "P").await,
973 })
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use crate::destination::{DestinationPage, Fit};
980 use crate::document::Document;
981 use pdfboss_testkit::PdfBuilder;
982
983 fn doc(page_extra: &str, objects: &[(u32, &str)], streams: &[(u32, &str, &[u8])]) -> Document {
987 let mut b = PdfBuilder::new();
988 b.object(
989 1,
990 "<< /Type /Catalog /Pages 2 0 R /Dests << /Here [3 0 R /FitH 500] >> \
991 /AA << /WC << /S /JavaScript /JS (bye) >> /DP 30 0 R >> >>",
992 );
993 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
994 b.object(
995 3,
996 &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] {page_extra} >>"),
997 );
998 b.object(30, "<< /S /Named /N /NextPage >>");
999 for (num, body) in objects {
1000 b.object(*num, body);
1001 }
1002 for (num, dict, data) in streams {
1003 b.stream(*num, dict, data);
1004 }
1005 Document::load(b.build(1)).expect("load")
1006 }
1007
1008 fn annotations(page_extra: &str, objects: &[(u32, &str)]) -> Vec<Annotation> {
1009 let doc = doc(page_extra, objects, &[]);
1010 doc.annotations(&doc.page(0).unwrap())
1011 }
1012
1013 fn one(page_extra: &str, objects: &[(u32, &str)]) -> Annotation {
1014 let mut all = annotations(page_extra, objects);
1015 assert_eq!(all.len(), 1, "{all:?}");
1016 all.remove(0)
1017 }
1018
1019 fn r(num: u32) -> ObjRef {
1020 ObjRef { num, gen: 0 }
1021 }
1022
1023 #[test]
1030 fn reads_the_entries_common_to_every_annotation() {
1031 let annotation = one(
1032 "/Annots [10 0 R]",
1033 &[(
1034 10,
1035 "<< /Type /Annot /Subtype /Square /Rect [150 90 50 10] /Contents (Draft) \
1036 /P 3 0 R /NM (sq-1) /M (D:20240102030405Z) /F 68 \
1037 /AP << /N 11 0 R >> /AS /On /Border [2 3 1 [3 2]] /C [1 0 0] \
1038 /StructParent 7 /OC 12 0 R >>",
1039 )],
1040 );
1041 assert_eq!(annotation.object, Some(r(10)));
1042 assert_eq!(annotation.subtype, "Square");
1043 assert_eq!(annotation.rect, Some(Rect::new(50.0, 10.0, 150.0, 90.0)));
1044 assert_eq!(annotation.contents.as_deref(), Some("Draft"));
1045 assert_eq!(annotation.page, Some(r(3)));
1046 assert_eq!(annotation.name.as_deref(), Some("sq-1"));
1047 assert_eq!(annotation.modified.as_deref(), Some("D:20240102030405Z"));
1048 assert_eq!(
1049 annotation.modified_date.map(|d| d.to_iso8601()).as_deref(),
1050 Some("2024-01-02T03:04:05Z")
1051 );
1052 assert_eq!(annotation.flags, AnnotationFlags(68));
1053 assert!(annotation.flags.print());
1054 assert!(annotation.flags.read_only());
1055 assert!(!annotation.flags.hidden());
1056 assert!(annotation.has_appearance);
1057 assert_eq!(annotation.appearance_state.as_deref(), Some("On"));
1058 assert_eq!(
1059 annotation.border,
1060 Some(Border {
1061 horizontal_radius: 2.0,
1062 vertical_radius: 3.0,
1063 width: 1.0,
1064 dash: Some(vec![3.0, 2.0]),
1065 })
1066 );
1067 assert_eq!(annotation.color, Some(vec![1.0, 0.0, 0.0]));
1068 assert_eq!(annotation.struct_parent, Some(7));
1069 assert_eq!(annotation.optional_content, Some(r(12)));
1070 assert!(
1071 annotation.markup.is_some(),
1072 "a square is a markup annotation"
1073 );
1074 assert_eq!(annotation.state, None);
1075 assert_eq!(annotation.action, None);
1076 }
1077
1078 #[test]
1084 fn direct_malformed_and_missing_annotations() {
1085 let all = annotations(
1086 "/Annots [<< /Subtype /Link /Rect [0 0 10 10] >> 5 << /Rect [0 0 1 1] >> 10 0 R]",
1087 &[(10, "<< /Subtype /Square /Rect [0 0 1] >>")],
1088 );
1089 assert_eq!(all.len(), 2, "{all:?}");
1090 assert_eq!(all[0].object, None);
1091 assert_eq!(all[0].subtype, "Link");
1092 assert_eq!(all[0].flags, AnnotationFlags(0));
1093 assert!(!all[0].has_appearance);
1094 assert_eq!(all[0].markup, None, "a link is not a markup annotation");
1095 assert_eq!(all[0].border, None);
1096 assert_eq!(all[1].rect, None, "a three-number rectangle is malformed");
1097 assert!(annotations("", &[]).is_empty());
1098 assert!(annotations("/Annots 4", &[]).is_empty());
1099 }
1100
1101 #[test]
1107 fn reads_the_markup_entries() {
1108 let doc = doc(
1109 "/Annots [10 0 R 11 0 R]",
1110 &[
1111 (
1112 10,
1113 "<< /Subtype /Square /Rect [0 0 10 10] /T (Ada) /Popup 12 0 R /CA 0.5 \
1114 /RC (<p>rich</p>) /CreationDate (D:20240102030405Z) /IRT 11 0 R \
1115 /Subj (Shape) /RT /Group /IT /SquareCloud >>",
1116 ),
1117 (11, "<< /Subtype /Circle /Rect [0 0 10 10] /RC 13 0 R >>"),
1118 ],
1119 &[(13, "", b"from a stream")],
1120 );
1121 let all = doc.annotations(&doc.page(0).unwrap());
1122 let square = all[0].markup.as_ref().unwrap();
1123 assert_eq!(square.title.as_deref(), Some("Ada"));
1124 assert_eq!(square.popup, Some(r(12)));
1125 assert_eq!(square.opacity, 0.5);
1126 assert_eq!(square.rich_contents.as_deref(), Some("<p>rich</p>"));
1127 assert_eq!(
1128 square.created.map(|d| d.to_iso8601()).as_deref(),
1129 Some("2024-01-02T03:04:05Z")
1130 );
1131 assert_eq!(square.in_reply_to, Some(r(11)));
1132 assert_eq!(square.subject.as_deref(), Some("Shape"));
1133 assert_eq!(square.reply_type, ReplyType::Group);
1134 assert_eq!(square.intent.as_deref(), Some("SquareCloud"));
1135 let circle = all[1].markup.as_ref().unwrap();
1136 assert_eq!(circle.opacity, 1.0);
1137 assert_eq!(circle.rich_contents.as_deref(), Some("from a stream"));
1138 assert_eq!(circle.reply_type, ReplyType::Reply);
1139 assert_eq!(circle.title, None);
1140 }
1141
1142 #[test]
1147 fn reads_the_state_a_reply_sets() {
1148 let all = annotations(
1149 "/Annots [10 0 R 11 0 R 12 0 R 13 0 R]",
1150 &[
1151 (
1152 10,
1153 "<< /Subtype /Text /Rect [0 0 10 10] /IRT 14 0 R /T (Ada) \
1154 /StateModel (Review) /State (Accepted) /Open true /Name /Comment >>",
1155 ),
1156 (
1157 11,
1158 "<< /Subtype /Text /Rect [0 0 10 10] /StateModel (Marked) >>",
1159 ),
1160 (
1161 12,
1162 "<< /Subtype /Text /Rect [0 0 10 10] /StateModel (Review) >>",
1163 ),
1164 (
1165 13,
1166 "<< /Subtype /Text /Rect [0 0 10 10] /State (Completed) >>",
1167 ),
1168 ],
1169 );
1170 assert_eq!(
1171 all[0].state,
1172 Some(AnnotationState {
1173 model: Some("Review".to_string()),
1174 state: "Accepted".to_string(),
1175 })
1176 );
1177 assert_eq!(all[0].open, Some(true));
1178 assert_eq!(all[0].icon.as_deref(), Some("Comment"));
1179 assert_eq!(all[0].markup.as_ref().unwrap().in_reply_to, Some(r(14)));
1180 assert_eq!(all[1].state.as_ref().unwrap().state, "Unmarked");
1181 assert_eq!(all[2].state.as_ref().unwrap().state, "None");
1182 assert_eq!(
1183 all[3].state,
1184 Some(AnnotationState {
1185 model: None,
1186 state: "Completed".to_string(),
1187 })
1188 );
1189 assert_eq!(all[3].open, None);
1190 }
1191
1192 #[test]
1197 fn reads_link_destinations_and_popups() {
1198 let all = annotations(
1199 "/Annots [10 0 R 11 0 R 12 0 R 13 0 R]",
1200 &[
1201 (
1202 10,
1203 "<< /Subtype /Link /Rect [0 0 10 10] /Dest [3 0 R /Fit] >>",
1204 ),
1205 (11, "<< /Subtype /Link /Rect [0 0 10 10] /Dest /Here >>"),
1206 (
1207 12,
1208 "<< /Subtype /Link /Rect [0 0 10 10] /A << /S /GoTo /D (Here) >> >>",
1209 ),
1210 (
1211 13,
1212 "<< /Subtype /Popup /Rect [0 0 10 10] /Parent 10 0 R /Open false >>",
1213 ),
1214 ],
1215 );
1216 let page = DestinationPage::Object(r(3));
1217 assert_eq!(
1218 all[0].destination,
1219 Some(Destination {
1220 page,
1221 fit: Fit::Fit
1222 })
1223 );
1224 assert_eq!(
1225 all[1].destination,
1226 Some(Destination {
1227 page,
1228 fit: Fit::FitH { top: Some(500.0) }
1229 })
1230 );
1231 assert_eq!(all[2].destination, None);
1232 assert_eq!(
1233 all[2].action.as_ref().unwrap().kind,
1234 ActionKind::GoTo {
1235 destination: Some(Destination {
1236 page,
1237 fit: Fit::FitH { top: Some(500.0) }
1238 }),
1239 named_destination: Some(b"Here".to_vec()),
1240 }
1241 );
1242 assert_eq!(all[3].parent, Some(r(10)));
1243 assert_eq!(all[3].open, Some(false));
1244 assert_eq!(all[3].markup, None, "a pop-up is not a markup annotation");
1245 }
1246
1247 #[test]
1251 fn reads_a_file_attachment_and_its_data() {
1252 let doc = doc(
1253 "/Annots [10 0 R]",
1254 &[
1255 (
1256 10,
1257 "<< /Subtype /FileAttachment /Rect [0 0 10 10] /FS 11 0 R /Name /Paperclip \
1258 /Contents (The data) >>",
1259 ),
1260 (
1261 11,
1262 "<< /Type /Filespec /F (data.csv) /EF << /F 12 0 R >> >>",
1263 ),
1264 ],
1265 &[(12, "/Type /EmbeddedFile", b"a,b\n1,2\n")],
1266 );
1267 let all = doc.annotations(&doc.page(0).unwrap());
1268 let file = all[0].file.as_ref().unwrap();
1269 assert_eq!(file.name(), "data.csv");
1270 assert_eq!(all[0].icon.as_deref(), Some("Paperclip"));
1271 assert_eq!(doc.file_spec_data(file).unwrap(), b"a,b\n1,2\n");
1272 let doc = self::doc(
1273 "/Annots [10 0 R]",
1274 &[(
1275 10,
1276 "<< /Subtype /Square /Rect [0 0 10 10] /FS (ignored.txt) >>",
1277 )],
1278 &[],
1279 );
1280 let all = doc.annotations(&doc.page(0).unwrap());
1281 assert_eq!(all[0].file, None, "only a file attachment carries a file");
1282 }
1283
1284 #[test]
1289 fn follows_next_chains_once() {
1290 let annotation = one(
1291 "/Annots [10 0 R]",
1292 &[
1293 (
1294 10,
1295 "<< /Subtype /Link /Rect [0 0 10 10] /A 11 0 R >>",
1296 ),
1297 (
1298 11,
1299 "<< /S /URI /URI (http://a.example) /Next [12 0 R << /S /Named /N /LastPage >>] >>",
1300 ),
1301 (
1302 12,
1303 "<< /S /Named /N /NextPage /Next 13 0 R >>",
1304 ),
1305 (13, "<< /S /Named /N /PrevPage /Next 11 0 R >>"),
1306 ],
1307 );
1308 let action = annotation.action.unwrap();
1309 assert_eq!(action.object, Some(r(11)));
1310 assert_eq!(
1311 action.kind,
1312 ActionKind::Uri {
1313 uri: "http://a.example".to_string(),
1314 is_map: false
1315 }
1316 );
1317 assert_eq!(action.next.len(), 2, "{:?}", action.next);
1318 assert_eq!(action.next[0].object, Some(r(12)));
1319 assert_eq!(
1320 action.next[0].kind,
1321 ActionKind::Named {
1322 name: "NextPage".to_string()
1323 }
1324 );
1325 assert_eq!(action.next[0].next.len(), 1);
1326 assert_eq!(action.next[0].next[0].object, Some(r(13)));
1327 assert!(
1328 action.next[0].next[0].next.is_empty(),
1329 "the chain back to 11 is not entered again"
1330 );
1331 assert_eq!(action.next[1].object, None);
1332 assert_eq!(
1333 action.next[1].kind,
1334 ActionKind::Named {
1335 name: "LastPage".to_string()
1336 }
1337 );
1338 }
1339
1340 #[test]
1343 fn a_runaway_next_chain_stops_at_the_budget() {
1344 let first = 100u32;
1345 let last = first + MAX_ACTIONS as u32 + 20;
1346 let bodies: Vec<(u32, String)> = (first..=last)
1347 .map(|num| {
1348 let next = if num < last {
1349 format!(" /Next {} 0 R", num + 1)
1350 } else {
1351 String::new()
1352 };
1353 (num, format!("<< /S /Named /N /NextPage{next} >>"))
1354 })
1355 .collect();
1356 let objects: Vec<(u32, &str)> = bodies
1357 .iter()
1358 .map(|(num, body)| (*num, body.as_str()))
1359 .collect();
1360 let annotation = one(
1361 "/Annots [<< /Subtype /Link /Rect [0 0 10 10] /A 100 0 R >>]",
1362 &objects,
1363 );
1364 let mut depth = 1;
1365 let mut action = annotation.action.as_ref().unwrap();
1366 while let Some(next) = action.next.first() {
1367 depth += 1;
1368 action = next;
1369 }
1370 assert_eq!(depth, MAX_ACTIONS);
1371 }
1372
1373 #[test]
1378 fn reads_trigger_events_on_annotations_pages_and_the_catalog() {
1379 let doc = doc(
1380 "/Annots [10 0 R] /AA << /C << /S /Named /N /FirstPage >> /O 30 0 R >>",
1381 &[(
1382 10,
1383 "<< /Subtype /Widget /Rect [0 0 10 10] /FT /Tx /T (f) \
1384 /AA << /C << /S /JavaScript /JS (calc) >> /E 30 0 R /Fo << /S /Named /N /LastPage >> \
1385 /Zz << /S /Named /N /Ignored >> >> >>",
1386 )],
1387 &[],
1388 );
1389 let page = doc.page(0).unwrap();
1390 let all = doc.annotations(&page);
1391 let triggers: Vec<Trigger> = all[0]
1392 .additional_actions
1393 .iter()
1394 .map(|t| t.trigger)
1395 .collect();
1396 assert_eq!(
1397 triggers,
1398 [Trigger::CursorEnter, Trigger::Focus, Trigger::Calculate]
1399 );
1400 assert_eq!(
1401 all[0].additional_actions[2].action.kind,
1402 ActionKind::JavaScript {
1403 script: Some("calc".to_string())
1404 }
1405 );
1406 let page_actions = doc.page_additional_actions(&page);
1407 let triggers: Vec<Trigger> = page_actions.iter().map(|t| t.trigger).collect();
1408 assert_eq!(triggers, [Trigger::Open, Trigger::Close]);
1409 assert_eq!(page_actions[0].action.object, Some(r(30)));
1410 let document_actions = doc.additional_actions();
1411 let triggers: Vec<Trigger> = document_actions.iter().map(|t| t.trigger).collect();
1412 assert_eq!(triggers, [Trigger::WillClose, Trigger::DidPrint]);
1413 assert_eq!(
1414 document_actions[0].action.kind,
1415 ActionKind::JavaScript {
1416 script: Some("bye".to_string())
1417 }
1418 );
1419 let bare = self::doc("", &[], &[]);
1420 assert!(bare
1421 .page_additional_actions(&bare.page(0).unwrap())
1422 .is_empty());
1423 }
1424
1425 fn action_of(
1428 objects: &[(u32, &str)],
1429 streams: &[(u32, &str, &[u8])],
1430 num: u32,
1431 ) -> Option<Action> {
1432 let doc = doc("", objects, streams);
1433 doc.action(&Object::Ref(r(num)))
1434 }
1435
1436 #[test]
1441 fn reads_every_action_type() {
1442 let others = [
1443 "Thread",
1444 "Sound",
1445 "Movie",
1446 "Hide",
1447 "ResetForm",
1448 "ImportData",
1449 "SetOCGState",
1450 "Rendition",
1451 "Trans",
1452 "GoTo3DView",
1453 ];
1454 let other_bodies: Vec<String> = others.iter().map(|k| format!("<< /S /{k} >>")).collect();
1455 let mut objects = vec![
1456 (
1457 20,
1458 "<< /S /SubmitForm /F (http://x.example/post) /Flags 4 >>",
1459 ),
1460 (22, "<< /S /URI /URI (http://x.example/?q) /IsMap true >>"),
1461 (23, "<< /S /Named /N /FirstPage >>"),
1462 (24, "<< /S /JavaScript /JS 21 0 R >>"),
1463 (25, "<< /Type /Action >>"),
1464 (26, "(not a dictionary)"),
1465 ];
1466 objects.extend(
1467 other_bodies
1468 .iter()
1469 .enumerate()
1470 .map(|(i, body)| (30 + i as u32, body.as_str())),
1471 );
1472 let doc = doc("", &objects, &[(21, "", b"app.alert(1)")]);
1473 let action = |num: u32| doc.action(&Object::Ref(r(num)));
1474 assert_eq!(
1475 action(22).unwrap().kind,
1476 ActionKind::Uri {
1477 uri: "http://x.example/?q".to_string(),
1478 is_map: true
1479 }
1480 );
1481 assert_eq!(
1482 action(23).unwrap().kind,
1483 ActionKind::Named {
1484 name: "FirstPage".to_string()
1485 }
1486 );
1487 assert_eq!(
1488 action(24).unwrap().kind,
1489 ActionKind::JavaScript {
1490 script: Some("app.alert(1)".to_string())
1491 }
1492 );
1493 let other = action(20).unwrap();
1494 assert_eq!(other.object, Some(r(20)));
1495 match other.kind {
1496 ActionKind::Other { kind, entries } => {
1497 assert_eq!(kind, "SubmitForm");
1498 assert_eq!(entries.get("Flags").and_then(Object::as_int), Some(4));
1499 }
1500 other => panic!("{other:?}"),
1501 }
1502 for (i, kind) in others.iter().enumerate() {
1503 let read = action(30 + i as u32).unwrap();
1504 assert!(
1505 matches!(&read.kind, ActionKind::Other { kind: k, .. } if k == kind),
1506 "{kind}: {read:?}"
1507 );
1508 }
1509 assert_eq!(action(25), None);
1510 assert_eq!(action(26), None);
1511 }
1512
1513 #[test]
1517 fn reads_remote_go_to_actions() {
1518 let objects = [
1519 (
1520 20,
1521 "<< /S /GoToR /F (other.pdf) /D [2 /XYZ 10 20 1.5] /NewWindow true >>",
1522 ),
1523 (
1524 21,
1525 "<< /S /GoToR /F << /F (o.pdf) /UF (o.pdf) >> /D (Here) >>",
1526 ),
1527 ];
1528 assert_eq!(
1529 action_of(&objects, &[], 20).unwrap().kind,
1530 ActionKind::GoToR {
1531 file: Some(FileSpec {
1532 file: Some(b"other.pdf".to_vec()),
1533 ..FileSpec::default()
1534 }),
1535 destination: Some(Destination {
1536 page: DestinationPage::Number(2),
1537 fit: Fit::Xyz {
1538 left: Some(10.0),
1539 top: Some(20.0),
1540 zoom: Some(1.5)
1541 }
1542 }),
1543 named_destination: None,
1544 new_window: Some(true),
1545 }
1546 );
1547 assert_eq!(
1548 action_of(&objects, &[], 21).unwrap().kind,
1549 ActionKind::GoToR {
1550 file: Some(FileSpec {
1551 file: Some(b"o.pdf".to_vec()),
1552 unicode_file: Some("o.pdf".to_string()),
1553 ..FileSpec::default()
1554 }),
1555 destination: None,
1556 named_destination: Some(b"Here".to_vec()),
1557 new_window: None,
1558 }
1559 );
1560 }
1561
1562 #[test]
1567 fn reads_embedded_go_to_targets() {
1568 let mut deep = String::from("<< /R /C >>");
1569 for _ in 0..(MAX_TARGET_DEPTH + 5) {
1570 deep = format!("<< /R /C /T {deep} >>");
1571 }
1572 let deep = format!("<< /S /GoToE /D (x) /T {deep} >>");
1573 let objects = [
1574 (
1575 20,
1576 "<< /S /GoToE /D [0 /Fit] /T << /R /P /T << /R /C /N (child.pdf) \
1577 /T << /R /C /P 3 /A (annotName) >> >> >> >>",
1578 ),
1579 (21, "<< /S /GoToE /D (x) /T << /R /C /P (Here) /A 2 >> >>"),
1580 (22, deep.as_str()),
1581 (23, "<< /S /GoToE /D (x) /T << /R /Sideways >> >>"),
1582 ];
1583 let target_of = |num: u32| match action_of(&objects, &[], num).unwrap().kind {
1584 ActionKind::GoToE {
1585 target,
1586 destination,
1587 file,
1588 ..
1589 } => (target, destination, file),
1590 other => panic!("{other:?}"),
1591 };
1592 let (target, destination, file) = target_of(20);
1593 assert_eq!(file, None);
1594 assert_eq!(
1595 destination.map(|d| d.page),
1596 Some(DestinationPage::Number(0))
1597 );
1598 let first = target.unwrap();
1599 assert_eq!(first.relationship, Relationship::Parent);
1600 let second = first.next.as_deref().unwrap();
1601 assert_eq!(second.relationship, Relationship::Child);
1602 assert_eq!(second.name.as_deref(), Some(b"child.pdf".as_slice()));
1603 let third = second.next.as_deref().unwrap();
1604 assert_eq!(third.page, Some(TargetPage::Number(3)));
1605 assert_eq!(
1606 third.annotation,
1607 Some(TargetAnnotation::Name("annotName".to_string()))
1608 );
1609 assert_eq!(third.next, None);
1610 let (target, _, _) = target_of(21);
1611 let target = target.unwrap();
1612 assert_eq!(target.page, Some(TargetPage::Named(b"Here".to_vec())));
1613 assert_eq!(target.annotation, Some(TargetAnnotation::Index(2)));
1614 let (target, _, _) = target_of(22);
1615 let mut depth = 1;
1616 let mut step = target.unwrap();
1617 while let Some(next) = step.next {
1618 depth += 1;
1619 step = *next;
1620 }
1621 assert_eq!(depth, MAX_TARGET_DEPTH);
1622 let (target, _, _) = target_of(23);
1623 assert_eq!(target, None);
1624 }
1625
1626 #[test]
1631 fn reads_launch_actions() {
1632 let objects = [
1633 (
1634 20,
1635 "<< /S /Launch /F (setup.exe) /NewWindow false \
1636 /Win << /F (c:\\\\tools\\\\setup.exe) /D (c:\\\\tools) /O (print) /P (-q) >> >>",
1637 ),
1638 (21, "<< /S /Launch /Win << /F (notepad.exe) >> >>"),
1639 ];
1640 assert_eq!(
1641 action_of(&objects, &[], 20).unwrap().kind,
1642 ActionKind::Launch {
1643 file: Some(FileSpec {
1644 file: Some(b"setup.exe".to_vec()),
1645 ..FileSpec::default()
1646 }),
1647 windows: Some(WindowsLaunch {
1648 file: b"c:\\tools\\setup.exe".to_vec(),
1649 directory: Some(b"c:\\tools".to_vec()),
1650 operation: "print".to_string(),
1651 parameters: Some(b"-q".to_vec()),
1652 }),
1653 new_window: Some(false),
1654 }
1655 );
1656 assert_eq!(
1657 action_of(&objects, &[], 21).unwrap().kind,
1658 ActionKind::Launch {
1659 file: None,
1660 windows: Some(WindowsLaunch {
1661 file: b"notepad.exe".to_vec(),
1662 directory: None,
1663 operation: "open".to_string(),
1664 parameters: None,
1665 }),
1666 new_window: None,
1667 }
1668 );
1669 }
1670}