Skip to main content

rpdfium_edit/
fpdf_annot.rs

1// Derived from PDFium's fpdfsdk/fpdf_annot.cpp / core/fpdfdoc/cpdf_annot.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 editing — create, modify, and delete annotations on pages.
7
8use std::collections::HashMap;
9
10use rpdfium_core::Name;
11use rpdfium_doc::{Annotation, AnnotationFlags, AnnotationType, generate_annotation_appearance};
12use rpdfium_parser::object::{Object, ObjectId, StreamData};
13
14use crate::cpdf_pagecontentgenerator::{
15    ResourceCollector, build_resource_dict, generate_content_stream,
16};
17use crate::document::EditDocument;
18use crate::error::EditError;
19use crate::page_object::PageObject;
20
21/// Specification for a new annotation.
22#[derive(Debug, Clone)]
23pub struct AnnotationSpec {
24    /// The annotation subtype.
25    pub subtype: AnnotationType,
26    /// The annotation rectangle [x1, y1, x2, y2].
27    pub rect: [f32; 4],
28    /// Optional text contents.
29    pub contents: Option<String>,
30    /// Optional color components (RGB: 3, CMYK: 4, Gray: 1).
31    pub color: Option<Vec<f32>>,
32    /// Annotation flags.
33    pub flags: AnnotationFlags,
34    /// Optional quad points (for markup annotations).
35    pub quad_points: Option<Vec<f32>>,
36}
37
38/// Builder for creating annotation specifications.
39pub struct AnnotationBuilder {
40    spec: AnnotationSpec,
41}
42
43impl AnnotationBuilder {
44    /// Create a new annotation builder.
45    pub fn new(subtype: AnnotationType, rect: [f32; 4]) -> Self {
46        Self {
47            spec: AnnotationSpec {
48                subtype,
49                rect,
50                contents: None,
51                color: None,
52                flags: AnnotationFlags::default(),
53                quad_points: None,
54            },
55        }
56    }
57
58    /// Set the annotation contents text.
59    pub fn contents(mut self, text: &str) -> Self {
60        self.spec.contents = Some(text.to_string());
61        self
62    }
63
64    /// Set the annotation color.
65    pub fn color(mut self, color: Vec<f32>) -> Self {
66        self.spec.color = Some(color);
67        self
68    }
69
70    /// Set the annotation flags.
71    pub fn flags(mut self, flags: AnnotationFlags) -> Self {
72        self.spec.flags = flags;
73        self
74    }
75
76    /// Set quad points (for highlight, underline, etc.).
77    pub fn quad_points(mut self, points: Vec<f32>) -> Self {
78        self.spec.quad_points = Some(points);
79        self
80    }
81
82    /// Build the annotation specification.
83    pub fn build(self) -> AnnotationSpec {
84        self.spec
85    }
86}
87
88/// Updates to apply to an existing annotation.
89#[derive(Debug, Clone, Default)]
90pub struct AnnotationUpdates {
91    /// New contents text.
92    pub contents: Option<String>,
93    /// New color.
94    pub color: Option<Vec<f32>>,
95    /// New flags.
96    pub flags: Option<AnnotationFlags>,
97    /// New rect.
98    pub rect: Option<[f32; 4]>,
99    /// Open/closed state for Text and Popup annotations (`/Open`).
100    pub open: Option<bool>,
101    /// Annotation subject (`/Subj`).
102    ///
103    /// Corresponds to `FPDFAnnot_SetStringValue` with key `/Subj`.
104    pub subject: Option<String>,
105    /// Annotation author/title (`/T`).
106    ///
107    /// Corresponds to `FPDFAnnot_SetStringValue` with key `/T`.
108    pub author: Option<String>,
109    /// Simple border width in points (`/BS /W`).
110    ///
111    /// Sets the border-style dictionary width.  Pass `Some(0.0)` to hide the
112    /// border; `None` leaves the existing border unchanged.
113    ///
114    /// Corresponds to `FPDFAnnot_SetBorderWidth`.
115    pub border_width: Option<f32>,
116
117    /// Full border triple `(horizontal_radius, vertical_radius, width)` written to `/Border`.
118    ///
119    /// This takes precedence over `border_width` when both are set.
120    ///
121    /// Corresponds to `FPDFAnnot_SetBorder()`.
122    pub border_style: Option<(f32, f32, f32)>,
123
124    /// Annotation color as `(red, green, blue, alpha)` in 0–255 range.
125    ///
126    /// Stored as a 3-component `/C` array (RGB) in the PDF dictionary; the alpha
127    /// channel is currently ignored (PDF does not support annotation opacity via `/C`).
128    ///
129    /// Corresponds to `FPDFAnnot_SetColor()`.
130    pub color_rgba: Option<(u8, u8, u8, u8)>,
131
132    /// Attachment (quad) point groups for markup annotations.
133    ///
134    /// Each element is 8 floats `[x1,y1, x2,y2, x3,y3, x4,y4]`.
135    /// Replaces the entire `/QuadPoints` array.
136    ///
137    /// Corresponds to `FPDFAnnot_SetAttachmentPoints()` (for a single group) /
138    /// writing all groups at once.
139    pub attachment_points: Option<Vec<[f32; 8]>>,
140
141    /// Ink strokes for Ink annotations.
142    ///
143    /// Each inner `Vec<f32>` is a flat `[x, y, x, y, …]` sequence for one stroke.
144    /// Replaces the entire `/InkList` array.
145    ///
146    /// Corresponds to `FPDFAnnot_AddInkStroke()` / `FPDFAnnot_RemoveInkList()`.
147    pub ink_list: Option<Vec<Vec<f32>>>,
148
149    /// Arbitrary string value to write: `(key, value)`.
150    ///
151    /// Writes `/<key> <value-as-PDF-string>` into the annotation dictionary.
152    /// Useful for less common keys not covered by the other fields.
153    ///
154    /// Corresponds to `FPDFAnnot_SetStringValue()`.
155    pub extra_string: Option<(String, String)>,
156
157    /// Polygon/PolyLine vertex pairs to write as `/Vertices`.
158    ///
159    /// Each element is an `[x, y]` coordinate pair. Replaces the entire
160    /// `/Vertices` array in the annotation dictionary.
161    ///
162    /// Corresponds to `FPDFAnnot_SetVertices()`.
163    pub vertices: Option<Vec<[f32; 2]>>,
164
165    /// Set a URI action on the annotation (`/A << /S /URI /URI (uri) >>`).
166    ///
167    /// Writes an action dictionary with subtype `/URI` and the given URI string
168    /// into the annotation's `/A` entry.
169    ///
170    /// Corresponds to `FPDFAnnot_SetURI()`.
171    pub uri_action: Option<String>,
172}
173
174impl AnnotationUpdates {
175    /// Append a new set of attachment points (quadpoints) to the list.
176    ///
177    /// If `attachment_points` is currently `None`, this initialises it to an
178    /// empty list before appending. Note that once any element is set, the
179    /// **entire** attachment-points array will be written on
180    /// [`EditDocument::update_annotation`](crate::document::EditDocument::update_annotation).
181    ///
182    /// # Upstream Correspondence
183    ///
184    /// `FPDFAnnot_AppendAttachmentPoints`.
185    pub fn append_attachment_points(&mut self, quad: [f32; 8]) -> &mut Self {
186        self.attachment_points
187            .get_or_insert_with(Vec::new)
188            .push(quad);
189        self
190    }
191}
192
193impl EditDocument {
194    /// Add an annotation to a page.
195    ///
196    /// Returns the object ID of the new annotation dictionary.
197    pub fn add_annotation(
198        &mut self,
199        page_index: usize,
200        spec: AnnotationSpec,
201    ) -> Result<ObjectId, EditError> {
202        let count = self.page_count();
203        if page_index >= count {
204            return Err(EditError::PageOutOfRange {
205                index: page_index,
206                count,
207            });
208        }
209
210        // Build annotation dictionary
211        let mut annot_dict = HashMap::new();
212        annot_dict.insert(Name::r#type(), Object::Name(Name::annot()));
213        annot_dict.insert(
214            Name::subtype(),
215            Object::Name(Name::from_bytes(
216                annotation_type_name(&spec.subtype).to_vec(),
217            )),
218        );
219        annot_dict.insert(
220            Name::rect(),
221            Object::Array(vec![
222                Object::Real(spec.rect[0] as f64),
223                Object::Real(spec.rect[1] as f64),
224                Object::Real(spec.rect[2] as f64),
225                Object::Real(spec.rect[3] as f64),
226            ]),
227        );
228
229        if let Some(text) = &spec.contents {
230            annot_dict.insert(
231                Name::contents(),
232                Object::String(rpdfium_core::PdfString::from_bytes(
233                    text.as_bytes().to_vec(),
234                )),
235            );
236        }
237
238        if let Some(color) = &spec.color {
239            let color_arr: Vec<Object> = color.iter().map(|&c| Object::Real(c as f64)).collect();
240            annot_dict.insert(Name::c(), Object::Array(color_arr));
241        }
242
243        let flags_val = spec.flags.bits();
244        if flags_val != 0 {
245            annot_dict.insert(Name::f(), Object::Integer(flags_val as i64));
246        }
247
248        if let Some(qp) = &spec.quad_points {
249            let qp_arr: Vec<Object> = qp.iter().map(|&p| Object::Real(p as f64)).collect();
250            annot_dict.insert(Name::quad_points(), Object::Array(qp_arr));
251        }
252
253        // Set /P (parent page reference) — PDF spec recommends this entry.
254        let page_id = self.page_ids()[page_index];
255        annot_dict.insert(Name::p(), Object::Reference(page_id));
256
257        // Auto-generate appearance stream (AP) so viewers can render the annotation.
258        // Build a minimal Annotation struct for the AP generator.
259        let temp_annot = Annotation {
260            subtype: spec.subtype,
261            rect: spec.rect,
262            contents: spec.contents.clone(),
263            flags: spec.flags,
264            color: spec.color.clone(),
265            name: None,
266            appearance: None,
267            border: None,
268            action: None,
269            destination: None,
270            subtype_data: Default::default(),
271            mk: None,
272            file_spec: None,
273            parent_ref: None,
274            object_id: None,
275            open: None,
276            ap_n_bytes: None,
277            ap_r_bytes: None,
278            ap_d_bytes: None,
279            irt_ref: None,
280            field_name: None,
281            alternate_name: None,
282            field_value: None,
283            form_field_flags: None,
284            additional_actions: None,
285            form_field_type: None,
286            options: None,
287        };
288        if let Some(ap_bytes) = generate_annotation_appearance(&temp_annot) {
289            // Create an AP stream XObject
290            let w = (spec.rect[2] - spec.rect[0]).abs();
291            let h = (spec.rect[3] - spec.rect[1]).abs();
292            let mut ap_stream_dict = HashMap::new();
293            ap_stream_dict.insert(Name::r#type(), Object::Name(Name::x_object()));
294            ap_stream_dict.insert(Name::subtype(), Object::Name(Name::form()));
295            ap_stream_dict.insert(
296                Name::b_box(),
297                Object::Array(vec![
298                    Object::Real(0.0),
299                    Object::Real(0.0),
300                    Object::Real(w as f64),
301                    Object::Real(h as f64),
302                ]),
303            );
304            let ap_stream = Object::Stream {
305                dict: ap_stream_dict,
306                data: StreamData::Decoded { data: ap_bytes },
307            };
308            let ap_id = self.add_object(ap_stream);
309
310            // Set /AP /N on the annotation dict
311            let mut ap_dict = HashMap::new();
312            ap_dict.insert(Name::n(), Object::Reference(ap_id));
313            annot_dict.insert(Name::ap(), Object::Dictionary(ap_dict));
314        }
315
316        let annot_id = self.add_object(Object::Dictionary(annot_dict));
317
318        // Add to page's /Annots array
319        let page = self.get_mut(page_id)?;
320        if let Object::Dictionary(dict) = page {
321            let annots = dict
322                .entry(Name::annots())
323                .or_insert_with(|| Object::Array(Vec::new()));
324            if let Object::Array(arr) = annots {
325                arr.push(Object::Reference(annot_id));
326            }
327        }
328
329        Ok(annot_id)
330    }
331
332    /// Delete an annotation from a page.
333    pub fn delete_annotation(
334        &mut self,
335        page_index: usize,
336        annot_id: ObjectId,
337    ) -> Result<(), EditError> {
338        let count = self.page_count();
339        if page_index >= count {
340            return Err(EditError::PageOutOfRange {
341                index: page_index,
342                count,
343            });
344        }
345
346        let page_id = self.page_ids()[page_index];
347        let page = self.get_mut(page_id)?;
348        if let Object::Dictionary(dict) = page {
349            if let Some(Object::Array(arr)) = dict.get_mut(&Name::annots()) {
350                arr.retain(|obj| {
351                    if let Object::Reference(id) = obj {
352                        *id != annot_id
353                    } else {
354                        true
355                    }
356                });
357            }
358        }
359
360        self.delete_object(annot_id);
361        Ok(())
362    }
363
364    /// Update an existing annotation.
365    ///
366    /// After applying changes, the appearance stream (/AP /N) is regenerated
367    /// so viewers display the updated annotation correctly (upstream:
368    /// `CPDFSDK_Widget::ResetAppearance()`).
369    pub fn update_annotation(
370        &mut self,
371        annot_id: ObjectId,
372        updates: AnnotationUpdates,
373    ) -> Result<(), EditError> {
374        // Apply updates to the annotation dict.
375        {
376            let annot = self.get_mut(annot_id)?;
377            let dict = annot
378                .as_dict_mut()
379                .ok_or(EditError::AnnotationNotFound(annot_id))?;
380
381            if let Some(text) = &updates.contents {
382                dict.insert(
383                    Name::contents(),
384                    Object::String(rpdfium_core::PdfString::from_bytes(
385                        text.as_bytes().to_vec(),
386                    )),
387                );
388            }
389
390            if let Some(color) = &updates.color {
391                let color_arr: Vec<Object> =
392                    color.iter().map(|&c| Object::Real(c as f64)).collect();
393                dict.insert(Name::c(), Object::Array(color_arr));
394            }
395
396            if let Some(flags) = &updates.flags {
397                dict.insert(Name::f(), Object::Integer(flags.bits() as i64));
398            }
399
400            if let Some(rect) = &updates.rect {
401                dict.insert(
402                    Name::rect(),
403                    Object::Array(vec![
404                        Object::Real(rect[0] as f64),
405                        Object::Real(rect[1] as f64),
406                        Object::Real(rect[2] as f64),
407                        Object::Real(rect[3] as f64),
408                    ]),
409                );
410            }
411
412            if let Some(open) = updates.open {
413                dict.insert(Name::open(), Object::Boolean(open));
414            }
415
416            if let Some(ref subject) = updates.subject {
417                dict.insert(
418                    Name::subj(),
419                    Object::String(rpdfium_core::PdfString::from_bytes(
420                        subject.as_bytes().to_vec(),
421                    )),
422                );
423            }
424
425            if let Some(ref author) = updates.author {
426                dict.insert(
427                    Name::t(),
428                    Object::String(rpdfium_core::PdfString::from_bytes(
429                        author.as_bytes().to_vec(),
430                    )),
431                );
432            }
433
434            if let Some(width) = updates.border_width {
435                // Write /BS << /W <width> >> border-style dict.
436                let mut bs_dict = HashMap::new();
437                bs_dict.insert(Name::from_bytes(b"W".to_vec()), Object::Real(width as f64));
438                dict.insert(Name::bs(), Object::Dictionary(bs_dict));
439            }
440
441            // Full border triple — /Border [h_rad v_rad width]
442            if let Some((h, v, w)) = updates.border_style {
443                dict.insert(
444                    Name::border(),
445                    Object::Array(vec![
446                        Object::Real(h as f64),
447                        Object::Real(v as f64),
448                        Object::Real(w as f64),
449                    ]),
450                );
451            }
452
453            // RGBA color → 3-component /C array (alpha not stored in PDF /C)
454            if let Some((r, g, b, _a)) = updates.color_rgba {
455                let to_f = |byte: u8| Object::Real(byte as f64 / 255.0);
456                dict.insert(Name::c(), Object::Array(vec![to_f(r), to_f(g), to_f(b)]));
457            }
458
459            // Attachment (quad) points → flat /QuadPoints array
460            if let Some(ref groups) = updates.attachment_points {
461                let flat: Vec<Object> = groups
462                    .iter()
463                    .flat_map(|g| g.iter().map(|&f| Object::Real(f as f64)))
464                    .collect();
465                dict.insert(Name::quad_points(), Object::Array(flat));
466            }
467
468            // Ink strokes → /InkList array-of-arrays
469            if let Some(ref strokes) = updates.ink_list {
470                let outer: Vec<Object> = strokes
471                    .iter()
472                    .map(|stroke| {
473                        Object::Array(stroke.iter().map(|&f| Object::Real(f as f64)).collect())
474                    })
475                    .collect();
476                dict.insert(Name::ink_list(), Object::Array(outer));
477            }
478
479            // Arbitrary string key-value
480            if let Some((ref key, ref value)) = updates.extra_string {
481                dict.insert(
482                    Name::from_bytes(key.as_bytes().to_vec()),
483                    Object::String(rpdfium_core::PdfString::from_bytes(
484                        value.as_bytes().to_vec(),
485                    )),
486                );
487            }
488
489            // Polygon/PolyLine vertices → flat /Vertices array
490            if let Some(ref verts) = updates.vertices {
491                let flat: Vec<Object> = verts
492                    .iter()
493                    .flat_map(|v| [Object::Real(v[0] as f64), Object::Real(v[1] as f64)])
494                    .collect();
495                dict.insert(Name::vertices(), Object::Array(flat));
496            }
497
498            // URI action → /A << /S /URI /URI (uri_string) >>
499            if let Some(ref uri) = updates.uri_action {
500                let mut action_dict = HashMap::new();
501                action_dict.insert(Name::s(), Object::Name(Name::uri()));
502                action_dict.insert(
503                    Name::uri(),
504                    Object::String(rpdfium_core::PdfString::from_bytes(uri.as_bytes().to_vec())),
505                );
506                dict.insert(Name::a(), Object::Dictionary(action_dict));
507            }
508        }
509
510        // G-R5: Regenerate appearance stream from updated dict.
511        regenerate_annotation_ap(self, annot_id)?;
512
513        Ok(())
514    }
515
516    /// Append a page object to an annotation's AP object list.
517    ///
518    /// The object is stored in memory keyed by `annot_id`.  Call
519    /// [`update_annotation_ap()`](Self::update_annotation_ap) afterwards to
520    /// regenerate the annotation's `/AP /N` stream from the accumulated list.
521    ///
522    /// Corresponds to `FPDFAnnot_AppendObject()`.
523    pub fn append_annotation_object(
524        &mut self,
525        annot_id: ObjectId,
526        object: PageObject,
527    ) -> Result<(), EditError> {
528        self.ap_objects.entry(annot_id).or_default().push(object);
529        Ok(())
530    }
531
532    /// Upstream-aligned alias for [`append_annotation_object()`](Self::append_annotation_object).
533    ///
534    /// Corresponds to `FPDFAnnot_AppendObject()`.
535    #[inline]
536    pub fn annot_append_object(
537        &mut self,
538        annot_id: ObjectId,
539        object: PageObject,
540    ) -> Result<(), EditError> {
541        self.append_annotation_object(annot_id, object)
542    }
543
544    /// Deprecated: use [`annot_append_object()`](Self::annot_append_object) — matches upstream `FPDFAnnot_AppendObject`.
545    #[deprecated(note = "use `annot_append_object()` — matches upstream `FPDFAnnot_AppendObject`")]
546    #[inline]
547    pub fn append_object(
548        &mut self,
549        annot_id: ObjectId,
550        object: PageObject,
551    ) -> Result<(), EditError> {
552        self.append_annotation_object(annot_id, object)
553    }
554
555    /// Return the number of objects in an annotation's AP object list.
556    ///
557    /// Returns `0` if no objects have been appended to `annot_id`.
558    ///
559    /// Corresponds to `FPDFAnnot_GetObjectCount()`.
560    pub fn annotation_object_count(&self, annot_id: ObjectId) -> usize {
561        self.ap_objects.get(&annot_id).map_or(0, Vec::len)
562    }
563
564    /// Non-upstream convenience alias for [`annotation_object_count()`](Self::annotation_object_count).
565    ///
566    /// Prefer [`annot_get_object_count()`](Self::annot_get_object_count), which matches the
567    /// upstream `FPDFAnnot_GetObjectCount` name exactly.
568    #[deprecated(
569        note = "use `annot_get_object_count()` — matches upstream `FPDFAnnot_GetObjectCount`"
570    )]
571    #[inline]
572    pub fn count_annotation_objects(&self, annot_id: ObjectId) -> usize {
573        self.annotation_object_count(annot_id)
574    }
575
576    /// Upstream-aligned alias for [`annotation_object_count()`](Self::annotation_object_count).
577    ///
578    /// Corresponds to `FPDFAnnot_GetObjectCount()`.
579    #[inline]
580    pub fn annot_get_object_count(&self, annot_id: ObjectId) -> usize {
581        self.annotation_object_count(annot_id)
582    }
583
584    /// Deprecated: use [`annot_get_object_count()`](Self::annot_get_object_count) — matches upstream `FPDFAnnot_GetObjectCount`.
585    #[deprecated(
586        note = "use `annot_get_object_count()` — matches upstream `FPDFAnnot_GetObjectCount`"
587    )]
588    #[inline]
589    pub fn get_object_count(&self, annot_id: ObjectId) -> usize {
590        self.annotation_object_count(annot_id)
591    }
592
593    /// Return a reference to the object at `index` in an annotation's AP object list.
594    ///
595    /// Returns `None` if `annot_id` has no objects or `index` is out of bounds.
596    ///
597    /// Corresponds to `FPDFAnnot_GetObject()`.
598    pub fn annotation_object_at(&self, annot_id: ObjectId, index: usize) -> Option<&PageObject> {
599        self.ap_objects.get(&annot_id)?.get(index)
600    }
601
602    /// Non-upstream convenience alias for [`annotation_object_at()`](Self::annotation_object_at).
603    ///
604    /// Prefer [`get_object()`](Self::get_object), which matches the upstream
605    /// `FPDFAnnot_GetObject` name exactly.
606    #[deprecated(note = "use `get_object()` — matches upstream FPDFAnnot_GetObject")]
607    #[inline]
608    pub fn get_annotation_object(&self, annot_id: ObjectId, index: usize) -> Option<&PageObject> {
609        self.annotation_object_at(annot_id, index)
610    }
611
612    /// Deprecated entry point for `FPDFAnnot_GetObject`.
613    ///
614    /// The exact T2 alias `get_object` cannot live on `EditDocument` alongside
615    /// `FPDFPage_GetObject` (same name, same struct).  Use the context type
616    /// instead:
617    ///
618    /// ```text
619    /// doc.annot_objects(annot_id).get_object(index)
620    /// ```
621    ///
622    /// The context type ([`AnnotObjectCtx`](crate::object_ctx::AnnotObjectCtx))
623    /// provides the correctly-namespaced `get_object` alias.
624    #[deprecated(note = "use `doc.annot_objects(annot_id).get_object(index)` — \
625                namespaced via AnnotObjectCtx to avoid collision with FPDFPage_GetObject")]
626    #[inline]
627    pub fn get_object(&self, annot_id: ObjectId, index: usize) -> Option<&PageObject> {
628        self.annotation_object_at(annot_id, index)
629    }
630
631    /// Return a mutable reference to the object at `index` in an annotation's AP object list.
632    ///
633    /// Returns `None` if `annot_id` has no objects or `index` is out of bounds.
634    pub fn annotation_object_at_mut(
635        &mut self,
636        annot_id: ObjectId,
637        index: usize,
638    ) -> Option<&mut PageObject> {
639        self.ap_objects.get_mut(&annot_id)?.get_mut(index)
640    }
641
642    /// Non-upstream convenience alias — use [`annotation_object_at_mut()`](Self::annotation_object_at_mut).
643    ///
644    /// There is no upstream `FPDFAnnot_GetObjectMut`; mutable access is
645    /// a Rust-specific addition.
646    #[deprecated(note = "use `annotation_object_at_mut()` — no exact upstream counterpart")]
647    #[inline]
648    pub fn get_annotation_object_mut(
649        &mut self,
650        annot_id: ObjectId,
651        index: usize,
652    ) -> Option<&mut PageObject> {
653        self.annotation_object_at_mut(annot_id, index)
654    }
655
656    /// Remove the object at `index` from an annotation's AP object list.
657    ///
658    /// Returns an error if `annot_id` has no AP objects or `index` is out of
659    /// bounds.
660    ///
661    /// Corresponds to `FPDFAnnot_RemoveObject()`.
662    pub fn remove_annotation_object(
663        &mut self,
664        annot_id: ObjectId,
665        index: usize,
666    ) -> Result<(), EditError> {
667        let list = self
668            .ap_objects
669            .get_mut(&annot_id)
670            .ok_or_else(|| EditError::NotSupported("annotation has no AP objects".into()))?;
671        if index >= list.len() {
672            return Err(EditError::NotSupported("index out of bounds".into()));
673        }
674        list.remove(index);
675        Ok(())
676    }
677
678    /// Deprecated entry point for `FPDFAnnot_RemoveObject`.
679    ///
680    /// The exact T2 alias `remove_object` cannot live on `EditDocument`
681    /// alongside `FPDFPage_RemoveObject` (same name, same struct).  Use the
682    /// context type instead:
683    ///
684    /// ```text
685    /// doc.annot_objects_mut(annot_id).remove_object(index)
686    /// ```
687    ///
688    /// The context type ([`AnnotObjectCtxMut`](crate::object_ctx::AnnotObjectCtxMut))
689    /// provides the correctly-namespaced `remove_object` alias.
690    #[deprecated(note = "use `doc.annot_objects_mut(annot_id).remove_object(index)` — \
691                namespaced via AnnotObjectCtxMut to avoid collision with FPDFPage_RemoveObject")]
692    #[inline]
693    pub fn remove_object(&mut self, annot_id: ObjectId, index: usize) -> Result<(), EditError> {
694        self.remove_annotation_object(annot_id, index)
695    }
696
697    /// Regenerate the annotation's `/AP /N` stream from the stored object list.
698    ///
699    /// Reads the object list stored via [`append_annotation_object()`](Self::append_annotation_object),
700    /// generates a PDF content stream, and writes it as a Form XObject into
701    /// the annotation's `/AP /N` entry.  The `/BBox` of the Form XObject is
702    /// derived from the annotation's current `/Rect`.
703    ///
704    /// Returns an error if no objects have been appended to `annot_id`.
705    ///
706    /// Corresponds to `FPDFAnnot_UpdateObject()`.
707    pub fn update_annotation_ap(&mut self, annot_id: ObjectId) -> Result<(), EditError> {
708        // Clone the object list to avoid holding an immutable borrow while we
709        // need to call `add_object()` (which requires &mut self).
710        let objects = self
711            .ap_objects
712            .get(&annot_id)
713            .ok_or_else(|| EditError::NotSupported("annotation has no AP objects".into()))?
714            .clone();
715
716        // Generate content stream and collect resource references.
717        let mut resources = ResourceCollector::new();
718        let content = generate_content_stream(&objects, &mut resources);
719
720        // Read the annotation's /Rect for the Form XObject /BBox.
721        let rect = {
722            let annot = self.resolve(annot_id)?;
723            let dict = annot
724                .as_dict()
725                .ok_or(EditError::AnnotationNotFound(annot_id))?;
726            dict.get(&Name::rect())
727                .and_then(|o| o.as_array())
728                .map(|arr| {
729                    let get = |i: usize| arr.get(i).and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
730                    [get(0), get(1), get(2), get(3)]
731                })
732                .unwrap_or([0.0; 4])
733        };
734
735        let w = (rect[2] - rect[0]).abs();
736        let h = (rect[3] - rect[1]).abs();
737
738        // Build the Form XObject stream dict.
739        let mut ap_stream_dict = HashMap::new();
740        ap_stream_dict.insert(
741            Name::r#type(),
742            Object::Name(Name::from_bytes(b"XObject".to_vec())),
743        );
744        ap_stream_dict.insert(
745            Name::subtype(),
746            Object::Name(Name::from_bytes(b"Form".to_vec())),
747        );
748        ap_stream_dict.insert(
749            Name::from_bytes(b"BBox".to_vec()),
750            Object::Array(vec![
751                Object::Real(0.0),
752                Object::Real(0.0),
753                Object::Real(w as f64),
754                Object::Real(h as f64),
755            ]),
756        );
757
758        // Attach resources dict only if non-empty (e.g. fonts for text objects).
759        let res_obj = build_resource_dict(&resources);
760        let has_resources = matches!(&res_obj, Object::Dictionary(d) if !d.is_empty());
761        if has_resources {
762            ap_stream_dict.insert(Name::from_bytes(b"Resources".to_vec()), res_obj);
763        }
764
765        let ap_stream = Object::Stream {
766            dict: ap_stream_dict,
767            data: StreamData::Decoded { data: content },
768        };
769        let ap_id = self.add_object(ap_stream);
770
771        // Write /AP /N reference into the annotation dict.
772        // Preserve any existing /D (Down) or /R (Rollover) streams — only /N is regenerated.
773        let annot = self.get_mut(annot_id)?;
774        if let Object::Dictionary(dict) = annot {
775            let ap_key = Name::from_bytes(b"AP".to_vec());
776            let n_key = Name::from_bytes(b"N".to_vec());
777            match dict.get_mut(&ap_key) {
778                Some(Object::Dictionary(existing)) => {
779                    existing.insert(n_key, Object::Reference(ap_id));
780                }
781                _ => {
782                    let mut ap_dict = HashMap::new();
783                    ap_dict.insert(n_key, Object::Reference(ap_id));
784                    dict.insert(ap_key, Object::Dictionary(ap_dict));
785                }
786            }
787        }
788
789        Ok(())
790    }
791
792    /// Upstream-aligned alias for [`update_annotation_ap()`](Self::update_annotation_ap).
793    ///
794    /// Corresponds to `FPDFAnnot_UpdateObject()`.
795    #[inline]
796    pub fn annot_update_object(&mut self, annot_id: ObjectId) -> Result<(), EditError> {
797        self.update_annotation_ap(annot_id)
798    }
799
800    /// Deprecated: use [`annot_update_object()`](Self::annot_update_object) — matches upstream `FPDFAnnot_UpdateObject`.
801    #[deprecated(note = "use `annot_update_object()` — matches upstream `FPDFAnnot_UpdateObject`")]
802    #[inline]
803    pub fn update_object(&mut self, annot_id: ObjectId) -> Result<(), EditError> {
804        self.update_annotation_ap(annot_id)
805    }
806
807    /// Add an embedded file attachment to a file-attachment annotation.
808    ///
809    /// **ADR-017 stub** — file-attachment annotation mutation is not yet
810    /// implemented. This stub is provided for API completeness per ADR-017.
811    ///
812    /// Corresponds to `FPDFAnnot_AddFileAttachment()`.
813    pub fn add_annotation_file_attachment(
814        &mut self,
815        _annot_id: rpdfium_parser::ObjectId,
816        _name: &str,
817    ) -> Result<(), crate::error::EditError> {
818        Err(crate::error::EditError::NotSupported(
819            "add_annotation_file_attachment: file-attachment annotation mutation not yet implemented"
820                .into(),
821        ))
822    }
823
824    /// Upstream-aligned alias for [`add_annotation_file_attachment()`](Self::add_annotation_file_attachment).
825    ///
826    /// Corresponds to `FPDFAnnot_AddFileAttachment`.
827    #[inline]
828    pub fn annot_add_file_attachment(
829        &mut self,
830        annot_id: rpdfium_parser::ObjectId,
831        name: &str,
832    ) -> Result<(), crate::error::EditError> {
833        self.add_annotation_file_attachment(annot_id, name)
834    }
835
836    /// Non-upstream alias — use [`annot_add_file_attachment()`](Self::annot_add_file_attachment).
837    #[deprecated(
838        note = "use `annot_add_file_attachment()` — matches upstream `FPDFAnnot_AddFileAttachment`"
839    )]
840    #[inline]
841    pub fn add_file_attachment(
842        &mut self,
843        annot_id: rpdfium_parser::ObjectId,
844        name: &str,
845    ) -> Result<(), crate::error::EditError> {
846        self.add_annotation_file_attachment(annot_id, name)
847    }
848
849    // -----------------------------------------------------------------------
850    // Appearance stream set/get  (FPDFAnnot_SetAP / FPDFAnnot_GetAP)
851    // -----------------------------------------------------------------------
852
853    /// Set the appearance stream content for the given AP mode on an annotation.
854    ///
855    /// Writes `content` as a decoded `/AP /<mode>` stream on the annotation
856    /// dictionary at `annot_id`.  Pass an empty slice to clear the stream for
857    /// that mode.
858    ///
859    /// Corresponds to `FPDFAnnot_SetAP`.
860    pub fn annotation_set_ap(
861        &mut self,
862        annot_id: ObjectId,
863        mode: rpdfium_doc::AppearanceMode,
864        content: &[u8],
865    ) -> Result<(), EditError> {
866        let mode_key = ap_mode_name(mode);
867
868        // Build a Form XObject stream for the appearance.
869        let ap_stream = Object::Stream {
870            dict: HashMap::new(),
871            data: StreamData::Decoded {
872                data: content.to_vec(),
873            },
874        };
875        let ap_id = self.add_object(ap_stream);
876
877        // Write /AP /<mode> into the annotation dict.
878        let ap_key = Name::ap();
879        let n_key = Name::from_bytes(mode_key.to_vec());
880        let annot = self.get_mut(annot_id)?;
881        if let Object::Dictionary(dict) = annot {
882            match dict.get_mut(&ap_key) {
883                Some(Object::Dictionary(existing)) => {
884                    existing.insert(n_key, Object::Reference(ap_id));
885                }
886                _ => {
887                    let mut ap_dict = HashMap::new();
888                    ap_dict.insert(n_key, Object::Reference(ap_id));
889                    dict.insert(ap_key, Object::Dictionary(ap_dict));
890                }
891            }
892        }
893        Ok(())
894    }
895
896    /// Upstream-aligned alias for [`annotation_set_ap()`](Self::annotation_set_ap).
897    ///
898    /// Corresponds to `FPDFAnnot_SetAP`.
899    #[inline]
900    pub fn annot_set_ap(
901        &mut self,
902        annot_id: ObjectId,
903        mode: rpdfium_doc::AppearanceMode,
904        content: &[u8],
905    ) -> Result<(), EditError> {
906        self.annotation_set_ap(annot_id, mode, content)
907    }
908
909    /// Non-upstream alias — use [`annot_set_ap()`](Self::annot_set_ap).
910    #[deprecated(note = "use `annot_set_ap()` — matches upstream `FPDFAnnot_SetAP`")]
911    #[inline]
912    pub fn set_ap(
913        &mut self,
914        annot_id: ObjectId,
915        mode: rpdfium_doc::AppearanceMode,
916        content: &[u8],
917    ) -> Result<(), EditError> {
918        self.annotation_set_ap(annot_id, mode, content)
919    }
920
921    /// Return the decoded appearance stream bytes for the given AP mode, or
922    /// `None` if no AP stream is set for that mode.
923    ///
924    /// Corresponds to `FPDFAnnot_GetAP`.
925    pub fn annotation_get_ap(
926        &self,
927        annot_id: ObjectId,
928        mode: rpdfium_doc::AppearanceMode,
929    ) -> Result<Option<Vec<u8>>, EditError> {
930        let mode_key = ap_mode_name(mode);
931        let ap_key = Name::ap();
932        let n_key = Name::from_bytes(mode_key.to_vec());
933
934        // Extract the AP stream reference in a nested scope to end borrows on `self`
935        // before we call `resolve` or `decode_stream` a second time.
936        let ap_ref: ObjectId = {
937            let annot = self.resolve(annot_id)?;
938            let dict = annot
939                .as_dict()
940                .ok_or(EditError::AnnotationNotFound(annot_id))?;
941            let ap_dict = match dict.get(&ap_key) {
942                Some(Object::Dictionary(d)) => d,
943                _ => return Ok(None),
944            };
945            match ap_dict.get(&n_key) {
946                Some(Object::Reference(id)) => *id,
947                _ => return Ok(None),
948            }
949        }; // borrows on `self` end here
950
951        let ap_obj = self.resolve(ap_ref)?.clone();
952        match &ap_obj {
953            Object::Stream { data, .. } => match data {
954                StreamData::Decoded { data } => Ok(Some(data.clone())),
955                StreamData::Raw { .. } => self.decode_stream(&ap_obj).map(Some),
956            },
957            _ => Ok(None),
958        }
959    }
960
961    /// Upstream-aligned alias for [`annotation_get_ap()`](Self::annotation_get_ap).
962    ///
963    /// Corresponds to `FPDFAnnot_GetAP`.
964    #[inline]
965    pub fn annot_get_ap(
966        &self,
967        annot_id: ObjectId,
968        mode: rpdfium_doc::AppearanceMode,
969    ) -> Result<Option<Vec<u8>>, EditError> {
970        self.annotation_get_ap(annot_id, mode)
971    }
972
973    /// Non-upstream alias — use [`annot_get_ap()`](Self::annot_get_ap).
974    #[deprecated(note = "use `annot_get_ap()` — matches upstream `FPDFAnnot_GetAP`")]
975    #[inline]
976    pub fn get_ap(
977        &self,
978        annot_id: ObjectId,
979        mode: rpdfium_doc::AppearanceMode,
980    ) -> Result<Option<Vec<u8>>, EditError> {
981        self.annotation_get_ap(annot_id, mode)
982    }
983}
984
985/// Map AppearanceMode to the PDF /AP sub-key byte string (b"N", b"R", or b"D").
986fn ap_mode_name(mode: rpdfium_doc::AppearanceMode) -> &'static [u8] {
987    match mode {
988        rpdfium_doc::AppearanceMode::Normal => b"N",
989        rpdfium_doc::AppearanceMode::Rollover => b"R",
990        rpdfium_doc::AppearanceMode::Down => b"D",
991    }
992}
993
994/// Map annotation type to PDF name.
995fn annotation_type_name(ty: &AnnotationType) -> &'static [u8] {
996    match ty {
997        AnnotationType::Text => b"Text",
998        AnnotationType::Link => b"Link",
999        AnnotationType::FreeText => b"FreeText",
1000        AnnotationType::Line => b"Line",
1001        AnnotationType::Square => b"Square",
1002        AnnotationType::Circle => b"Circle",
1003        AnnotationType::Polygon => b"Polygon",
1004        AnnotationType::PolyLine => b"PolyLine",
1005        AnnotationType::Highlight => b"Highlight",
1006        AnnotationType::Underline => b"Underline",
1007        AnnotationType::Squiggly => b"Squiggly",
1008        AnnotationType::StrikeOut => b"StrikeOut",
1009        AnnotationType::Stamp => b"Stamp",
1010        AnnotationType::Caret => b"Caret",
1011        AnnotationType::Ink => b"Ink",
1012        AnnotationType::Popup => b"Popup",
1013        AnnotationType::FileAttachment => b"FileAttachment",
1014        AnnotationType::Sound => b"Sound",
1015        AnnotationType::Movie => b"Movie",
1016        AnnotationType::Widget => b"Widget",
1017        AnnotationType::Screen => b"Screen",
1018        AnnotationType::PrinterMark => b"PrinterMark",
1019        AnnotationType::TrapNet => b"TrapNet",
1020        AnnotationType::Watermark => b"Watermark",
1021        AnnotationType::ThreeD => b"3D",
1022        AnnotationType::RichMedia => b"RichMedia",
1023        AnnotationType::XFAWidget => b"XFAWidget",
1024        AnnotationType::Redact => b"Redact",
1025        AnnotationType::Other => b"Unknown",
1026    }
1027}
1028
1029/// Regenerate the AP (appearance) stream for an annotation after updates.
1030///
1031/// Reads the current annotation dict, builds a minimal `Annotation`, calls
1032/// `generate_annotation_appearance()`, and replaces /AP /N in the dict.
1033fn regenerate_annotation_ap(doc: &mut EditDocument, annot_id: ObjectId) -> Result<(), EditError> {
1034    // Read current annotation fields (immutable borrow).
1035    let (subtype, rect, contents, color, flags) = {
1036        let annot = doc.resolve(annot_id)?;
1037        let dict = annot
1038            .as_dict()
1039            .ok_or(EditError::AnnotationNotFound(annot_id))?;
1040
1041        let subtype = dict
1042            .get(&Name::subtype())
1043            .and_then(|o| o.as_name())
1044            .map(|n| name_to_annotation_type(n.as_bytes()))
1045            .unwrap_or(AnnotationType::Other);
1046
1047        let rect = dict
1048            .get(&Name::rect())
1049            .and_then(|o| o.as_array())
1050            .map(|arr| {
1051                let get = |i: usize| arr.get(i).and_then(|v| v.as_f64()).unwrap_or(0.0) as f32;
1052                [get(0), get(1), get(2), get(3)]
1053            })
1054            .unwrap_or([0.0; 4]);
1055
1056        let contents = dict
1057            .get(&Name::contents())
1058            .and_then(|o| o.as_string())
1059            .map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned());
1060
1061        let color = dict.get(&Name::c()).and_then(|o| o.as_array()).map(|arr| {
1062            arr.iter()
1063                .filter_map(|v| v.as_f64().map(|f| f as f32))
1064                .collect::<Vec<f32>>()
1065        });
1066
1067        let flags_val = dict.get(&Name::f()).and_then(|o| o.as_i64()).unwrap_or(0);
1068        let flags = AnnotationFlags::from_bits(flags_val as u32);
1069
1070        (subtype, rect, contents, color, flags)
1071    };
1072
1073    // Build temporary Annotation for AP generation.
1074    let temp_annot = Annotation {
1075        subtype,
1076        rect,
1077        contents,
1078        flags,
1079        color,
1080        name: None,
1081        appearance: None,
1082        border: None,
1083        action: None,
1084        destination: None,
1085        subtype_data: Default::default(),
1086        mk: None,
1087        file_spec: None,
1088        parent_ref: None,
1089        object_id: None,
1090        open: None,
1091        ap_n_bytes: None,
1092        ap_r_bytes: None,
1093        ap_d_bytes: None,
1094        irt_ref: None,
1095        field_name: None,
1096        alternate_name: None,
1097        field_value: None,
1098        form_field_flags: None,
1099        additional_actions: None,
1100        form_field_type: None,
1101        options: None,
1102    };
1103
1104    if let Some(ap_bytes) = generate_annotation_appearance(&temp_annot) {
1105        let w = (rect[2] - rect[0]).abs();
1106        let h = (rect[3] - rect[1]).abs();
1107        let mut ap_stream_dict = HashMap::new();
1108        ap_stream_dict.insert(
1109            Name::r#type(),
1110            Object::Name(Name::from_bytes(b"XObject".to_vec())),
1111        );
1112        ap_stream_dict.insert(
1113            Name::subtype(),
1114            Object::Name(Name::from_bytes(b"Form".to_vec())),
1115        );
1116        ap_stream_dict.insert(
1117            Name::from_bytes(b"BBox".to_vec()),
1118            Object::Array(vec![
1119                Object::Real(0.0),
1120                Object::Real(0.0),
1121                Object::Real(w as f64),
1122                Object::Real(h as f64),
1123            ]),
1124        );
1125        let ap_stream = Object::Stream {
1126            dict: ap_stream_dict,
1127            data: StreamData::Decoded { data: ap_bytes },
1128        };
1129        let ap_id = doc.add_object(ap_stream);
1130
1131        // Update /AP /N in the annotation dict, preserving /D and /R if present.
1132        let annot = doc.get_mut(annot_id)?;
1133        if let Object::Dictionary(dict) = annot {
1134            let ap_key = Name::from_bytes(b"AP".to_vec());
1135            let n_key = Name::from_bytes(b"N".to_vec());
1136            match dict.get_mut(&ap_key) {
1137                Some(Object::Dictionary(existing)) => {
1138                    existing.insert(n_key, Object::Reference(ap_id));
1139                }
1140                _ => {
1141                    let mut ap_dict = HashMap::new();
1142                    ap_dict.insert(n_key, Object::Reference(ap_id));
1143                    dict.insert(ap_key, Object::Dictionary(ap_dict));
1144                }
1145            }
1146        }
1147    }
1148
1149    Ok(())
1150}
1151
1152/// Map a PDF annotation subtype name back to `AnnotationType`.
1153fn name_to_annotation_type(name: &[u8]) -> AnnotationType {
1154    match name {
1155        b"Text" => AnnotationType::Text,
1156        b"Link" => AnnotationType::Link,
1157        b"FreeText" => AnnotationType::FreeText,
1158        b"Line" => AnnotationType::Line,
1159        b"Square" => AnnotationType::Square,
1160        b"Circle" => AnnotationType::Circle,
1161        b"Polygon" => AnnotationType::Polygon,
1162        b"PolyLine" => AnnotationType::PolyLine,
1163        b"Highlight" => AnnotationType::Highlight,
1164        b"Underline" => AnnotationType::Underline,
1165        b"Squiggly" => AnnotationType::Squiggly,
1166        b"StrikeOut" => AnnotationType::StrikeOut,
1167        b"Stamp" => AnnotationType::Stamp,
1168        b"Caret" => AnnotationType::Caret,
1169        b"Ink" => AnnotationType::Ink,
1170        b"Popup" => AnnotationType::Popup,
1171        b"FileAttachment" => AnnotationType::FileAttachment,
1172        b"Sound" => AnnotationType::Sound,
1173        b"Movie" => AnnotationType::Movie,
1174        b"Widget" => AnnotationType::Widget,
1175        b"Screen" => AnnotationType::Screen,
1176        b"PrinterMark" => AnnotationType::PrinterMark,
1177        b"TrapNet" => AnnotationType::TrapNet,
1178        b"Watermark" => AnnotationType::Watermark,
1179        b"3D" => AnnotationType::ThreeD,
1180        b"RichMedia" => AnnotationType::RichMedia,
1181        b"XFAWidget" => AnnotationType::XFAWidget,
1182        b"Redact" => AnnotationType::Redact,
1183        _ => AnnotationType::Other,
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190    use rpdfium_core::Rect;
1191
1192    #[test]
1193    fn test_builder_creates_spec() {
1194        let spec = AnnotationBuilder::new(AnnotationType::Highlight, [10.0, 20.0, 100.0, 50.0])
1195            .contents("Test annotation")
1196            .color(vec![1.0, 0.0, 0.0])
1197            .build();
1198
1199        assert!(matches!(spec.subtype, AnnotationType::Highlight));
1200        assert_eq!(spec.rect, [10.0, 20.0, 100.0, 50.0]);
1201        assert_eq!(spec.contents.as_deref(), Some("Test annotation"));
1202        assert_eq!(spec.color.as_ref().map(|c| c.len()), Some(3));
1203    }
1204
1205    #[test]
1206    fn test_add_annotation_to_page() {
1207        let mut doc = EditDocument::new_blank();
1208        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1209
1210        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0])
1211            .contents("Hello")
1212            .build();
1213
1214        let annot_id = doc.add_annotation(0, spec).unwrap();
1215
1216        // Verify annotation exists
1217        let annot = doc.resolve(annot_id).unwrap();
1218        let dict = annot.as_dict().unwrap();
1219        assert!(dict.contains_key(&Name::subtype()));
1220        assert!(dict.contains_key(&Name::rect()));
1221
1222        // Verify it's in the page's /Annots
1223        let page_id = doc.page_id(0).unwrap();
1224        let page = doc.resolve(page_id).unwrap();
1225        let pdict = page.as_dict().unwrap();
1226        let annots = pdict.get(&Name::annots()).unwrap().as_array().unwrap();
1227        assert_eq!(annots.len(), 1);
1228    }
1229
1230    #[test]
1231    fn test_add_annotation_out_of_range() {
1232        let mut doc = EditDocument::new_blank();
1233        let spec = AnnotationBuilder::new(AnnotationType::Text, [0.0; 4]).build();
1234        let result = doc.add_annotation(0, spec);
1235        assert!(result.is_err());
1236    }
1237
1238    #[test]
1239    fn test_delete_annotation() {
1240        let mut doc = EditDocument::new_blank();
1241        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1242
1243        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1244        let annot_id = doc.add_annotation(0, spec).unwrap();
1245
1246        doc.delete_annotation(0, annot_id).unwrap();
1247
1248        // Annotation should no longer be resolvable
1249        assert!(doc.resolve(annot_id).is_err());
1250
1251        // Page's /Annots should be empty
1252        let page_id = doc.page_id(0).unwrap();
1253        let page = doc.resolve(page_id).unwrap();
1254        let pdict = page.as_dict().unwrap();
1255        let annots = pdict.get(&Name::annots()).unwrap().as_array().unwrap();
1256        assert_eq!(annots.len(), 0);
1257    }
1258
1259    #[test]
1260    fn test_update_annotation_contents() {
1261        let mut doc = EditDocument::new_blank();
1262        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1263
1264        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0])
1265            .contents("Original")
1266            .build();
1267        let annot_id = doc.add_annotation(0, spec).unwrap();
1268
1269        let updates = AnnotationUpdates {
1270            contents: Some("Updated".to_string()),
1271            ..Default::default()
1272        };
1273        doc.update_annotation(annot_id, updates).unwrap();
1274
1275        let annot = doc.resolve(annot_id).unwrap();
1276        let dict = annot.as_dict().unwrap();
1277        let contents = dict
1278            .get(&Name::contents())
1279            .and_then(|o| o.as_string())
1280            .unwrap();
1281        assert_eq!(contents.as_bytes(), b"Updated");
1282    }
1283
1284    #[test]
1285    fn test_update_annotation_rect() {
1286        let mut doc = EditDocument::new_blank();
1287        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1288
1289        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1290        let annot_id = doc.add_annotation(0, spec).unwrap();
1291
1292        let updates = AnnotationUpdates {
1293            rect: Some([0.0, 0.0, 100.0, 100.0]),
1294            ..Default::default()
1295        };
1296        doc.update_annotation(annot_id, updates).unwrap();
1297
1298        let annot = doc.resolve(annot_id).unwrap();
1299        let dict = annot.as_dict().unwrap();
1300        let rect = dict.get(&Name::rect()).unwrap().as_array().unwrap();
1301        assert_eq!(rect[2].as_f64(), Some(100.0));
1302    }
1303
1304    #[test]
1305    fn test_multiple_annotations_on_page() {
1306        let mut doc = EditDocument::new_blank();
1307        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1308
1309        for i in 0..5 {
1310            let r = i as f32 * 10.0;
1311            let spec =
1312                AnnotationBuilder::new(AnnotationType::Text, [r, r, r + 30.0, r + 30.0]).build();
1313            doc.add_annotation(0, spec).unwrap();
1314        }
1315
1316        let page_id = doc.page_id(0).unwrap();
1317        let page = doc.resolve(page_id).unwrap();
1318        let pdict = page.as_dict().unwrap();
1319        let annots = pdict.get(&Name::annots()).unwrap().as_array().unwrap();
1320        assert_eq!(annots.len(), 5);
1321    }
1322
1323    #[test]
1324    fn test_annotation_type_names() {
1325        assert_eq!(
1326            annotation_type_name(&AnnotationType::Highlight),
1327            b"Highlight"
1328        );
1329        assert_eq!(annotation_type_name(&AnnotationType::Link), b"Link");
1330        assert_eq!(annotation_type_name(&AnnotationType::ThreeD), b"3D");
1331    }
1332
1333    // ------------------------------------------------------------------
1334    // Fix E: AnnotationUpdates new fields — subject, author, border_width
1335    // ------------------------------------------------------------------
1336
1337    #[test]
1338    fn test_update_annotation_subject() {
1339        let mut doc = EditDocument::new_blank();
1340        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1341
1342        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1343        let annot_id = doc.add_annotation(0, spec).unwrap();
1344
1345        let updates = AnnotationUpdates {
1346            subject: Some("Important Note".to_string()),
1347            ..Default::default()
1348        };
1349        doc.update_annotation(annot_id, updates).unwrap();
1350
1351        let annot = doc.resolve(annot_id).unwrap();
1352        let dict = annot.as_dict().unwrap();
1353        let subj = dict.get(&Name::subj()).and_then(|o| o.as_string()).unwrap();
1354        assert_eq!(subj.as_bytes(), b"Important Note");
1355    }
1356
1357    #[test]
1358    fn test_update_annotation_author() {
1359        let mut doc = EditDocument::new_blank();
1360        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1361
1362        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1363        let annot_id = doc.add_annotation(0, spec).unwrap();
1364
1365        let updates = AnnotationUpdates {
1366            author: Some("Jane Doe".to_string()),
1367            ..Default::default()
1368        };
1369        doc.update_annotation(annot_id, updates).unwrap();
1370
1371        let annot = doc.resolve(annot_id).unwrap();
1372        let dict = annot.as_dict().unwrap();
1373        let author = dict.get(&Name::t()).and_then(|o| o.as_string()).unwrap();
1374        assert_eq!(author.as_bytes(), b"Jane Doe");
1375    }
1376
1377    #[test]
1378    fn test_update_annotation_border_width() {
1379        let mut doc = EditDocument::new_blank();
1380        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1381
1382        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1383        let annot_id = doc.add_annotation(0, spec).unwrap();
1384
1385        let updates = AnnotationUpdates {
1386            border_width: Some(2.5),
1387            ..Default::default()
1388        };
1389        doc.update_annotation(annot_id, updates).unwrap();
1390
1391        let annot = doc.resolve(annot_id).unwrap();
1392        let dict = annot.as_dict().unwrap();
1393        let bs_dict = dict.get(&Name::bs()).and_then(|o| o.as_dict()).unwrap();
1394        let w = bs_dict
1395            .get(&Name::from_bytes(b"W".to_vec()))
1396            .and_then(|o| o.as_f64())
1397            .unwrap();
1398        assert!((w - 2.5).abs() < 0.001);
1399    }
1400
1401    // ------------------------------------------------------------------
1402    // New AnnotationUpdates fields: border_style, color_rgba,
1403    // attachment_points, ink_list, extra_string
1404    // ------------------------------------------------------------------
1405
1406    #[test]
1407    fn test_update_annotation_border_style_writes_border_array() {
1408        let mut doc = EditDocument::new_blank();
1409        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1410        let spec =
1411            AnnotationBuilder::new(AnnotationType::Square, [10.0, 10.0, 100.0, 100.0]).build();
1412        let annot_id = doc.add_annotation(0, spec).unwrap();
1413
1414        let updates = AnnotationUpdates {
1415            border_style: Some((5.0, 10.0, 2.0)), // h_rad=5, v_rad=10, width=2
1416            ..Default::default()
1417        };
1418        doc.update_annotation(annot_id, updates).unwrap();
1419
1420        let annot = doc.resolve(annot_id).unwrap();
1421        let dict = annot.as_dict().unwrap();
1422        let border = dict
1423            .get(&Name::border())
1424            .and_then(|o| o.as_array())
1425            .unwrap();
1426        assert_eq!(border.len(), 3);
1427        assert!((border[0].as_f64().unwrap() - 5.0).abs() < 0.001);
1428        assert!((border[1].as_f64().unwrap() - 10.0).abs() < 0.001);
1429        assert!((border[2].as_f64().unwrap() - 2.0).abs() < 0.001);
1430    }
1431
1432    #[test]
1433    fn test_update_annotation_color_rgba_writes_c_array() {
1434        let mut doc = EditDocument::new_blank();
1435        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1436        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1437        let annot_id = doc.add_annotation(0, spec).unwrap();
1438
1439        // Red: (255, 0, 0, 255)
1440        let updates = AnnotationUpdates {
1441            color_rgba: Some((255, 0, 0, 255)),
1442            ..Default::default()
1443        };
1444        doc.update_annotation(annot_id, updates).unwrap();
1445
1446        let annot = doc.resolve(annot_id).unwrap();
1447        let dict = annot.as_dict().unwrap();
1448        let c = dict.get(&Name::c()).and_then(|o| o.as_array()).unwrap();
1449        assert_eq!(c.len(), 3);
1450        assert!((c[0].as_f64().unwrap() - 1.0).abs() < 0.01); // R ≈ 1.0
1451        assert!((c[1].as_f64().unwrap() - 0.0).abs() < 0.01); // G ≈ 0.0
1452        assert!((c[2].as_f64().unwrap() - 0.0).abs() < 0.01); // B ≈ 0.0
1453    }
1454
1455    #[test]
1456    fn test_update_annotation_attachment_points_writes_quad_points() {
1457        let mut doc = EditDocument::new_blank();
1458        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1459        let spec =
1460            AnnotationBuilder::new(AnnotationType::Highlight, [10.0, 10.0, 200.0, 30.0]).build();
1461        let annot_id = doc.add_annotation(0, spec).unwrap();
1462
1463        let updates = AnnotationUpdates {
1464            attachment_points: Some(vec![[10.0, 10.0, 200.0, 10.0, 200.0, 30.0, 10.0, 30.0]]),
1465            ..Default::default()
1466        };
1467        doc.update_annotation(annot_id, updates).unwrap();
1468
1469        let annot = doc.resolve(annot_id).unwrap();
1470        let dict = annot.as_dict().unwrap();
1471        let qp = dict
1472            .get(&Name::quad_points())
1473            .and_then(|o| o.as_array())
1474            .unwrap();
1475        assert_eq!(qp.len(), 8);
1476        assert!((qp[0].as_f64().unwrap() - 10.0).abs() < 0.001);
1477        assert!((qp[2].as_f64().unwrap() - 200.0).abs() < 0.001);
1478    }
1479
1480    #[test]
1481    fn test_update_annotation_ink_list_writes_ink_list() {
1482        let mut doc = EditDocument::new_blank();
1483        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1484        let spec = AnnotationBuilder::new(AnnotationType::Ink, [0.0, 0.0, 200.0, 200.0]).build();
1485        let annot_id = doc.add_annotation(0, spec).unwrap();
1486
1487        let updates = AnnotationUpdates {
1488            ink_list: Some(vec![
1489                vec![10.0, 10.0, 20.0, 30.0, 40.0, 50.0], // stroke 0
1490                vec![100.0, 100.0, 110.0, 120.0],         // stroke 1
1491            ]),
1492            ..Default::default()
1493        };
1494        doc.update_annotation(annot_id, updates).unwrap();
1495
1496        let annot = doc.resolve(annot_id).unwrap();
1497        let dict = annot.as_dict().unwrap();
1498        let ink = dict
1499            .get(&Name::ink_list())
1500            .and_then(|o| o.as_array())
1501            .unwrap();
1502        assert_eq!(ink.len(), 2); // 2 strokes
1503        let stroke0 = ink[0].as_array().unwrap();
1504        assert_eq!(stroke0.len(), 6);
1505        assert!((stroke0[0].as_f64().unwrap() - 10.0).abs() < 0.001);
1506        let stroke1 = ink[1].as_array().unwrap();
1507        assert_eq!(stroke1.len(), 4);
1508    }
1509
1510    #[test]
1511    fn test_update_annotation_extra_string_writes_arbitrary_key() {
1512        let mut doc = EditDocument::new_blank();
1513        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1514        let spec = AnnotationBuilder::new(AnnotationType::Text, [10.0, 10.0, 50.0, 50.0]).build();
1515        let annot_id = doc.add_annotation(0, spec).unwrap();
1516
1517        let updates = AnnotationUpdates {
1518            extra_string: Some(("RC".to_string(), "rich text content".to_string())),
1519            ..Default::default()
1520        };
1521        doc.update_annotation(annot_id, updates).unwrap();
1522
1523        let annot = doc.resolve(annot_id).unwrap();
1524        let dict = annot.as_dict().unwrap();
1525        let key = Name::from_bytes(b"RC".to_vec());
1526        let val = dict.get(&key).and_then(|o| o.as_string()).unwrap();
1527        assert_eq!(val.as_bytes(), b"rich text content");
1528    }
1529
1530    // ------------------------------------------------------------------
1531    // AP stream object management (FPDFAnnot_AppendObject etc.)
1532    // ------------------------------------------------------------------
1533
1534    #[test]
1535    fn test_ap_object_append_and_count() {
1536        use crate::page_object::{PageObject, PathObject, PathSegment};
1537
1538        let mut doc = EditDocument::new_blank();
1539        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1540
1541        let spec = AnnotationBuilder::new(AnnotationType::Ink, [0.0, 0.0, 100.0, 100.0]).build();
1542        let annot_id = doc.add_annotation(0, spec).unwrap();
1543
1544        // Initially no AP objects
1545        assert_eq!(doc.annotation_object_count(annot_id), 0);
1546        assert!(doc.annotation_object_at(annot_id, 0).is_none());
1547
1548        // Append a simple path object
1549        let path = PathObject {
1550            segments: vec![
1551                PathSegment::MoveTo(10.0, 10.0),
1552                PathSegment::LineTo(90.0, 90.0),
1553            ],
1554            ..Default::default()
1555        };
1556        doc.append_annotation_object(annot_id, PageObject::Path(path))
1557            .unwrap();
1558
1559        assert_eq!(doc.annotation_object_count(annot_id), 1);
1560        assert!(doc.annotation_object_at(annot_id, 0).is_some());
1561        assert!(doc.annotation_object_at(annot_id, 1).is_none());
1562    }
1563
1564    #[test]
1565    fn test_ap_object_remove() {
1566        use crate::page_object::{PageObject, PathObject, PathSegment};
1567
1568        let mut doc = EditDocument::new_blank();
1569        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1570
1571        let spec = AnnotationBuilder::new(AnnotationType::Ink, [0.0, 0.0, 100.0, 100.0]).build();
1572        let annot_id = doc.add_annotation(0, spec).unwrap();
1573
1574        let path = PathObject {
1575            segments: vec![PathSegment::MoveTo(0.0, 0.0)],
1576            ..Default::default()
1577        };
1578        doc.append_annotation_object(annot_id, PageObject::Path(path))
1579            .unwrap();
1580
1581        assert_eq!(doc.annotation_object_count(annot_id), 1);
1582
1583        // Remove it
1584        doc.remove_annotation_object(annot_id, 0).unwrap();
1585        assert_eq!(doc.annotation_object_count(annot_id), 0);
1586
1587        // Remove from non-existing list returns error
1588        let other_id = ObjectId::new(999, 0);
1589        assert!(doc.remove_annotation_object(other_id, 0).is_err());
1590    }
1591
1592    #[test]
1593    fn test_ap_object_update_generates_ap_stream() {
1594        use crate::page_object::{FillMode, PageObject, PathObject, PathSegment};
1595        use rpdfium_graphics::Color;
1596
1597        let mut doc = EditDocument::new_blank();
1598        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1599
1600        let spec = AnnotationBuilder::new(AnnotationType::Ink, [10.0, 10.0, 90.0, 90.0]).build();
1601        let annot_id = doc.add_annotation(0, spec).unwrap();
1602
1603        let path = PathObject {
1604            segments: vec![
1605                PathSegment::MoveTo(10.0, 10.0),
1606                PathSegment::LineTo(90.0, 10.0),
1607                PathSegment::LineTo(90.0, 90.0),
1608                PathSegment::LineTo(10.0, 90.0),
1609                PathSegment::Close,
1610            ],
1611            fill_color: Some(Color::rgb(1.0, 0.0, 0.0)),
1612            stroke_color: Some(Color::rgb(0.0, 0.0, 1.0)),
1613            line_width: 2.0,
1614            fill_mode: FillMode::NonZero,
1615            ..Default::default()
1616        };
1617        doc.append_annotation_object(annot_id, PageObject::Path(path))
1618            .unwrap();
1619
1620        // update_annotation_ap should succeed and write /AP /N
1621        doc.update_annotation_ap(annot_id).unwrap();
1622
1623        // Verify the annotation dict now has /AP entry
1624        let annot = doc.resolve(annot_id).unwrap();
1625        let dict = annot.as_dict().unwrap();
1626        assert!(
1627            dict.contains_key(&Name::from_bytes(b"AP".to_vec())),
1628            "/AP key should be present after update_annotation_ap"
1629        );
1630        let ap_dict = dict
1631            .get(&Name::from_bytes(b"AP".to_vec()))
1632            .and_then(|o| o.as_dict())
1633            .unwrap();
1634        assert!(
1635            ap_dict.contains_key(&Name::from_bytes(b"N".to_vec())),
1636            "/AP /N key should be present"
1637        );
1638    }
1639
1640    #[test]
1641    fn test_ap_object_update_no_objects_returns_error() {
1642        let mut doc = EditDocument::new_blank();
1643        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1644
1645        let spec = AnnotationBuilder::new(AnnotationType::Ink, [0.0, 0.0, 100.0, 100.0]).build();
1646        let annot_id = doc.add_annotation(0, spec).unwrap();
1647
1648        // No objects appended — should return error
1649        let result = doc.update_annotation_ap(annot_id);
1650        assert!(
1651            result.is_err(),
1652            "update_annotation_ap with no objects should return error"
1653        );
1654    }
1655
1656    #[test]
1657    fn test_count_annotation_objects_alias() {
1658        use crate::page_object::{PageObject, PathObject, PathSegment};
1659
1660        let mut doc = EditDocument::new_blank();
1661        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1662
1663        let spec = AnnotationBuilder::new(AnnotationType::Stamp, [0.0, 0.0, 200.0, 100.0]).build();
1664        let annot_id = doc.add_annotation(0, spec).unwrap();
1665
1666        let path = PathObject {
1667            segments: vec![PathSegment::MoveTo(0.0, 0.0)],
1668            ..Default::default()
1669        };
1670        doc.append_annotation_object(annot_id, PageObject::Path(path))
1671            .unwrap();
1672
1673        assert_eq!(
1674            doc.annotation_object_count(annot_id),
1675            doc.annot_get_object_count(annot_id)
1676        );
1677    }
1678
1679    // ------------------------------------------------------------------
1680    // Gap 1: AnnotationUpdates::vertices — FPDFAnnot_SetVertices
1681    // ------------------------------------------------------------------
1682
1683    #[test]
1684    fn test_update_annotation_vertices_writes_vertices_array() {
1685        let mut doc = EditDocument::new_blank();
1686        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1687        let spec =
1688            AnnotationBuilder::new(AnnotationType::Polygon, [0.0, 0.0, 200.0, 200.0]).build();
1689        let annot_id = doc.add_annotation(0, spec).unwrap();
1690
1691        let updates = AnnotationUpdates {
1692            vertices: Some(vec![
1693                [10.0, 20.0],
1694                [100.0, 20.0],
1695                [100.0, 120.0],
1696                [10.0, 120.0],
1697            ]),
1698            ..Default::default()
1699        };
1700        doc.update_annotation(annot_id, updates).unwrap();
1701
1702        let annot = doc.resolve(annot_id).unwrap();
1703        let dict = annot.as_dict().unwrap();
1704        let verts = dict
1705            .get(&Name::vertices())
1706            .and_then(|o| o.as_array())
1707            .unwrap();
1708        // 4 points × 2 coords = 8 values
1709        assert_eq!(verts.len(), 8);
1710        assert!((verts[0].as_f64().unwrap() - 10.0).abs() < 0.001);
1711        assert!((verts[1].as_f64().unwrap() - 20.0).abs() < 0.001);
1712        assert!((verts[2].as_f64().unwrap() - 100.0).abs() < 0.001);
1713    }
1714
1715    #[test]
1716    fn test_update_annotation_vertices_replaces_existing() {
1717        let mut doc = EditDocument::new_blank();
1718        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1719        let spec =
1720            AnnotationBuilder::new(AnnotationType::Polygon, [0.0, 0.0, 200.0, 200.0]).build();
1721        let annot_id = doc.add_annotation(0, spec).unwrap();
1722
1723        // First update
1724        let updates1 = AnnotationUpdates {
1725            vertices: Some(vec![[0.0, 0.0], [50.0, 0.0], [50.0, 50.0]]),
1726            ..Default::default()
1727        };
1728        doc.update_annotation(annot_id, updates1).unwrap();
1729
1730        // Second update replaces
1731        let updates2 = AnnotationUpdates {
1732            vertices: Some(vec![[5.0, 5.0], [15.0, 5.0]]),
1733            ..Default::default()
1734        };
1735        doc.update_annotation(annot_id, updates2).unwrap();
1736
1737        let annot = doc.resolve(annot_id).unwrap();
1738        let dict = annot.as_dict().unwrap();
1739        let verts = dict
1740            .get(&Name::vertices())
1741            .and_then(|o| o.as_array())
1742            .unwrap();
1743        // 2 points × 2 = 4 values
1744        assert_eq!(verts.len(), 4);
1745        assert!((verts[0].as_f64().unwrap() - 5.0).abs() < 0.001);
1746    }
1747
1748    #[test]
1749    fn test_update_annotation_vertices_empty_clears_array() {
1750        let mut doc = EditDocument::new_blank();
1751        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1752        let spec =
1753            AnnotationBuilder::new(AnnotationType::Polygon, [0.0, 0.0, 200.0, 200.0]).build();
1754        let annot_id = doc.add_annotation(0, spec).unwrap();
1755
1756        let updates = AnnotationUpdates {
1757            vertices: Some(vec![]),
1758            ..Default::default()
1759        };
1760        doc.update_annotation(annot_id, updates).unwrap();
1761
1762        let annot = doc.resolve(annot_id).unwrap();
1763        let dict = annot.as_dict().unwrap();
1764        let verts = dict
1765            .get(&Name::vertices())
1766            .and_then(|o| o.as_array())
1767            .unwrap();
1768        assert_eq!(verts.len(), 0);
1769    }
1770
1771    // ------------------------------------------------------------------
1772    // Task 2: AnnotationUpdates::uri_action — FPDFAnnot_SetURI
1773    // ------------------------------------------------------------------
1774
1775    #[test]
1776    fn test_update_annotation_sets_uri_action() {
1777        let mut doc = EditDocument::new_blank();
1778        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1779        let spec = AnnotationBuilder::new(AnnotationType::Link, [10.0, 10.0, 100.0, 30.0]).build();
1780        let annot_id = doc.add_annotation(0, spec).unwrap();
1781
1782        let updates = AnnotationUpdates {
1783            uri_action: Some("https://example.com".to_string()),
1784            ..Default::default()
1785        };
1786        doc.update_annotation(annot_id, updates).unwrap();
1787
1788        let annot = doc.resolve(annot_id).unwrap();
1789        let dict = annot.as_dict().unwrap();
1790        let action_dict = dict.get(&Name::a()).and_then(|o| o.as_dict()).unwrap();
1791        // /S entry should be the Name /URI
1792        let s_val = action_dict
1793            .get(&Name::s())
1794            .and_then(|o| o.as_name())
1795            .unwrap();
1796        assert_eq!(s_val.as_bytes(), b"URI");
1797        // /URI entry should be the string value
1798        let uri_val = action_dict
1799            .get(&Name::uri())
1800            .and_then(|o| o.as_string())
1801            .unwrap();
1802        assert_eq!(uri_val.as_bytes(), b"https://example.com");
1803    }
1804
1805    #[test]
1806    fn test_update_annotation_uri_in_correct_dict_structure() {
1807        // Verify the exact dict structure: /A << /S /URI /URI (url) >>
1808        let mut doc = EditDocument::new_blank();
1809        doc.add_page(Rect::new(0.0, 0.0, 612.0, 792.0));
1810        let spec = AnnotationBuilder::new(AnnotationType::Widget, [0.0, 0.0, 200.0, 50.0]).build();
1811        let annot_id = doc.add_annotation(0, spec).unwrap();
1812
1813        let updates = AnnotationUpdates {
1814            uri_action: Some("https://rust-lang.org/".to_string()),
1815            ..Default::default()
1816        };
1817        doc.update_annotation(annot_id, updates).unwrap();
1818
1819        let annot = doc.resolve(annot_id).unwrap();
1820        let dict = annot.as_dict().unwrap();
1821
1822        // /A key must exist and be a dictionary
1823        let action = dict.get(&Name::a()).expect("/A key missing");
1824        let action_dict = action.as_dict().expect("/A value is not a dictionary");
1825
1826        // Must contain /S and /URI entries
1827        assert!(
1828            action_dict.contains_key(&Name::s()),
1829            "/S missing from action dict"
1830        );
1831        assert!(
1832            action_dict.contains_key(&Name::uri()),
1833            "/URI missing from action dict"
1834        );
1835
1836        let s = action_dict
1837            .get(&Name::s())
1838            .and_then(|o| o.as_name())
1839            .unwrap();
1840        assert_eq!(s.as_bytes(), b"URI", "/S value should be /URI");
1841
1842        let uri = action_dict
1843            .get(&Name::uri())
1844            .and_then(|o| o.as_string())
1845            .unwrap();
1846        assert_eq!(uri.as_bytes(), b"https://rust-lang.org/");
1847    }
1848
1849    // ------------------------------------------------------------------
1850    // Batch 18: AnnotationUpdates::append_attachment_points
1851    // FPDFAnnot_AppendAttachmentPoints
1852    // ------------------------------------------------------------------
1853
1854    #[test]
1855    fn test_annotation_updates_append_attachment_points() {
1856        let mut updates = AnnotationUpdates::default();
1857        updates.append_attachment_points([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]);
1858        updates.append_attachment_points([9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0]);
1859        let pts = updates.attachment_points.as_ref().unwrap();
1860        assert_eq!(pts.len(), 2);
1861        assert_eq!(pts[0][0], 1.0);
1862        assert_eq!(pts[1][0], 9.0);
1863    }
1864}