Skip to main content

rpdfium_doc/
annotation_appearance.rs

1//! Annotation appearance stream extraction (ISO 32000-2 section 12.5.5).
2//!
3//! Each annotation can have an `/AP` dictionary with up to three appearance
4//! sub-dictionaries: `/N` (normal), `/R` (rollover), and `/D` (down).
5//! Each sub-entry can be a single stream or a dictionary mapping state names
6//! to streams.
7
8use rpdfium_core::{Name, ObjectId, PdfSource};
9use rpdfium_parser::{Object, ObjectStore};
10
11use crate::error::{DocError, DocResult};
12
13/// Resolved appearance stream object IDs for an annotation.
14#[derive(Debug, Clone)]
15pub struct AnnotationAppearance {
16    /// Normal appearance stream (`/AP/N`).
17    pub normal: Option<ObjectId>,
18    /// Rollover appearance stream (`/AP/R`).
19    pub rollover: Option<ObjectId>,
20    /// Down (mouse-press) appearance stream (`/AP/D`).
21    pub down: Option<ObjectId>,
22}
23
24/// Extract appearance stream references from an `/AP` dictionary.
25///
26/// Each sub-entry (`/N`, `/R`, `/D`) can be:
27/// - An indirect reference to a stream (Form XObject) -- returns the ObjectId directly
28/// - A dictionary mapping state names to streams -- returns the first entry's ObjectId
29pub fn extract_appearance<S: PdfSource>(
30    ap_obj: &Object,
31    store: &ObjectStore<S>,
32) -> DocResult<AnnotationAppearance> {
33    let resolved = store
34        .deep_resolve(ap_obj)
35        .map_err(|e| DocError::Parser(e.to_string()))?;
36    let ap_dict = resolved.as_dict().ok_or(DocError::UnexpectedType)?;
37
38    let normal = extract_sub_appearance(ap_dict.get(&Name::n()), store);
39    let rollover = extract_sub_appearance(ap_dict.get(&Name::r()), store);
40    let down = extract_sub_appearance(ap_dict.get(&Name::d()), store);
41
42    Ok(AnnotationAppearance {
43        normal,
44        rollover,
45        down,
46    })
47}
48
49/// Extract a single sub-appearance entry (e.g., `/N` value).
50///
51/// Returns the ObjectId of the stream if found, or the first entry from
52/// a state dictionary.
53fn extract_sub_appearance<S: PdfSource>(
54    obj: Option<&Object>,
55    store: &ObjectStore<S>,
56) -> Option<ObjectId> {
57    let entry = obj?;
58
59    // If it's a direct reference, that's the stream ID
60    if let Some(id) = entry.as_reference() {
61        return Some(id);
62    }
63
64    // Try to resolve and check if it's a stream (which should have been a ref)
65    let resolved = store.deep_resolve(entry).ok()?;
66
67    // If it's a dict (state dictionary), take the first entry's value
68    if let Some(state_dict) = resolved.as_dict() {
69        for value in state_dict.values() {
70            if let Some(id) = value.as_reference() {
71                return Some(id);
72            }
73        }
74    }
75
76    None
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use rpdfium_core::error::ObjectId as CoreObjectId;
83    use std::collections::HashMap;
84
85    fn build_store() -> ObjectStore<Vec<u8>> {
86        let pdf = build_minimal_pdf();
87        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
88    }
89
90    fn build_minimal_pdf() -> Vec<u8> {
91        let mut pdf = Vec::new();
92        pdf.extend_from_slice(b"%PDF-1.4\n");
93        let obj1_offset = pdf.len();
94        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
95        let obj2_offset = pdf.len();
96        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
97        let xref_offset = pdf.len();
98        pdf.extend_from_slice(b"xref\n0 3\n");
99        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
100        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
101        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
102        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
103        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
104        pdf
105    }
106
107    #[test]
108    fn test_extract_appearance_with_direct_refs() {
109        let store = build_store();
110        let mut ap_dict = HashMap::new();
111        ap_dict.insert(Name::n(), Object::Reference(CoreObjectId::new(1, 0)));
112        ap_dict.insert(Name::r(), Object::Reference(CoreObjectId::new(2, 0)));
113
114        let ap_obj = Object::Dictionary(ap_dict);
115        let result = extract_appearance(&ap_obj, &store).unwrap();
116        assert_eq!(result.normal, Some(CoreObjectId::new(1, 0)));
117        assert_eq!(result.rollover, Some(CoreObjectId::new(2, 0)));
118        assert!(result.down.is_none());
119    }
120
121    #[test]
122    fn test_extract_appearance_with_state_dict() {
123        let store = build_store();
124        // /N is a state dict mapping state names to stream refs
125        let mut state_dict = HashMap::new();
126        state_dict.insert(
127            Name::from("Yes"),
128            Object::Reference(CoreObjectId::new(1, 0)),
129        );
130
131        let mut ap_dict = HashMap::new();
132        ap_dict.insert(Name::n(), Object::Dictionary(state_dict));
133
134        let ap_obj = Object::Dictionary(ap_dict);
135        let result = extract_appearance(&ap_obj, &store).unwrap();
136        // Should pick the first entry from the state dict
137        assert!(result.normal.is_some());
138    }
139
140    #[test]
141    fn test_extract_appearance_empty() {
142        let store = build_store();
143        let ap_dict: HashMap<Name, Object> = HashMap::new();
144        let ap_obj = Object::Dictionary(ap_dict);
145        let result = extract_appearance(&ap_obj, &store).unwrap();
146        assert!(result.normal.is_none());
147        assert!(result.rollover.is_none());
148        assert!(result.down.is_none());
149    }
150}