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, Subtype};
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/// Whether an annotation stays upright on a rotated page.
159///
160/// `NoRotate` says so, and a text note (`/Text`, the sticky-note icon) does
161/// whatever its flags say: pdf.js sets `noRotate` for every `TextAnnotation`,
162/// and poppler and Acrobat keep the icon upright too. Upstream rotates a note
163/// without the flag along with the page; three readers out of four do not,
164/// so the note follows them.
165fn keeps_upright(annot: &Annotation) -> bool {
166    annot.flags.no_rotate() || annot.subtype == Subtype::Text
167}
168
169/// The transform placing an annotation's appearance form on the page.
170///
171/// The form's `/BBox`, mapped through its own `/Matrix`, is fitted into the
172/// annotation's **normalized** rectangle. A `NoRotate` annotation on a
173/// rotated page then counter-rotates about the rectangle's **top-left**
174/// corner, which is where the anchor comes from.
175///
176/// `page_quarter_turns` is the page's `/Rotate` in quarter turns clockwise
177/// (`Rotation::quarters`), which is `GetPageRotation` upstream
178/// (`cpdf_annot.cpp`, `AnnotGetMatrix`). The sign needs no flip: the matrix
179/// built here is in unrotated page space, where `Affine::rotate` with a
180/// positive angle turns **counter**-clockwise (y points up), and the page's
181/// display matrix then turns everything clockwise by `/Rotate`. The two
182/// cancel, so the annotation ends upright on screen, which is also what
183/// pdf.js's viewer does for `NoRotate`.
184///
185/// The composition order is the inverse of the C++'s row-vector `Concat`
186/// chain: `kurbo`'s `a * b` applies `b` first, so the fit comes last in the
187/// product and `user_to_device` first.
188#[must_use]
189pub(crate) fn annot_matrix(
190    annot: &Annotation,
191    form_dict: &Dict,
192    page_quarter_turns: u8,
193    user_to_device: Affine,
194    r: &impl Resolve,
195) -> Affine {
196    let form_matrix = form_dict.matrix(names::MATRIX, r);
197    let form_bbox = geom::transform_rect(form_matrix, form_dict.rect(names::BBOX, r));
198    let rect = geom::normalize(annot.rect);
199    let mut matrix = geom::match_rect(rect, form_bbox);
200
201    if keeps_upright(annot) && page_quarter_turns != 0 {
202        let (ox, oy) = (rect.x0, rect.y1);
203        let angle = std::f64::consts::FRAC_PI_2 * f64::from(page_quarter_turns);
204        matrix = Affine::translate((ox, oy))
205            * Affine::rotate(angle)
206            * Affine::translate((-ox, -oy))
207            * matrix;
208    }
209    user_to_device * matrix
210}
211
212#[cfg(test)]
213mod tests {
214    use super::{ApMode, annot_ap, has_appearance};
215    use pdfrum_object::{ByteSpan, Dict, Name, NoResolve, Object, PdfString, Stream};
216
217    fn dict(pairs: &[(&str, Object)]) -> Dict {
218        Dict::from_pairs(
219            pairs
220                .iter()
221                .map(|(k, v)| (Name::from(*k), v.clone()))
222                .collect::<Vec<_>>(),
223        )
224    }
225
226    fn stream(marker: &[u8]) -> Object {
227        Object::Stream(Box::new(Stream::new(
228            Dict::new(),
229            ByteSpan::from(marker.to_vec()),
230        )))
231    }
232
233    fn found(annot: &Dict, mode: ApMode, fallback: bool) -> Option<Vec<u8>> {
234        annot_ap(annot, mode, fallback, &NoResolve).map(|s| s.data.as_bytes().to_vec())
235    }
236
237    #[test]
238    fn no_appearance_dictionary_finds_nothing() {
239        assert_eq!(found(&Dict::new(), ApMode::Normal, true), None);
240        assert_eq!(
241            found(&dict(&[("AP", Object::Int(4))]), ApMode::Normal, true),
242            None
243        );
244    }
245
246    #[test]
247    fn a_direct_stream_is_the_answer() {
248        let annot = dict(&[("AP", Object::Dict(dict(&[("N", stream(b"body"))])))]);
249        assert_eq!(found(&annot, ApMode::Normal, true), Some(b"body".to_vec()));
250    }
251
252    #[test]
253    fn the_mode_fallback_tests_presence_not_usability() {
254        let ap_missing_r = dict(&[("AP", Object::Dict(dict(&[("N", stream(b"n"))])))]);
255        assert_eq!(
256            found(&ap_missing_r, ApMode::Rollover, true),
257            Some(b"n".to_vec())
258        );
259        assert_eq!(found(&ap_missing_r, ApMode::Rollover, false), None);
260
261        // A present-but-null `/R` suppresses the fallback and yields nothing.
262        let ap_null_r = dict(&[(
263            "AP",
264            Object::Dict(dict(&[("N", stream(b"n")), ("R", Object::Null)])),
265        )]);
266        assert_eq!(found(&ap_null_r, ApMode::Rollover, true), None);
267    }
268
269    #[test]
270    fn a_state_dictionary_is_chosen_by_the_appearance_state() {
271        let states = dict(&[("Yes", stream(b"on")), ("Off", stream(b"off"))]);
272        let with_as = dict(&[
273            (
274                "AP",
275                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
276            ),
277            ("AS", Object::Name(Name::from("Yes"))),
278        ]);
279        assert_eq!(found(&with_as, ApMode::Normal, true), Some(b"on".to_vec()));
280
281        // With no `/AS`, `/V` on the annotation decides — coercively, so a
282        // string works as well as a name.
283        let with_v = dict(&[
284            (
285                "AP",
286                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
287            ),
288            ("V", Object::Str(PdfString::literal(b"Yes"))),
289        ]);
290        assert_eq!(found(&with_v, ApMode::Normal, true), Some(b"on".to_vec()));
291
292        // A `/V` naming a state the dictionary lacks falls back to `Off`,
293        // not to the only on-state.
294        let wrong_v = dict(&[
295            (
296                "AP",
297                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
298            ),
299            ("V", Object::Name(Name::from("Nope"))),
300        ]);
301        assert_eq!(found(&wrong_v, ApMode::Normal, true), Some(b"off".to_vec()));
302
303        // With neither, `Off` again.
304        let bare = dict(&[("AP", Object::Dict(dict(&[("N", Object::Dict(states))])))]);
305        assert_eq!(found(&bare, ApMode::Normal, true), Some(b"off".to_vec()));
306    }
307
308    #[test]
309    fn the_parent_is_consulted_one_level_only() {
310        let states = dict(&[("Yes", stream(b"on")), ("Off", stream(b"off"))]);
311        let grandparent = dict(&[("V", Object::Name(Name::from("Yes")))]);
312        let parent = dict(&[("Parent", Object::Dict(grandparent))]);
313        let annot = dict(&[
314            (
315                "AP",
316                Object::Dict(dict(&[("N", Object::Dict(states.clone()))])),
317            ),
318            ("Parent", Object::Dict(parent)),
319        ]);
320        // The grandparent's `/V` is invisible to this ladder.
321        assert_eq!(found(&annot, ApMode::Normal, true), Some(b"off".to_vec()));
322
323        let direct_parent = dict(&[("V", Object::Name(Name::from("Yes")))]);
324        let annot = dict(&[
325            ("AP", Object::Dict(dict(&[("N", Object::Dict(states))]))),
326            ("Parent", Object::Dict(direct_parent)),
327        ]);
328        assert_eq!(found(&annot, ApMode::Normal, true), Some(b"on".to_vec()));
329    }
330
331    #[test]
332    fn a_non_stream_state_value_yields_nothing() {
333        let states = dict(&[("Yes", Object::Int(5))]);
334        let annot = dict(&[
335            ("AP", Object::Dict(dict(&[("N", Object::Dict(states))]))),
336            ("AS", Object::Name(Name::from("Yes"))),
337        ]);
338        assert_eq!(found(&annot, ApMode::Normal, true), None);
339    }
340
341    #[test]
342    fn a_stream_normal_appearance_counts_as_having_one() {
343        let with_stream = dict(&[("AP", Object::Dict(dict(&[("N", stream(b"x"))])))]);
344        assert!(has_appearance(&with_stream, &NoResolve));
345
346        let with_states = dict(&[(
347            "AP",
348            Object::Dict(dict(&[("N", Object::Dict(dict(&[("Yes", stream(b"x"))])))])),
349        )]);
350        assert!(has_appearance(&with_states, &NoResolve));
351
352        // A scalar `/N` leaves generation open, as does a missing `/AP`.
353        let scalar = dict(&[("AP", Object::Dict(dict(&[("N", Object::Int(5))])))]);
354        assert!(!has_appearance(&scalar, &NoResolve));
355        assert!(!has_appearance(&Dict::new(), &NoResolve));
356    }
357
358    /// A `NoRotate` annotation on a rotated page counter-rotates about the
359    /// rectangle's top-left corner: that corner stays where it was and the
360    /// opposite one moves.
361    #[test]
362    fn no_rotate_counter_rotates_about_the_top_left_corner() {
363        use super::annot_matrix;
364        use crate::annot::Annotation;
365        use kurbo::{Affine, Point};
366
367        let ints = |values: &[i64]| {
368            Object::Array(pdfrum_object::Array::of(
369                values.iter().copied().map(Object::Int),
370            ))
371        };
372        let annot = Annotation::read(
373            &dict(&[
374                ("Subtype", Object::Name(Name::from("Stamp"))),
375                ("Rect", ints(&[100, 200, 140, 260])),
376                ("F", Object::Int(16)),
377            ]),
378            &NoResolve,
379        );
380        let form = dict(&[("BBox", ints(&[0, 0, 40, 60]))]);
381
382        let upright = annot_matrix(&annot, &form, 0, Affine::IDENTITY, &NoResolve);
383        let turned = annot_matrix(&annot, &form, 1, Affine::IDENTITY, &NoResolve);
384
385        // The form's top-left corner is the rectangle's top-left, in both.
386        let corner = Point::new(100.0, 260.0);
387        let top_left = Point::new(0.0, 60.0);
388        for matrix in [upright, turned] {
389            let got = matrix * top_left;
390            assert!(got.distance(corner) < 1e-9, "{got:?}");
391        }
392        // The opposite corner swings a quarter turn about it.
393        let far = turned * Point::new(40.0, 0.0);
394        assert!(
395            far.distance(Point::new(100.0 + 60.0, 260.0 + 40.0)) < 1e-9,
396            "{far:?}"
397        );
398
399        // Without the flag the page's rotation changes nothing.
400        let plain = Annotation::read(&dict(&[("Rect", ints(&[100, 200, 140, 260]))]), &NoResolve);
401        let plain = annot_matrix(&plain, &form, 1, Affine::IDENTITY, &NoResolve);
402        let unturned = upright * Point::new(40.0, 0.0);
403        assert!((plain * Point::new(40.0, 0.0)).distance(unturned) < 1e-9);
404        assert!(
405            far.distance(unturned) > 1.0,
406            "the turn moved the far corner"
407        );
408    }
409}