Skip to main content

pdfrum_doc/annot/
list.rs

1//! A page's annotation list, which is **not** its `/Annots` array.
2//!
3//! Two views of the same page differ, and both are correct for their caller:
4//!
5//! - The `--annot` dump walks `/Annots` directly, so it sees pop-up
6//!   annotations that are in the file and does not see the ones synthesized
7//!   here.
8//! - The rendering list drops the file's pop-ups ("the viewer provides its
9//!   own") and appends a synthesized one per markup annotation that carries
10//!   text — which is then never drawn, because a pop-up only draws when it is
11//!   open and nothing here ever opens one.
12
13use kurbo::Rect;
14use pdfrum_object::{Dict, Name, Object, PdfString, Resolve, decode_text, names as obj_names};
15
16use crate::annot::{Annotation, Subtype, is_popup};
17use crate::geom;
18use crate::names;
19
20/// A page's annotations as the rendering path sees them.
21///
22/// ```
23/// use pdfrum_doc::annot::AnnotList;
24/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
25///
26/// let note = Dict::from_pairs([
27///     (Name::from("Subtype"), Object::Name(Name::from("Text"))),
28///     (Name::from("Contents"), Object::Str(PdfString::literal(b"a note"))),
29///     (
30///         Name::from("Rect"),
31///         Object::Array(Array::of([10, 300, 30, 320].map(Object::from))),
32///     ),
33/// ]);
34/// let page = Dict::from_pairs([(
35///     Name::from("Annots"),
36///     Object::Array(Array::of([Object::Dict(note)])),
37/// )]);
38///
39/// // `page_width` is the crop-box width, which decides where a
40/// // synthesized pop-up lands.
41/// let list = AnnotList::load(&page, 612.0, &NoResolve);
42///
43/// assert_eq!(list.annots.len(), 1);
44/// // The file declares no pop-up; one is synthesized for the note.
45/// assert_eq!(list.popups.len(), 1);
46/// ```
47#[derive(Debug, Clone, Default, PartialEq)]
48pub struct AnnotList {
49    /// The annotations the file declares, minus its pop-ups, in `/Annots`
50    /// order.
51    pub annots: Vec<Annotation>,
52    /// Their indices in the original `/Annots` array, which the dump and the
53    /// overlay are keyed by.
54    pub source_indices: Vec<usize>,
55    /// Pop-ups synthesized for the annotations above, appended after them.
56    /// Each records which entry of `annots` it belongs to.
57    pub popups: Vec<(usize, Annotation)>,
58}
59
60impl AnnotList {
61    /// Builds the list for one page.
62    ///
63    /// `page_width` is the crop-box width, which decides where a synthesized
64    /// pop-up lands.
65    ///
66    /// ```
67    /// use pdfrum_doc::annot::AnnotList;
68    /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
69    ///
70    /// let note = Dict::from_pairs([
71    ///     (Name::from("Subtype"), Object::Name(Name::from("Text"))),
72    ///     (Name::from("Contents"), Object::Str(PdfString::literal(b"a note"))),
73    ///     (
74    ///         Name::from("Rect"),
75    ///         Object::Array(Array::of([10, 300, 30, 320].map(Object::from))),
76    ///     ),
77    /// ]);
78    /// let page = Dict::from_pairs([(
79    ///     Name::from("Annots"),
80    ///     Object::Array(Array::of([Object::Dict(note)])),
81    /// )]);
82    ///
83    /// // `page_width` is the crop-box width, which decides where a
84    /// // synthesized pop-up lands.
85    /// let list = AnnotList::load(&page, 612.0, &NoResolve);
86    ///
87    /// // The index in the original `/Annots` array survives, which is what
88    /// // keeps the dump and the appearance overlay aligned.
89    /// assert_eq!(list.source_indices, [0]);
90    /// ```
91    #[must_use]
92    pub fn load<R: Resolve>(page: &Dict, page_width: f32, r: &R) -> AnnotList {
93        let mut list = AnnotList::default();
94        // `/Annots` is not an inherited attribute: it is read off the page's
95        // own dictionary or it is not there.
96        let Some(array) = page.array(obj_names::ANNOTS, r) else {
97            return list;
98        };
99        for index in 0..array.len() {
100            let Some(dict) = array.dict_at(index, r) else {
101                continue;
102            };
103            if is_popup(&dict, r) {
104                continue;
105            }
106            list.annots.push(Annotation::read(&dict, r));
107            list.source_indices.push(index);
108        }
109        for (index, annot) in list.annots.iter().enumerate() {
110            if let Some(popup) = create_popup(annot, page_width, r) {
111                list.popups.push((index, popup));
112            }
113        }
114        list
115    }
116}
117
118/// Whether an annotation of this subtype gets a synthesized pop-up.
119///
120/// Free text and widgets do not, despite both carrying text.
121#[must_use]
122pub fn popup_appears_for(subtype: Subtype) -> bool {
123    matches!(
124        subtype,
125        Subtype::Text
126            | Subtype::Line
127            | Subtype::Square
128            | Subtype::Circle
129            | Subtype::Polygon
130            | Subtype::PolyLine
131            | Subtype::Highlight
132            | Subtype::Underline
133            | Subtype::Squiggly
134            | Subtype::StrikeOut
135            | Subtype::Stamp
136            | Subtype::Caret
137            | Subtype::Ink
138            | Subtype::FileAttachment
139            | Subtype::Redact
140    )
141}
142
143/// Synthesizes a pop-up for one annotation, when it deserves one.
144///
145/// The emptiness test runs on the **decoded** text, not the raw bytes: a bare
146/// byte-order mark, or a mark followed by nothing but a language-code region,
147/// both decode to nothing and suppress the pop-up even though the raw string
148/// is non-empty.
149fn create_popup<R: Resolve>(parent: &Annotation, page_width: f32, r: &R) -> Option<Annotation> {
150    if !popup_appears_for(parent.subtype) {
151        return None;
152    }
153    let contents = parent.dict.byte_string(obj_names::CONTENTS, r)?;
154    if decode_text(&contents).is_empty() {
155        return None;
156    }
157
158    let rect = popup_rect(geom::normalize(parent.rect), page_width);
159    let title = parent.dict.byte_string(obj_names::T, r).unwrap_or_default();
160    let dict = Dict::from_pairs([
161        (obj_names::TYPE.clone(), Object::Name(names::ANNOT.clone())),
162        (
163            obj_names::SUBTYPE.clone(),
164            Object::Name(Name::new(Subtype::Popup.as_bytes().to_vec())),
165        ),
166        (obj_names::T.clone(), Object::Str(PdfString::literal(title))),
167        (
168            obj_names::CONTENTS.clone(),
169            Object::Str(PdfString::literal(contents)),
170        ),
171        (
172            obj_names::RECT.clone(),
173            Object::Array(pdfrum_object::Array::of([
174                Object::from(geom::left(rect)),
175                Object::from(geom::bottom(rect)),
176                Object::from(geom::right(rect)),
177                Object::from(geom::top(rect)),
178            ])),
179        ),
180        (obj_names::F.clone(), Object::Int(0)),
181    ]);
182    Some(Annotation::read(&dict, r))
183}
184
185/// Where a synthesized 200×200 pop-up lands beside its parent.
186fn popup_rect(parent: Rect, page_width: f32) -> Rect {
187    let (left, bottom, right, top) = (
188        geom::left(parent),
189        geom::bottom(parent),
190        geom::right(parent),
191        geom::top(parent),
192    );
193    let base = geom::rect(0.0, 0.0, 200.0, 200.0);
194    if left + 200.0 > page_width && bottom - 200.0 < 0.0 {
195        // No room to the right and none below: hang it off the parent's
196        // top-right corner instead.
197        geom::translate(base, right - 200.0, top)
198    } else {
199        geom::translate(
200            base,
201            left.min(page_width - 200.0),
202            (bottom - 200.0).max(0.0),
203        )
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::{AnnotList, popup_appears_for, popup_rect};
210    use crate::annot::Subtype;
211    use crate::geom;
212    use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
213
214    fn dict(pairs: &[(&str, Object)]) -> Dict {
215        Dict::from_pairs(
216            pairs
217                .iter()
218                .map(|(k, v)| (Name::from(*k), v.clone()))
219                .collect::<Vec<_>>(),
220        )
221    }
222
223    fn page_with(annots: &[Object]) -> Dict {
224        dict(&[("Annots", Object::Array(Array::of(annots.to_vec())))])
225    }
226
227    fn text_annot(contents: &[u8]) -> Object {
228        Object::Dict(dict(&[
229            ("Subtype", Object::Name(Name::from("Text"))),
230            ("Contents", Object::Str(PdfString::literal(contents))),
231            (
232                "Rect",
233                Object::Array(Array::of([
234                    Object::from(10.0_f32),
235                    Object::from(300.0_f32),
236                    Object::from(30.0_f32),
237                    Object::from(320.0_f32),
238                ])),
239            ),
240        ]))
241    }
242
243    #[test]
244    fn a_text_annotation_with_content_gains_one_synthesized_popup() {
245        let page = page_with(&[text_annot(b"Aa\xE4\xA0")]);
246        let list = AnnotList::load(&page, 612.0, &NoResolve);
247        assert_eq!(list.annots.len(), 1);
248        assert_eq!(list.popups.len(), 1);
249        // The raw bytes travel through verbatim.
250        let popup = list.popups.first().map(|(_, popup)| popup);
251        assert_eq!(
252            popup
253                .and_then(|popup| popup.dict.string(pdfrum_object::names::CONTENTS))
254                .map(|s| s.bytes.to_vec()),
255            Some(b"Aa\xE4\xA0".to_vec())
256        );
257    }
258
259    #[test]
260    fn emptiness_is_tested_after_decoding_not_before() {
261        // Empty, a bare byte-order mark, and a mark followed by nothing but a
262        // language-code region all decode to nothing.
263        for contents in [
264            &b""[..],
265            &b"\xFE\xFF"[..],
266            &b"\xFE\xFF\x00\x1Bja\x00\x1B"[..],
267        ] {
268            let page = page_with(&[text_annot(contents)]);
269            let list = AnnotList::load(&page, 612.0, &NoResolve);
270            assert_eq!(list.popups.len(), 0, "{contents:?}");
271        }
272    }
273
274    #[test]
275    fn a_popup_in_the_file_is_dropped_from_the_list() {
276        let popup = Object::Dict(dict(&[("Subtype", Object::Name(Name::from("Popup")))]));
277        let page = page_with(&[popup, text_annot(b"x")]);
278        let list = AnnotList::load(&page, 612.0, &NoResolve);
279        assert_eq!(list.annots.len(), 1);
280        // The index it occupied in `/Annots` survives, which is what keeps
281        // the dump and the overlay aligned.
282        assert_eq!(list.source_indices, vec![1]);
283    }
284
285    #[test]
286    fn free_text_and_widgets_get_no_popup() {
287        assert!(!popup_appears_for(Subtype::FreeText));
288        assert!(!popup_appears_for(Subtype::Widget));
289        assert!(popup_appears_for(Subtype::Text));
290        assert!(popup_appears_for(Subtype::Redact));
291    }
292
293    #[test]
294    fn the_popup_hugs_the_parents_left_edge_until_the_page_runs_out() {
295        // 690.511 wide, parent at left 500 with top 360.046: the left is
296        // clamped to the page width less 200.
297        let parent = geom::rect(500.0, 260.046, 520.0, 360.046);
298        let placed = popup_rect(parent, 690.511);
299        assert!((geom::left(placed) - 490.511).abs() < 1e-3, "{placed:?}");
300        assert!((geom::bottom(placed) - 60.046).abs() < 1e-3);
301    }
302
303    #[test]
304    fn a_bottom_right_parent_hangs_its_popup_off_its_own_corner() {
305        // No room to the right and none below.
306        let parent = geom::rect(500.0, 100.0, 560.0, 180.0);
307        let placed = popup_rect(parent, 612.0);
308        assert!((geom::left(placed) - 360.0).abs() < f32::EPSILON);
309        assert!((geom::bottom(placed) - 180.0).abs() < f32::EPSILON);
310    }
311}