Skip to main content

rpdfium_doc/
annot_list.rs

1// Derived from PDFium's cpdf_annotlist.h/cpp
2// Original: Copyright 2014 The PDFium Authors
3// Licensed under BSD-3-Clause / Apache-2.0
4// See pdfium-upstream/LICENSE for the original license.
5
6//! Annotation list parsing — `CPDF_AnnotList`.
7//!
8//! Parses the `/Annots` array on a PDF page into a `Vec<Annotation>` and
9//! provides utilities for locating related annotations (e.g., popup parents).
10//! Individual annotation parsing is in [`crate::annotation`].
11
12use rpdfium_core::PdfSource;
13use rpdfium_parser::{Object, ObjectStore};
14
15use crate::annotation::{Annotation, parse_single_annotation};
16use crate::error::{DocError, DocResult};
17
18/// Parse all annotations from a page's `/Annots` array.
19///
20/// The `annots_obj` should be the value of the `/Annots` key from the page
21/// dictionary — either a direct array or an indirect reference to one.
22pub fn parse_annotations<S: PdfSource>(
23    annots_obj: &Object,
24    store: &ObjectStore<S>,
25) -> DocResult<Vec<Annotation>> {
26    let resolved = store
27        .deep_resolve(annots_obj)
28        .map_err(|e| DocError::Parser(e.to_string()))?;
29
30    let arr = resolved.as_array().ok_or(DocError::UnexpectedType)?;
31
32    let mut annotations = Vec::with_capacity(arr.len());
33    for item in arr {
34        // Capture the object ID from the indirect reference before resolving
35        let obj_id = item.as_reference();
36
37        let annot_obj = store
38            .deep_resolve(item)
39            .map_err(|e| DocError::Parser(e.to_string()))?;
40
41        match parse_single_annotation(annot_obj, store, obj_id) {
42            Ok(annot) => annotations.push(annot),
43            Err(_) => {
44                // Skip malformed annotations in lenient mode
45                continue;
46            }
47        }
48    }
49
50    Ok(annotations)
51}
52
53/// Find the parent annotation of a popup annotation.
54///
55/// Returns the index of the parent annotation in the list, if the popup
56/// has a `/Parent` reference matching another annotation's object ID.
57pub fn find_parent_annotation(annotations: &[Annotation], popup: &Annotation) -> Option<usize> {
58    let parent_id = popup.parent_ref?;
59    annotations
60        .iter()
61        .position(|a| a.object_id == Some(parent_id))
62}
63
64/// Returns the index of the topmost annotation whose rect contains `(x, y)`.
65///
66/// Iterates annotations in reverse order (last annotation = highest z-order) and
67/// returns the index of the first one whose rectangle contains the given point.
68///
69/// Corresponds to `FPDFAnnot_GetFormFieldAtPoint` — finds widget annotations at a
70/// point. Returns `None` if no annotation contains the point.
71pub fn annotation_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<usize> {
72    annotations.iter().enumerate().rev().find_map(|(i, ann)| {
73        let r = ann.rect;
74        // PDF coordinate system: y-up, so rect[1] is bottom (smaller y) and rect[3] is top (larger y).
75        // Normalize to handle rects stored in either order.
76        let x_min = r[0].min(r[2]);
77        let x_max = r[0].max(r[2]);
78        let y_min = r[1].min(r[3]);
79        let y_max = r[1].max(r[3]);
80        if x >= x_min && x <= x_max && y >= y_min && y <= y_max {
81            Some(i)
82        } else {
83            None
84        }
85    })
86}
87
88/// ADR-019 T2 alias for [`annotation_at_point()`].
89///
90/// Corresponds to `FPDFAnnot_GetFormFieldAtPoint`.
91#[inline]
92pub fn annot_get_form_field_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<usize> {
93    annotation_at_point(annotations, x, y)
94}
95
96/// Deprecated — use [`annot_get_form_field_at_point()`].
97#[deprecated(
98    note = "use `annot_get_form_field_at_point()` — matches upstream `FPDFAnnot_GetFormFieldAtPoint`"
99)]
100#[inline]
101pub fn get_annotation_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<usize> {
102    annotation_at_point(annotations, x, y)
103}
104
105/// Deprecated — use [`annot_get_form_field_at_point()`].
106#[deprecated(
107    note = "use `annot_get_form_field_at_point()` — matches upstream `FPDFAnnot_GetFormFieldAtPoint`"
108)]
109#[inline]
110pub fn get_form_field_at_point(annotations: &[Annotation], x: f32, y: f32) -> Option<usize> {
111    annotation_at_point(annotations, x, y)
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use crate::action::Action;
118    use crate::annotation::{Annotation, AnnotationFlags, AnnotationSubtypeData, AnnotationType};
119    use crate::destination::Destination;
120    use rpdfium_core::{Name, PdfString};
121    use rpdfium_parser::ObjectId;
122    use std::collections::HashMap;
123
124    fn build_store() -> ObjectStore<Vec<u8>> {
125        let pdf = build_minimal_pdf();
126        ObjectStore::open(pdf, rpdfium_core::ParsingMode::Lenient).unwrap()
127    }
128
129    fn build_minimal_pdf() -> Vec<u8> {
130        let mut pdf = Vec::new();
131        pdf.extend_from_slice(b"%PDF-1.4\n");
132        let obj1_offset = pdf.len();
133        pdf.extend_from_slice(b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
134        let obj2_offset = pdf.len();
135        pdf.extend_from_slice(b"2 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n");
136        let xref_offset = pdf.len();
137        pdf.extend_from_slice(b"xref\n0 3\n");
138        pdf.extend_from_slice(b"0000000000 65535 f \r\n");
139        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj1_offset).as_bytes());
140        pdf.extend_from_slice(format!("{:010} 00000 n \r\n", obj2_offset).as_bytes());
141        pdf.extend_from_slice(b"trailer\n<< /Size 3 /Root 1 0 R >>\n");
142        pdf.extend_from_slice(format!("startxref\n{}\n%%EOF", xref_offset).as_bytes());
143        pdf
144    }
145
146    fn make_rect_array(x1: f64, y1: f64, x2: f64, y2: f64) -> Object {
147        Object::Array(vec![
148            Object::Real(x1),
149            Object::Real(y1),
150            Object::Real(x2),
151            Object::Real(y2),
152        ])
153    }
154
155    #[test]
156    fn test_parse_empty_annotations_array() {
157        let store = build_store();
158        let arr = Object::Array(vec![]);
159        let result = parse_annotations(&arr, &store).unwrap();
160        assert!(result.is_empty());
161    }
162
163    #[test]
164    fn test_parse_text_annotation() {
165        let store = build_store();
166        let mut dict = HashMap::new();
167        dict.insert(Name::subtype(), Object::Name(Name::from("Text")));
168        dict.insert(Name::rect(), make_rect_array(10.0, 20.0, 100.0, 50.0));
169        dict.insert(
170            Name::contents(),
171            Object::String(PdfString::from_bytes(b"A note".to_vec())),
172        );
173
174        let arr = Object::Array(vec![Object::Dictionary(dict)]);
175        let result = parse_annotations(&arr, &store).unwrap();
176        assert_eq!(result.len(), 1);
177        assert_eq!(result[0].subtype, AnnotationType::Text);
178        assert_eq!(result[0].rect, [10.0, 20.0, 100.0, 50.0]);
179        assert_eq!(result[0].contents.as_deref(), Some("A note"));
180    }
181
182    #[test]
183    fn test_parse_link_annotation_with_uri_action() {
184        let store = build_store();
185        let mut action_dict = HashMap::new();
186        action_dict.insert(Name::s(), Object::Name(Name::from("URI")));
187        action_dict.insert(
188            Name::uri(),
189            Object::String(PdfString::from_bytes(b"https://example.com".to_vec())),
190        );
191
192        let mut dict = HashMap::new();
193        dict.insert(Name::subtype(), Object::Name(Name::from("Link")));
194        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 200.0, 20.0));
195        dict.insert(Name::a(), Object::Dictionary(action_dict));
196
197        let arr = Object::Array(vec![Object::Dictionary(dict)]);
198        let result = parse_annotations(&arr, &store).unwrap();
199        assert_eq!(result.len(), 1);
200        assert_eq!(result[0].subtype, AnnotationType::Link);
201        match &result[0].action {
202            Some(Action::Uri(uri)) => assert_eq!(uri, "https://example.com"),
203            _ => panic!("expected URI action"),
204        }
205    }
206
207    #[test]
208    fn test_parse_highlight_annotation() {
209        let store = build_store();
210        let mut dict = HashMap::new();
211        dict.insert(Name::subtype(), Object::Name(Name::from("Highlight")));
212        dict.insert(Name::rect(), make_rect_array(50.0, 700.0, 200.0, 720.0));
213        dict.insert(
214            Name::c(),
215            Object::Array(vec![
216                Object::Real(1.0),
217                Object::Real(1.0),
218                Object::Real(0.0),
219            ]),
220        );
221
222        let arr = Object::Array(vec![Object::Dictionary(dict)]);
223        let result = parse_annotations(&arr, &store).unwrap();
224        assert_eq!(result.len(), 1);
225        assert_eq!(result[0].subtype, AnnotationType::Highlight);
226        assert_eq!(result[0].color.as_deref(), Some(&[1.0, 1.0, 0.0][..]));
227    }
228
229    #[test]
230    fn test_border_from_bs_dict() {
231        use crate::annotation::BorderStyle;
232        let store = build_store();
233        let mut bs_dict = HashMap::new();
234        bs_dict.insert(Name::w(), Object::Real(2.5));
235        bs_dict.insert(Name::s(), Object::Name(Name::from("D")));
236
237        let mut dict = HashMap::new();
238        dict.insert(Name::subtype(), Object::Name(Name::from("Text")));
239        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 100.0, 100.0));
240        dict.insert(Name::bs(), Object::Dictionary(bs_dict));
241
242        let arr = Object::Array(vec![Object::Dictionary(dict)]);
243        let result = parse_annotations(&arr, &store).unwrap();
244        let border = result[0].border.as_ref().unwrap();
245        assert_eq!(border.width, 2.5);
246        assert_eq!(border.style, BorderStyle::Dashed);
247    }
248
249    #[test]
250    fn test_border_from_border_array() {
251        use crate::annotation::BorderStyle;
252        let store = build_store();
253        let mut dict = HashMap::new();
254        dict.insert(Name::subtype(), Object::Name(Name::from("Link")));
255        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 100.0, 100.0));
256        dict.insert(
257            Name::border(),
258            Object::Array(vec![
259                Object::Integer(0),
260                Object::Integer(0),
261                Object::Real(3.0),
262            ]),
263        );
264
265        let arr = Object::Array(vec![Object::Dictionary(dict)]);
266        let result = parse_annotations(&arr, &store).unwrap();
267        let border = result[0].border.as_ref().unwrap();
268        assert_eq!(border.width, 3.0);
269        assert_eq!(border.style, BorderStyle::Solid);
270    }
271
272    #[test]
273    fn test_annotation_with_no_appearance() {
274        let store = build_store();
275        let mut dict = HashMap::new();
276        dict.insert(Name::subtype(), Object::Name(Name::from("Text")));
277        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 50.0));
278
279        let arr = Object::Array(vec![Object::Dictionary(dict)]);
280        let result = parse_annotations(&arr, &store).unwrap();
281        assert!(result[0].appearance.is_none());
282    }
283
284    #[test]
285    fn test_rect_extraction() {
286        let store = build_store();
287        let mut dict = HashMap::new();
288        dict.insert(Name::subtype(), Object::Name(Name::from("Square")));
289        dict.insert(Name::rect(), make_rect_array(72.0, 144.0, 288.0, 432.0));
290
291        let arr = Object::Array(vec![Object::Dictionary(dict)]);
292        let result = parse_annotations(&arr, &store).unwrap();
293        assert_eq!(result[0].rect, [72.0, 144.0, 288.0, 432.0]);
294    }
295
296    #[test]
297    fn test_multiple_annotations() {
298        let store = build_store();
299
300        let mut dict1 = HashMap::new();
301        dict1.insert(Name::subtype(), Object::Name(Name::from("Text")));
302        dict1.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 50.0));
303
304        let mut dict2 = HashMap::new();
305        dict2.insert(Name::subtype(), Object::Name(Name::from("Link")));
306        dict2.insert(Name::rect(), make_rect_array(100.0, 100.0, 200.0, 200.0));
307
308        let mut dict3 = HashMap::new();
309        dict3.insert(Name::subtype(), Object::Name(Name::from("Highlight")));
310        dict3.insert(Name::rect(), make_rect_array(50.0, 50.0, 150.0, 60.0));
311
312        let arr = Object::Array(vec![
313            Object::Dictionary(dict1),
314            Object::Dictionary(dict2),
315            Object::Dictionary(dict3),
316        ]);
317        let result = parse_annotations(&arr, &store).unwrap();
318        assert_eq!(result.len(), 3);
319        assert_eq!(result[0].subtype, AnnotationType::Text);
320        assert_eq!(result[1].subtype, AnnotationType::Link);
321        assert_eq!(result[2].subtype, AnnotationType::Highlight);
322    }
323
324    #[test]
325    fn test_annotation_with_destination() {
326        let store = build_store();
327        let mut dict = HashMap::new();
328        dict.insert(Name::subtype(), Object::Name(Name::from("Link")));
329        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 100.0, 20.0));
330        dict.insert(
331            Name::dest(),
332            Object::String(PdfString::from_bytes(b"chapter1".to_vec())),
333        );
334
335        let arr = Object::Array(vec![Object::Dictionary(dict)]);
336        let result = parse_annotations(&arr, &store).unwrap();
337        match &result[0].destination {
338            Some(Destination::Named(name)) => assert_eq!(name, "chapter1"),
339            _ => panic!("expected named destination"),
340        }
341    }
342
343    #[test]
344    fn test_annotation_with_action() {
345        let store = build_store();
346        let mut action_dict = HashMap::new();
347        action_dict.insert(Name::s(), Object::Name(Name::from("Named")));
348        action_dict.insert(Name::n(), Object::Name(Name::from("NextPage")));
349
350        let mut dict = HashMap::new();
351        dict.insert(Name::subtype(), Object::Name(Name::from("Widget")));
352        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 20.0));
353        dict.insert(Name::a(), Object::Dictionary(action_dict));
354
355        let arr = Object::Array(vec![Object::Dictionary(dict)]);
356        let result = parse_annotations(&arr, &store).unwrap();
357        match &result[0].action {
358            Some(Action::Named(name)) => assert_eq!(name, "NextPage"),
359            _ => panic!("expected Named action"),
360        }
361    }
362
363    #[test]
364    fn test_annotation_with_nm_name() {
365        let store = build_store();
366        let mut dict = HashMap::new();
367        dict.insert(Name::subtype(), Object::Name(Name::from("Text")));
368        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 50.0));
369        dict.insert(
370            Name::nm(),
371            Object::String(PdfString::from_bytes(b"annot-001".to_vec())),
372        );
373
374        let arr = Object::Array(vec![Object::Dictionary(dict)]);
375        let result = parse_annotations(&arr, &store).unwrap();
376        assert_eq!(result[0].name.as_deref(), Some("annot-001"));
377    }
378
379    #[test]
380    fn test_annotation_with_flags() {
381        let store = build_store();
382        let mut dict = HashMap::new();
383        dict.insert(Name::subtype(), Object::Name(Name::from("Text")));
384        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 50.0));
385        dict.insert(Name::f(), Object::Integer(6)); // Hidden + Print
386
387        let arr = Object::Array(vec![Object::Dictionary(dict)]);
388        let result = parse_annotations(&arr, &store).unwrap();
389        assert!(result[0].flags.hidden());
390        assert!(result[0].flags.print());
391    }
392
393    #[test]
394    fn test_highlight_with_quad_points() {
395        let store = build_store();
396        let mut dict = HashMap::new();
397        dict.insert(Name::subtype(), Object::Name(Name::from("Highlight")));
398        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 100.0, 20.0));
399        dict.insert(
400            Name::quad_points(),
401            Object::Array(vec![
402                Object::Real(0.0),
403                Object::Real(0.0),
404                Object::Real(100.0),
405                Object::Real(0.0),
406                Object::Real(100.0),
407                Object::Real(20.0),
408                Object::Real(0.0),
409                Object::Real(20.0),
410            ]),
411        );
412
413        let arr = Object::Array(vec![Object::Dictionary(dict)]);
414        let result = parse_annotations(&arr, &store).unwrap();
415        assert_eq!(
416            result[0].subtype_data.quad_points,
417            Some(vec![0.0, 0.0, 100.0, 0.0, 100.0, 20.0, 0.0, 20.0])
418        );
419    }
420
421    #[test]
422    fn test_highlight_with_multiple_quads() {
423        let store = build_store();
424        let mut dict = HashMap::new();
425        dict.insert(Name::subtype(), Object::Name(Name::from("Highlight")));
426        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 200.0, 40.0));
427        dict.insert(
428            Name::quad_points(),
429            Object::Array(vec![
430                // First quad
431                Object::Real(0.0),
432                Object::Real(0.0),
433                Object::Real(100.0),
434                Object::Real(0.0),
435                Object::Real(100.0),
436                Object::Real(20.0),
437                Object::Real(0.0),
438                Object::Real(20.0),
439                // Second quad
440                Object::Real(0.0),
441                Object::Real(20.0),
442                Object::Real(100.0),
443                Object::Real(20.0),
444                Object::Real(100.0),
445                Object::Real(40.0),
446                Object::Real(0.0),
447                Object::Real(40.0),
448            ]),
449        );
450
451        let arr = Object::Array(vec![Object::Dictionary(dict)]);
452        let result = parse_annotations(&arr, &store).unwrap();
453        let qp = result[0].subtype_data.quad_points.as_ref().unwrap();
454        assert_eq!(qp.len(), 16);
455    }
456
457    #[test]
458    fn test_line_annotation_endpoints() {
459        let store = build_store();
460        let mut dict = HashMap::new();
461        dict.insert(Name::subtype(), Object::Name(Name::from("Line")));
462        dict.insert(Name::rect(), make_rect_array(10.0, 20.0, 100.0, 200.0));
463        dict.insert(
464            Name::l(),
465            Object::Array(vec![
466                Object::Real(10.0),
467                Object::Real(20.0),
468                Object::Real(100.0),
469                Object::Real(200.0),
470            ]),
471        );
472
473        let arr = Object::Array(vec![Object::Dictionary(dict)]);
474        let result = parse_annotations(&arr, &store).unwrap();
475        assert_eq!(
476            result[0].subtype_data.line_points,
477            Some([10.0, 20.0, 100.0, 200.0])
478        );
479    }
480
481    #[test]
482    fn test_line_annotation_leader_line() {
483        let store = build_store();
484        let mut dict = HashMap::new();
485        dict.insert(Name::subtype(), Object::Name(Name::from("Line")));
486        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 100.0, 100.0));
487        dict.insert(
488            Name::l(),
489            Object::Array(vec![
490                Object::Real(0.0),
491                Object::Real(0.0),
492                Object::Real(100.0),
493                Object::Real(100.0),
494            ]),
495        );
496        dict.insert(Name::ll(), Object::Real(15.0));
497
498        let arr = Object::Array(vec![Object::Dictionary(dict)]);
499        let result = parse_annotations(&arr, &store).unwrap();
500        assert_eq!(
501            result[0].subtype_data.line_points,
502            Some([0.0, 0.0, 100.0, 100.0])
503        );
504        assert_eq!(result[0].subtype_data.leader_line_length, Some(15.0));
505    }
506
507    #[test]
508    fn test_polygon_vertices() {
509        let store = build_store();
510        let mut dict = HashMap::new();
511        dict.insert(Name::subtype(), Object::Name(Name::from("Polygon")));
512        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 100.0, 100.0));
513        dict.insert(
514            Name::vertices(),
515            Object::Array(vec![
516                Object::Real(0.0),
517                Object::Real(0.0),
518                Object::Real(50.0),
519                Object::Real(100.0),
520                Object::Real(100.0),
521                Object::Real(0.0),
522            ]),
523        );
524
525        let arr = Object::Array(vec![Object::Dictionary(dict)]);
526        let result = parse_annotations(&arr, &store).unwrap();
527        assert_eq!(
528            result[0].subtype_data.vertices,
529            Some(vec![0.0, 0.0, 50.0, 100.0, 100.0, 0.0])
530        );
531    }
532
533    #[test]
534    fn test_polyline_vertices() {
535        let store = build_store();
536        let mut dict = HashMap::new();
537        dict.insert(Name::subtype(), Object::Name(Name::from("PolyLine")));
538        dict.insert(Name::rect(), make_rect_array(10.0, 10.0, 30.0, 20.0));
539        dict.insert(
540            Name::vertices(),
541            Object::Array(vec![
542                Object::Real(10.0),
543                Object::Real(10.0),
544                Object::Real(20.0),
545                Object::Real(20.0),
546                Object::Real(30.0),
547                Object::Real(10.0),
548            ]),
549        );
550
551        let arr = Object::Array(vec![Object::Dictionary(dict)]);
552        let result = parse_annotations(&arr, &store).unwrap();
553        assert_eq!(
554            result[0].subtype_data.vertices,
555            Some(vec![10.0, 10.0, 20.0, 20.0, 30.0, 10.0])
556        );
557    }
558
559    #[test]
560    fn test_ink_annotation_strokes() {
561        let store = build_store();
562        let mut dict = HashMap::new();
563        dict.insert(Name::subtype(), Object::Name(Name::from("Ink")));
564        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 50.0));
565        dict.insert(
566            Name::ink_list(),
567            Object::Array(vec![
568                Object::Array(vec![
569                    Object::Real(10.0),
570                    Object::Real(10.0),
571                    Object::Real(20.0),
572                    Object::Real(20.0),
573                ]),
574                Object::Array(vec![
575                    Object::Real(30.0),
576                    Object::Real(30.0),
577                    Object::Real(40.0),
578                    Object::Real(40.0),
579                ]),
580            ]),
581        );
582
583        let arr = Object::Array(vec![Object::Dictionary(dict)]);
584        let result = parse_annotations(&arr, &store).unwrap();
585        assert_eq!(
586            result[0].subtype_data.ink_list,
587            Some(vec![
588                vec![10.0, 10.0, 20.0, 20.0],
589                vec![30.0, 30.0, 40.0, 40.0],
590            ])
591        );
592    }
593
594    #[test]
595    fn test_freetext_default_appearance() {
596        let store = build_store();
597        let mut dict = HashMap::new();
598        dict.insert(Name::subtype(), Object::Name(Name::from("FreeText")));
599        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 200.0, 50.0));
600        dict.insert(
601            Name::da(),
602            Object::String(PdfString::from_bytes(b"0 g /Helv 12 Tf".to_vec())),
603        );
604
605        let arr = Object::Array(vec![Object::Dictionary(dict)]);
606        let result = parse_annotations(&arr, &store).unwrap();
607        assert_eq!(
608            result[0].subtype_data.default_appearance,
609            Some("0 g /Helv 12 Tf".to_string())
610        );
611    }
612
613    #[test]
614    fn test_stamp_name_field() {
615        let store = build_store();
616        let mut dict = HashMap::new();
617        dict.insert(Name::subtype(), Object::Name(Name::from("Stamp")));
618        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 150.0, 80.0));
619        dict.insert(Name::name_key(), Object::Name(Name::from("Approved")));
620
621        let arr = Object::Array(vec![Object::Dictionary(dict)]);
622        let result = parse_annotations(&arr, &store).unwrap();
623        assert_eq!(
624            result[0].subtype_data.stamp_name,
625            Some("Approved".to_string())
626        );
627    }
628
629    #[test]
630    fn test_text_annotation_no_subtype_data() {
631        let store = build_store();
632        let mut dict = HashMap::new();
633        dict.insert(Name::subtype(), Object::Name(Name::from("Text")));
634        dict.insert(Name::rect(), make_rect_array(0.0, 0.0, 50.0, 50.0));
635
636        let arr = Object::Array(vec![Object::Dictionary(dict)]);
637        let result = parse_annotations(&arr, &store).unwrap();
638        let data = &result[0].subtype_data;
639        assert!(data.quad_points.is_none());
640        assert!(data.line_points.is_none());
641        assert!(data.leader_line_length.is_none());
642        assert!(data.vertices.is_none());
643        assert!(data.ink_list.is_none());
644        assert!(data.default_appearance.is_none());
645        assert!(data.stamp_name.is_none());
646    }
647
648    #[test]
649    fn test_popup_annotation_parent_ref() {
650        let store = build_store();
651        let mut popup_dict = HashMap::new();
652        popup_dict.insert(Name::subtype(), Object::Name(Name::from("Popup")));
653        popup_dict.insert(Name::rect(), make_rect_array(200.0, 700.0, 400.0, 800.0));
654        popup_dict.insert(Name::parent(), Object::Reference(ObjectId::new(5, 0)));
655
656        let arr = Object::Array(vec![Object::Dictionary(popup_dict)]);
657        let result = parse_annotations(&arr, &store).unwrap();
658        assert_eq!(result[0].subtype, AnnotationType::Popup);
659        assert_eq!(result[0].parent_ref, Some(ObjectId::new(5, 0)));
660    }
661
662    #[test]
663    fn test_find_parent_annotation_by_object_id() {
664        let text_annot = Annotation {
665            subtype: AnnotationType::Text,
666            rect: [0.0, 0.0, 50.0, 50.0],
667            contents: Some("Note".into()),
668            flags: AnnotationFlags::from_bits(0),
669            name: None,
670            appearance: None,
671            color: None,
672            border: None,
673            action: None,
674            destination: None,
675            subtype_data: AnnotationSubtypeData::default(),
676            mk: None,
677            file_spec: None,
678            parent_ref: None,
679            object_id: Some(ObjectId::new(5, 0)),
680            open: None,
681            ap_n_bytes: None,
682            ap_r_bytes: None,
683            ap_d_bytes: None,
684            irt_ref: None,
685            field_name: None,
686            alternate_name: None,
687            field_value: None,
688            form_field_flags: None,
689            additional_actions: None,
690            form_field_type: None,
691            options: None,
692        };
693
694        let popup_annot = Annotation {
695            subtype: AnnotationType::Popup,
696            rect: [200.0, 700.0, 400.0, 800.0],
697            contents: None,
698            flags: AnnotationFlags::from_bits(0),
699            name: None,
700            appearance: None,
701            color: None,
702            border: None,
703            action: None,
704            destination: None,
705            subtype_data: AnnotationSubtypeData::default(),
706            mk: None,
707            file_spec: None,
708            parent_ref: Some(ObjectId::new(5, 0)),
709            object_id: Some(ObjectId::new(6, 0)),
710            open: None,
711            ap_n_bytes: None,
712            ap_r_bytes: None,
713            ap_d_bytes: None,
714            irt_ref: None,
715            field_name: None,
716            alternate_name: None,
717            field_value: None,
718            form_field_flags: None,
719            additional_actions: None,
720            form_field_type: None,
721            options: None,
722        };
723
724        let annotations = vec![text_annot, popup_annot.clone()];
725        let parent_idx = find_parent_annotation(&annotations, &popup_annot);
726        assert_eq!(parent_idx, Some(0));
727    }
728
729    #[test]
730    fn test_find_parent_annotation_no_match() {
731        let popup_annot = Annotation {
732            subtype: AnnotationType::Popup,
733            rect: [0.0, 0.0, 100.0, 100.0],
734            contents: None,
735            flags: AnnotationFlags::from_bits(0),
736            name: None,
737            appearance: None,
738            color: None,
739            border: None,
740            action: None,
741            destination: None,
742            subtype_data: AnnotationSubtypeData::default(),
743            mk: None,
744            file_spec: None,
745            parent_ref: Some(ObjectId::new(99, 0)),
746            object_id: None,
747            open: None,
748            ap_n_bytes: None,
749            ap_r_bytes: None,
750            ap_d_bytes: None,
751            irt_ref: None,
752            field_name: None,
753            alternate_name: None,
754            field_value: None,
755            form_field_flags: None,
756            additional_actions: None,
757            form_field_type: None,
758            options: None,
759        };
760
761        let annotations: Vec<Annotation> = Vec::new();
762        assert!(find_parent_annotation(&annotations, &popup_annot).is_none());
763    }
764
765    // ------------------------------------------------------------------
766    // annotation_at_point — FPDFAnnot_GetFormFieldAtPoint
767    // ------------------------------------------------------------------
768
769    fn make_annotation(rect: [f32; 4]) -> Annotation {
770        Annotation {
771            subtype: AnnotationType::Widget,
772            rect,
773            contents: None,
774            flags: AnnotationFlags::from_bits(0),
775            name: None,
776            appearance: None,
777            color: None,
778            border: None,
779            action: None,
780            destination: None,
781            subtype_data: AnnotationSubtypeData::default(),
782            mk: None,
783            file_spec: None,
784            parent_ref: None,
785            object_id: None,
786            open: None,
787            ap_n_bytes: None,
788            ap_r_bytes: None,
789            ap_d_bytes: None,
790            irt_ref: None,
791            field_name: None,
792            alternate_name: None,
793            field_value: None,
794            form_field_flags: None,
795            additional_actions: None,
796            form_field_type: None,
797            options: None,
798        }
799    }
800
801    #[test]
802    fn test_annotation_at_point_empty() {
803        let annotations: Vec<Annotation> = Vec::new();
804        assert!(annotation_at_point(&annotations, 50.0, 50.0).is_none());
805    }
806
807    #[test]
808    fn test_annotation_at_point_hit() {
809        let annotations = vec![make_annotation([10.0, 20.0, 100.0, 80.0])];
810        // Point inside the rect
811        let idx = annotation_at_point(&annotations, 50.0, 50.0);
812        assert_eq!(idx, Some(0));
813    }
814
815    #[test]
816    fn test_annotation_at_point_miss() {
817        let annotations = vec![make_annotation([10.0, 20.0, 100.0, 80.0])];
818        // Point clearly outside the rect
819        assert!(annotation_at_point(&annotations, 200.0, 200.0).is_none());
820    }
821
822    #[test]
823    fn test_annotation_at_point_returns_topmost() {
824        // Two overlapping annotations; the second (index 1) is on top (higher z-order).
825        let annotations = vec![
826            make_annotation([0.0, 0.0, 100.0, 100.0]),
827            make_annotation([0.0, 0.0, 100.0, 100.0]),
828        ];
829        // Should return index 1 — the topmost (last) annotation containing the point.
830        let idx = annotation_at_point(&annotations, 50.0, 50.0);
831        assert_eq!(idx, Some(1));
832    }
833
834    // -----------------------------------------------------------------------
835    // Upstream: cpdf_annotlist_unittest.cpp — Popup auto-creation tests
836    //
837    // These tests cover automatic Popup annotation creation from /Contents
838    // with various encodings (PDFDocEncoding, Unicode BOM, empty strings).
839    // The feature is NOT yet implemented in rpdfium.
840    // -----------------------------------------------------------------------
841
842    /// Upstream: TEST_F(CPDFAnnotListTest, CreatePopupAnnotFromPdfEncoded)
843    #[test]
844    #[ignore = "popup auto-creation from /Contents not yet implemented"]
845    fn test_cpdf_annot_list_create_popup_annot_from_pdf_encoded() {
846        // TODO: implement popup auto-creation
847        // The annotation list should auto-create a Popup annotation when
848        // a Text annotation has a non-empty /Contents with PDFDocEncoding.
849        // After creation: list.count() == 2, popup's raw /Contents matches
850        // the original, and decoded contents == "Aaae"
851        todo!()
852    }
853
854    /// Upstream: TEST_F(CPDFAnnotListTest, CreatePopupAnnotFromUnicode)
855    #[test]
856    #[ignore = "popup auto-creation from /Contents not yet implemented"]
857    fn test_cpdf_annot_list_create_popup_annot_from_unicode() {
858        // TODO: implement popup auto-creation
859        // Unicode BOM-prefixed /Contents: FE FF 00 41 00 61 00 E4 20 AC D8 3C DF A8
860        // After creation: list.count() == 2, decoded contents include emoji
861        todo!()
862    }
863
864    /// Upstream: TEST_F(CPDFAnnotListTest, CreatePopupAnnotFromEmptyPdfEncoded)
865    #[test]
866    #[ignore = "popup auto-creation from /Contents not yet implemented"]
867    fn test_cpdf_annot_list_create_popup_annot_from_empty_pdf_encoded() {
868        // TODO: implement popup auto-creation
869        // Empty PDFDocEncoded /Contents => NO popup created, list.count() == 1
870        todo!()
871    }
872
873    /// Upstream: TEST_F(CPDFAnnotListTest, CreatePopupAnnotFromEmptyUnicode)
874    #[test]
875    #[ignore = "popup auto-creation from /Contents not yet implemented"]
876    fn test_cpdf_annot_list_create_popup_annot_from_empty_unicode() {
877        // TODO: implement popup auto-creation
878        // Unicode BOM only (FE FF) with no content => NO popup, list.count() == 1
879        todo!()
880    }
881
882    /// Upstream: TEST_F(CPDFAnnotListTest, CreatePopupAnnotFromEmptyUnicodedWithEscape)
883    #[test]
884    #[ignore = "popup auto-creation from /Contents not yet implemented"]
885    fn test_cpdf_annot_list_create_popup_annot_from_empty_unicoded_with_escape() {
886        // TODO: implement popup auto-creation
887        // Unicode with only escape sequences (FE FF 00 1B 6A 61 00 1B)
888        // => effectively empty, NO popup, list.count() == 1
889        todo!()
890    }
891}