Skip to main content

pdfrum_doc/annot/
appearance.rs

1//! Finding an annotation's appearance stream, and placing it on the page.
2//!
3//! The lookup is a ladder every rung of which has a trap, so it is written
4//! once and called from everywhere:
5//!
6//! 1. `/AP` must be a dictionary.
7//! 2. The mode's entry (`/N`, `/R`, `/D`); when the caller allows it, a
8//!    **missing key** — not an unusable value — falls back to `/N`.
9//! 3. A stream there is the answer.
10//! 4. Otherwise it must be a dictionary of states, chosen by `/AS`.
11//! 5. With no `/AS`, `/V` on the annotation, then `/V` on its immediate
12//!    `/Parent` — one level, not the full inherited-attribute walk — and the
13//!    chosen name must actually be a key of the state dictionary or the state
14//!    is `Off`.
15
16use kurbo::Affine;
17use pdfrum_object::{Dict, Name, Object, Resolve, Stream};
18
19use crate::annot::Annotation;
20use crate::geom;
21use crate::names;
22
23/// Which appearance a lookup wants.
24///
25/// ```
26/// use pdfrum_doc::annot::{ApMode, annot_ap, has_appearance};
27/// use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, Stream};
28///
29/// let normal = Stream::new(Dict::default(), ByteSpan::whole(b"0 0 10 10 re f".as_slice().into()));
30/// let annot = Dict::from_pairs([(
31///     Name::from("AP"),
32///     Object::Dict(Dict::from_pairs([(
33///         Name::from("N"),
34///         Object::Stream(Box::new(normal)),
35///     )])),
36/// )]);
37///
38/// // A missing `/R` falls back to `/N` when the caller allows it.
39/// assert!(annot_ap(&annot, ApMode::Rollover, true, &NoResolve).is_some());
40/// assert!(annot_ap(&annot, ApMode::Rollover, false, &NoResolve).is_none());
41/// ```
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum ApMode {
44    /// The normal appearance (`/N`).
45    #[default]
46    Normal,
47    /// The rollover appearance (`/R`).
48    Rollover,
49    /// The pressed appearance (`/D`).
50    Down,
51}
52
53impl ApMode {
54    /// The `/AP` sub-key this mode reads.
55    fn key(self) -> &'static Name {
56        match self {
57            ApMode::Normal => names::N,
58            ApMode::Rollover => names::R,
59            ApMode::Down => names::D,
60        }
61    }
62}
63
64/// Resolves an annotation's appearance stream.
65///
66/// `fallback_to_normal` decides what a missing rollover or down entry means.
67/// With it set, a **missing key** falls back to `/N`; a key that is present
68/// but holds nothing usable does **not** — it suppresses the fallback and the
69/// lookup comes back empty.
70///
71/// ```
72/// use pdfrum_doc::annot::{ApMode, annot_ap, has_appearance};
73/// use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, Stream};
74///
75/// let normal = Stream::new(Dict::default(), ByteSpan::whole(b"0 0 10 10 re f".as_slice().into()));
76/// let annot = Dict::from_pairs([(
77///     Name::from("AP"),
78///     Object::Dict(Dict::from_pairs([(
79///         Name::from("N"),
80///         Object::Stream(Box::new(normal)),
81///     )])),
82/// )]);
83///
84/// assert!(annot_ap(&annot, ApMode::Normal, false, &NoResolve).is_some());
85/// // No `/AP` at all: nothing.
86/// assert!(annot_ap(&Dict::default(), ApMode::Normal, true, &NoResolve).is_none());
87/// ```
88#[must_use]
89pub fn annot_ap<R: Resolve>(
90    dict: &Dict,
91    mode: ApMode,
92    fallback_to_normal: bool,
93    r: &R,
94) -> Option<Stream> {
95    let ap = dict.dict(names::AP, r)?;
96    let mut entry = mode.key();
97    if fallback_to_normal && !ap.contains_key(entry) {
98        entry = names::N;
99    }
100    let sub = ap.get(entry, r)?.get().clone();
101    if let Object::Stream(stream) = sub {
102        return Some(*stream);
103    }
104    let states = sub.as_dict()?;
105
106    let mut state = dict.byte_string(names::AS, r).unwrap_or_default();
107    if state.is_empty() {
108        // Both `/AS` and `/V` are read coercively, so a name `/V /Yes` and a
109        // string `/V (Yes)` choose the same state.
110        let mut value = dict.byte_string(names::V, r).unwrap_or_default();
111        if value.is_empty() {
112            value = dict
113                .dict(names::PARENT, r)
114                .and_then(|parent| parent.byte_string(names::V, r))
115                .unwrap_or_default();
116        }
117        // A value naming a state the dictionary lacks falls back to `Off`,
118        // not to whichever single on-state exists.
119        state = if !value.is_empty() && states.contains_key(&Name::new(value.clone())) {
120            value
121        } else {
122            names::OFF.as_bytes().to_vec()
123        };
124    }
125    states.stream(&Name::new(state), r)
126}
127
128/// Whether an annotation already carries a usable normal appearance.
129///
130/// The test is that `/AP /N` reads as a **dictionary** — and a stream answers
131/// with its own dictionary, so the overwhelmingly common "a stream is there"
132/// case suppresses generation just as a multi-state dictionary does. Only a
133/// missing `/AP`, a missing `/N`, or a scalar `/N` leaves the door open.
134///
135/// ```
136/// use pdfrum_doc::annot::{ApMode, annot_ap, has_appearance};
137/// use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, Stream};
138///
139/// let normal = Stream::new(Dict::default(), ByteSpan::whole(b"0 0 10 10 re f".as_slice().into()));
140/// let annot = Dict::from_pairs([(
141///     Name::from("AP"),
142///     Object::Dict(Dict::from_pairs([(
143///         Name::from("N"),
144///         Object::Stream(Box::new(normal)),
145///     )])),
146/// )]);
147///
148/// assert!(has_appearance(&annot, &NoResolve));
149/// assert!(!has_appearance(&Dict::default(), &NoResolve));
150/// ```
151#[must_use]
152pub fn has_appearance<R: Resolve>(dict: &Dict, r: &R) -> bool {
153    dict.dict(names::AP, r)
154        .and_then(|ap| ap.dict(names::N, r))
155        .is_some()
156}
157
158/// The transform placing an annotation's appearance form on the page.
159///
160/// The form's `/BBox`, mapped through its own `/Matrix`, is fitted into the
161/// annotation's **normalized** rectangle. A `NoRotate` annotation on a
162/// rotated page then counter-rotates about the rectangle's **top-left**
163/// corner, which is where the anchor comes from.
164#[must_use]
165pub(crate) fn annot_matrix(
166    annot: &Annotation,
167    form_dict: &Dict,
168    page_quarter_turns: u8,
169    user_to_device: Affine,
170    r: &impl Resolve,
171) -> Affine {
172    let form_matrix = form_dict.matrix(names::MATRIX, r);
173    let form_bbox = geom::transform_rect(form_matrix, form_dict.rect(names::BBOX, r));
174    let rect = geom::normalize(annot.rect);
175    let mut matrix = geom::match_rect(rect, form_bbox);
176
177    if annot.flags.no_rotate() && page_quarter_turns != 0 {
178        let (ox, oy) = (rect.x0, rect.y1);
179        let angle = std::f64::consts::FRAC_PI_2 * f64::from(page_quarter_turns);
180        matrix = matrix
181            * Affine::translate((-ox, -oy))
182            * Affine::rotate(angle)
183            * Affine::translate((ox, oy));
184    }
185    matrix * user_to_device
186}
187
188#[cfg(test)]
189mod tests {
190    use super::{ApMode, annot_ap, has_appearance};
191    use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, PdfString, Stream};
192
193    fn dict(pairs: &[(&str, Object)]) -> Dict {
194        Dict::from_pairs(
195            pairs
196                .iter()
197                .map(|(k, v)| (Name::from(*k), v.clone()))
198                .collect::<Vec<_>>(),
199        )
200    }
201
202    fn stream(marker: &[u8]) -> Object {
203        Object::Stream(Box::new(Stream::new(
204            Dict::new(),
205            ByteSpan::from(marker.to_vec()),
206        )))
207    }
208
209    fn found(annot: &Dict, mode: ApMode, fallback: bool) -> Option<Vec<u8>> {
210        annot_ap(annot, mode, fallback, &NoResolve).map(|s| s.data.as_bytes().to_vec())
211    }
212
213    #[test]
214    fn no_appearance_dictionary_finds_nothing() {
215        assert_eq!(found(&Dict::new(), ApMode::Normal, true), None);
216        assert_eq!(
217            found(&dict(&[("AP", Object::Int(4))]), ApMode::Normal, true),
218            None
219        );
220    }
221
222    #[test]
223    fn a_direct_stream_is_the_answer() {
224        let annot = dict(&[("AP", Object::Dict(dict(&[("N", stream(b"body"))])))]);
225        assert_eq!(found(&annot, ApMode::Normal, true), Some(b"body".to_vec()));
226    }
227
228    #[test]
229    fn the_mode_fallback_tests_presence_not_usability() {
230        let ap_missing_r = dict(&[("AP", Object::Dict(dict(&[("N", stream(b"n"))])))]);
231        assert_eq!(
232            found(&ap_missing_r, ApMode::Rollover, true),
233            Some(b"n".to_vec())
234        );
235        assert_eq!(found(&ap_missing_r, ApMode::Rollover, false), None);
236
237        // A present-but-null `/R` suppresses the fallback and yields nothing.
238        let ap_null_r = dict(&[(
239            "AP",
240            Object::Dict(dict(&[("N", stream(b"n")), ("R", Object::Null)])),
241        )]);
242        assert_eq!(found(&ap_null_r, ApMode::Rollover, true), None);
243    }
244
245    #[test]
246    fn a_state_dictionary_is_chosen_by_the_appearance_state() {
247        let states = dict(&[("Yes", stream(b"on")), ("Off", stream(b"off"))]);
248        let with_as = dict(&[
249            (
250                "AP",
251                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
252            ),
253            ("AS", Object::Name(Name::from("Yes"))),
254        ]);
255        assert_eq!(found(&with_as, ApMode::Normal, true), Some(b"on".to_vec()));
256
257        // With no `/AS`, `/V` on the annotation decides — coercively, so a
258        // string works as well as a name.
259        let with_v = dict(&[
260            (
261                "AP",
262                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
263            ),
264            ("V", Object::Str(PdfString::literal(b"Yes"))),
265        ]);
266        assert_eq!(found(&with_v, ApMode::Normal, true), Some(b"on".to_vec()));
267
268        // A `/V` naming a state the dictionary lacks falls back to `Off`,
269        // not to the only on-state.
270        let wrong_v = dict(&[
271            (
272                "AP",
273                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
274            ),
275            ("V", Object::Name(Name::from("Nope"))),
276        ]);
277        assert_eq!(found(&wrong_v, ApMode::Normal, true), Some(b"off".to_vec()));
278
279        // With neither, `Off` again.
280        let bare = dict(&[("AP", Object::Dict(dict(&[("N", Object::Dict(states))])))]);
281        assert_eq!(found(&bare, ApMode::Normal, true), Some(b"off".to_vec()));
282    }
283
284    #[test]
285    fn the_parent_is_consulted_one_level_only() {
286        let states = dict(&[("Yes", stream(b"on")), ("Off", stream(b"off"))]);
287        let grandparent = dict(&[("V", Object::Name(Name::from("Yes")))]);
288        let parent = dict(&[("Parent", Object::Dict(grandparent))]);
289        let annot = dict(&[
290            (
291                "AP",
292                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
293            ),
294            ("Parent", Object::Dict(parent)),
295        ]);
296        // The grandparent's `/V` is invisible to this ladder.
297        assert_eq!(found(&annot, ApMode::Normal, true), Some(b"off".to_vec()));
298
299        let direct_parent = dict(&[("V", Object::Name(Name::from("Yes")))]);
300        let annot = dict(&[
301            ("AP", Object::Dict(dict(&[("N", Object::Dict(states))]))),
302            ("Parent", Object::Dict(direct_parent)),
303        ]);
304        assert_eq!(found(&annot, ApMode::Normal, true), Some(b"on".to_vec()));
305    }
306
307    #[test]
308    fn a_non_stream_state_value_yields_nothing() {
309        let states = dict(&[("Yes", Object::Int(5))]);
310        let annot = dict(&[
311            ("AP", Object::Dict(dict(&[("N", Object::Dict(states))]))),
312            ("AS", Object::Name(Name::from("Yes"))),
313        ]);
314        assert_eq!(found(&annot, ApMode::Normal, true), None);
315    }
316
317    #[test]
318    fn a_stream_normal_appearance_counts_as_having_one() {
319        let with_stream = dict(&[("AP", Object::Dict(dict(&[("N", stream(b"x"))])))]);
320        assert!(has_appearance(&with_stream, &NoResolve));
321
322        let with_states = dict(&[(
323            "AP",
324            Object::Dict(dict(&[("N", Object::Dict(dict(&[("Yes", stream(b"x"))])))])),
325        )]);
326        assert!(has_appearance(&with_states, &NoResolve));
327
328        // A scalar `/N` leaves generation open, as does a missing `/AP`.
329        let scalar = dict(&[("AP", Object::Dict(dict(&[("N", Object::Int(5))])))]);
330        assert!(!has_appearance(&scalar, &NoResolve));
331        assert!(!has_appearance(&Dict::new(), &NoResolve));
332    }
333}