From 8cbbb66e97adc23412a4a4e3624e1c23dee0bba6 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 24 Sep 2026 12:36:05 +0900 Subject: [PATCH] image_viewer: Undo and delete PDF annotations - Every committed document edit (annotations and form entries) pushes the previous state onto a bounded history, so ctrl-z, the annotation palette's Undo button, and the context menu's "Undo Edit" walk back through them; undoing past the first edit returns to the file's bytes. - Right clicking an annotation offers "Delete Annotation". The engine lists a page's markup annotations (links, widgets, and popups are excluded) and removes the one under the pointer by its annotation index. - The palette's Undo button is disabled while the history is empty, and every edit clears the annotation cache that hit testing reads. --- assets/keymaps/default-linux.json | 1 + assets/keymaps/default-macos.json | 1 + assets/keymaps/default-windows.json | 1 + crates/image_viewer/src/image_viewer.rs | 158 ++++++++++++++++- crates/pdf_viewer/src/pdf_viewer.rs | 6 +- crates/pdf_viewer/src/pdfium.rs | 225 ++++++++++++++++++++++++ 6 files changed, 381 insertions(+), 11 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index a46d7d4698..c58ae13bcc 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1630,6 +1630,7 @@ "ctrl-g": "image_viewer::FindNext", "ctrl-shift-g": "image_viewer::FindPrevious", "ctrl-s": "image_viewer::SaveCopyAs", + "ctrl-z": "image_viewer::UndoEdit", "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 975cf8493e..ab4cd92ef1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1682,6 +1682,7 @@ "cmd-g": "image_viewer::FindNext", "cmd-shift-g": "image_viewer::FindPrevious", "cmd-s": "image_viewer::SaveCopyAs", + "cmd-z": "image_viewer::UndoEdit", "cmd-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index e8532142cc..d64b301609 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1605,6 +1605,7 @@ "ctrl-g": "image_viewer::FindNext", "ctrl-shift-g": "image_viewer::FindPrevious", "ctrl-s": "image_viewer::SaveCopyAs", + "ctrl-z": "image_viewer::UndoEdit", "ctrl-shift-t": "image_viewer::ToggleTextPanel" }, }, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index f4b380767b..950a6a1da9 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -87,6 +87,10 @@ actions!( RotateCounterclockwise, /// Toggle the annotation tool palette (PDF). ToggleAnnotationTools, + /// Undo the last document edit, such as an annotation or form entry (PDF). + UndoEdit, + /// Delete the annotation under the context menu pointer (PDF). + DeleteAnnotation, /// Save a copy of the PDF to a chosen location (PDF). SaveCopyAs, /// Open the find-in-document bar (PDF). @@ -145,6 +149,7 @@ const HIGHLIGHT_COLORS: [pdf_viewer::PdfAnnotationColor; 4] = [ ]; const DEFAULT_HIGHLIGHT_COLOR: pdf_viewer::PdfAnnotationColor = HIGHLIGHT_COLORS[0]; const PEN_LINE_WIDTH: f32 = 2.0; +const MAX_EDIT_HISTORY: usize = 20; const MAX_SAME_LINE_DISTANCE: f32 = 0.15; // Page-width fraction for line-end fallbacks. const MAX_NEAREST_DISTANCE_SQUARED: f32 = 0.0064; // 8% of a page, for margin clicks. @@ -279,6 +284,12 @@ pub struct ImageView { annotation_color: pdf_viewer::PdfAnnotationColor, /// Ink stroke being drawn: the page it belongs to and its page-space points. pen_stroke: Option<(usize, Vec<(f32, f32)>)>, + /// Document states before each committed edit, newest last, for undo. + edit_history: Vec>>, + /// Annotation targeted by the context menu's delete entry. + pending_annotation_delete: Option<(usize, usize)>, + /// Markup annotations per page, filled on demand for delete hit testing. + pdf_annotation_cache: RefCell>>>, thumbnail_scroll_handle: gpui::ScrollHandle, pdf_thumbnail_cache: PageLruCache, pdf_page_cache: PageLruCache, @@ -427,6 +438,9 @@ impl ImageView { annotation_tool: PdfAnnotationTool::Select, annotation_color: DEFAULT_HIGHLIGHT_COLOR, pen_stroke: None, + edit_history: Vec::new(), + pending_annotation_delete: None, + pdf_annotation_cache: RefCell::new(HashMap::default()), thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -455,6 +469,8 @@ impl ImageView { self.pdf_metadata = None; self.pdf_char_cache.borrow_mut().clear(); self.pdf_edited_bytes = None; + self.edit_history.clear(); + self.pdf_annotation_cache.borrow_mut().clear(); self.pdf_form_widgets.borrow_mut().clear(); self.form_text_edit = None; self.form_option_menu = None; @@ -472,6 +488,8 @@ impl ImageView { self.pdf_metadata = None; self.pdf_char_cache.borrow_mut().clear(); self.pdf_edited_bytes = None; + self.edit_history.clear(); + self.pdf_annotation_cache.borrow_mut().clear(); self.pdf_form_widgets.borrow_mut().clear(); self.form_text_edit = None; self.form_option_menu = None; @@ -649,6 +667,8 @@ impl ImageView { .is_some_and(|s| !s.text.is_empty()); let is_pdf = self.image_item.read(cx).is_pdf(); let can_copy = has_selection; + let can_undo = !self.edit_history.is_empty(); + let can_delete_annotation = self.pending_annotation_delete.is_some(); let focus_handle = self.focus_handle.clone(); let context_menu = ContextMenu::build(window, cx, move |menu, _, _| { @@ -665,6 +685,13 @@ impl ImageView { .action("Reset Zoom", Box::new(ResetZoom)) .action("Fit to View", Box::new(FitToView)) .separator() + .when(can_delete_annotation, |menu| { + menu.action("Delete Annotation", Box::new(DeleteAnnotation)) + .separator() + }) + .when(can_undo, |menu| { + menu.action("Undo Edit", Box::new(UndoEdit)).separator() + }) .when(is_pdf, |menu| { menu.action("Save a Copy As…", Box::new(SaveCopyAs)) .separator() @@ -1218,6 +1245,8 @@ impl ImageView { window: &mut Window, cx: &mut Context, ) { + // Start from a clean slate and only offer deletion for what this click hits. + self.pending_annotation_delete = None; let bounds = self.page_bounds.borrow().get(&page_index).copied(); if let Some(bounds) = bounds { @@ -1245,6 +1274,12 @@ impl ImageView { }) }; + // Remember which annotation the pointer is on so the context menu can + // offer to delete it. + self.pending_annotation_delete = self + .annotation_at(page_index, click_pos, cx) + .map(|annotation| (page_index, annotation.index)); + // Right clicking outside the selection selects the word under the pointer, // matching the browser viewer's context menu behaviour. if let Some((index, boxes)) = hit { @@ -2127,14 +2162,110 @@ impl ImageView { )) } - /// Swaps the renders over to the rewritten document after an annotation edit. - fn apply_annotation_bytes(&mut self, edited: Vec, cx: &mut Context) { + /// Swaps the renders over to a rewritten document, remembering the previous state so + /// the edit can be undone. Both form entries and annotations come through here. + fn commit_edited_bytes(&mut self, edited: Vec, cx: &mut Context) { + if let Some(previous) = self.active_pdf_bytes(cx) { + self.edit_history.push(previous); + if self.edit_history.len() > MAX_EDIT_HISTORY { + self.edit_history.remove(0); + } + } + self.pdf_edited_bytes = Some(Arc::new(edited)); self.pdf_page_cache.clear(); self.pdf_thumbnail_cache.clear(); + self.pdf_annotation_cache.borrow_mut().clear(); cx.notify(); } + /// Restores the document state from before the last committed edit. + fn undo_last_edit(&mut self, _: &UndoEdit, _window: &mut Window, cx: &mut Context) { + let Some(previous) = self.edit_history.pop() else { + return; + }; + + // The stack starts at the file's own bytes, so undoing back to it clears the + // edited copy entirely rather than pinning an identical duplicate. + let original = self + .image_item + .read(cx) + .pdf_info + .as_ref() + .map(|info| info.pdf_bytes.clone()); + self.pdf_edited_bytes = match original { + Some(original) if Arc::ptr_eq(&original, &previous) => None, + _ => Some(previous), + }; + self.pdf_page_cache.clear(); + self.pdf_thumbnail_cache.clear(); + self.pdf_annotation_cache.borrow_mut().clear(); + self.pdf_form_widgets.borrow_mut().clear(); + cx.notify(); + } + + /// Markup annotations of a page, extracted through the engine on first use. + fn annotations_for_page( + &self, + pdf_bytes: &[u8], + page_index: usize, + ) -> Arc> { + if let Some(cached) = self.pdf_annotation_cache.borrow().get(&page_index) { + return cached.clone(); + } + + let annotations = Arc::new( + PdfiumEngine::new() + .extract_annotations_from_bytes(pdf_bytes, page_index) + .log_err() + .unwrap_or_default(), + ); + self.pdf_annotation_cache + .borrow_mut() + .insert(page_index, annotations.clone()); + annotations + } + + /// Annotation under a window position, used by the context menu's delete entry. + fn annotation_at( + &self, + page_index: usize, + position: Point, + cx: &App, + ) -> Option { + let bytes = self.active_pdf_bytes(cx)?; + let (norm_x, norm_y) = self.page_space_point(page_index, position)?; + + self.annotations_for_page(&bytes, page_index) + .iter() + .find(|annotation| { + norm_x >= annotation.x + && norm_x <= annotation.x + annotation.width + && norm_y >= annotation.y + && norm_y <= annotation.y + annotation.height + }) + .cloned() + } + + fn delete_annotation( + &mut self, + _: &DeleteAnnotation, + _window: &mut Window, + cx: &mut Context, + ) { + let Some((page_index, index)) = self.pending_annotation_delete.take() else { + return; + }; + let Some(bytes) = self.active_pdf_bytes(cx).map(|bytes| bytes.to_vec()) else { + return; + }; + + match PdfiumEngine::new().delete_annotation(&bytes, page_index, index) { + Ok(edited) => self.commit_edited_bytes(edited, cx), + Err(error) => log::error!("Failed to delete PDF annotation: {error:#}"), + } + } + /// Turns the current text selection into highlight annotations, one page at a time. fn highlight_current_selection(&mut self, cx: &mut Context) { let Some(selection) = self.pdf_selection.clone() else { @@ -2196,7 +2327,7 @@ impl ImageView { if let Some(edited) = edited { self.pdf_selection = None; - self.apply_annotation_bytes(edited, cx); + self.commit_edited_bytes(edited, cx); } } @@ -2235,7 +2366,7 @@ impl ImageView { self.annotation_color, PEN_LINE_WIDTH, ) { - Ok(edited) => self.apply_annotation_bytes(edited, cx), + Ok(edited) => self.commit_edited_bytes(edited, cx), Err(error) => log::error!("Failed to add PDF ink annotation: {error:#}"), } } @@ -2319,11 +2450,8 @@ impl ImageView { match PdfiumEngine::new().apply_form_edits(&bytes, std::slice::from_ref(&edit)) { Ok(edited) => { - self.pdf_edited_bytes = Some(Arc::new(edited)); - self.pdf_page_cache.clear(); - self.pdf_thumbnail_cache.clear(); self.pdf_form_widgets.borrow_mut().clear(); - cx.notify(); + self.commit_edited_bytes(edited, cx); } Err(error) => log::error!("Failed to apply PDF form edit: {error:#}"), } @@ -3023,6 +3151,9 @@ impl Item for ImageView { annotation_tool: PdfAnnotationTool::Select, annotation_color: DEFAULT_HIGHLIGHT_COLOR, pen_stroke: None, + edit_history: Vec::new(), + pending_annotation_delete: None, + pdf_annotation_cache: RefCell::new(HashMap::default()), thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -3193,6 +3324,8 @@ impl Render for ImageView { .on_action(cx.listener(Self::rotate_clockwise)) .on_action(cx.listener(Self::rotate_counterclockwise)) .on_action(cx.listener(Self::save_copy_as)) + .on_action(cx.listener(Self::undo_last_edit)) + .on_action(cx.listener(Self::delete_annotation)) .on_action(cx.listener(Self::toggle_annotation_tools)) .on_action(cx.listener(Self::select_all)) .on_action(cx.listener(Self::open_pdf_find)) @@ -3678,6 +3811,15 @@ impl Render for ImageView { .child(tool_button(PdfAnnotationTool::Highlight, cx)) .child(tool_button(PdfAnnotationTool::Pen, cx)) .child(Divider::vertical()) + .child( + Button::new("pdf-annotation-undo", "Undo") + .label_size(LabelSize::XSmall) + .disabled(self.edit_history.is_empty()) + .on_click(cx.listener(|this, _, window, cx| { + this.undo_last_edit(&UndoEdit, window, cx); + })), + ) + .child(Divider::vertical()) .children(HIGHLIGHT_COLORS.into_iter().map(|color| { let is_active = self.annotation_color == color; div() diff --git a/crates/pdf_viewer/src/pdf_viewer.rs b/crates/pdf_viewer/src/pdf_viewer.rs index 2460348034..9203eb4601 100644 --- a/crates/pdf_viewer/src/pdf_viewer.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -22,9 +22,9 @@ pub mod watcher; pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ - PdfAnnotationColor, PdfCharBox, PdfDocumentDetails, PdfFormEdit, PdfFormWidget, - PdfFormWidgetKind, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, - PdfTextSegment, PdfiumEngine, + PdfAnnotationColor, PdfAnnotationInfo, PdfAnnotationKind, PdfCharBox, PdfDocumentDetails, + PdfFormEdit, PdfFormWidget, PdfFormWidgetKind, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, + PdfPageDetails, PdfTextSegment, PdfiumEngine, }; pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings}; diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index f2f02b79c0..f222ce1064 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -2001,6 +2001,43 @@ impl PdfiumEngine { // `GenerateInkAP`, which read `/QuadPoints`, `/InkList`, `/C` and the border width), and other // viewers generate the same appearance the same way, while a hand-written stream would instead // replace that machinery and freeze the markup at the zoom it was authored for. +// +// [`PdfiumEngine::extract_annotations_from_bytes`] reads those same entries back and +// [`PdfiumEngine::delete_annotation`] removes an annotation from the page, both through the same +// guards. `FPDFPage_GetAnnot`, and therefore every annotation this layer reports, is addressed by +// the annotation's position in the page's annotation array, which is also the index +// `FPDFPage_RemoveAnnot` takes, so an index reported by the reader is directly usable by the +// remover as long as the two run against the same annotation array. + +/// The kind of a markup annotation, as far as this module's readers and removers care. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PdfAnnotationKind { + /// `FPDF_ANNOT_HIGHLIGHT`. Written by [`PdfiumEngine::add_highlight_annotation`]. + Highlight, + /// `FPDF_ANNOT_INK`. Written by [`PdfiumEngine::add_ink_annotation`]. + Ink, + /// Any other markup subtype: underline, strikeout, squiggly, stamp, note, and so on. + Other, +} + +/// A markup annotation of one page, as [`PdfiumEngine::extract_annotations_from_bytes`] reports it. +/// +/// The rectangle is the annotation's `/Rect`, normalized exactly as [`PdfCharBox`], +/// [`PdfLinkAnnotation`] and [`PdfFormWidget`] are: fractions of the page's unrotated width and +/// height measured from the top-left corner. That is the inverse of the conversion +/// [`PdfiumEngine::add_highlight_annotation`] and [`PdfiumEngine::add_ink_annotation`] apply, so a +/// rectangle read back here equals the rectangle that was passed in. +#[derive(Debug, Clone, PartialEq)] +pub struct PdfAnnotationInfo { + /// The annotation's index in pdfium's annotation array, the value + /// [`PdfiumEngine::delete_annotation`] expects. + pub index: usize, + pub kind: PdfAnnotationKind, + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} /// An RGB color for a highlight or ink annotation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -2010,12 +2047,18 @@ pub struct PdfAnnotationColor { pub blue: u8, } +/// `FPDF_ANNOT_LINK` annotation subtype from pdfium's `public/fpdf_annot.h`. +const FPDF_ANNOT_LINK: c_int = 2; + /// `FPDF_ANNOT_HIGHLIGHT` annotation subtype from pdfium's `public/fpdf_annot.h`. const FPDF_ANNOT_HIGHLIGHT: c_int = 9; /// `FPDF_ANNOT_INK` annotation subtype from pdfium's `public/fpdf_annot.h`. const FPDF_ANNOT_INK: c_int = 15; +/// `FPDF_ANNOT_POPUP` annotation subtype from pdfium's `public/fpdf_annot.h`. +const FPDF_ANNOT_POPUP: c_int = 16; + /// `FPDFANNOT_COLORTYPE_Color`: the annotation's `/C` entry, as opposed to its interior color. const FPDFANNOT_COLORTYPE_COLOR: FPDFANNOT_COLORTYPE = 0; @@ -2106,6 +2149,22 @@ fn set_annotation_color( Ok(()) } +/// Maps a pdfium annotation subtype onto the kind this module reports, or `None` for the subtypes +/// that are not markup. +/// +/// Links are navigation the viewer already exposes through [`PdfLinkAnnotation`], and widgets +/// belong to the form layer; neither is markup the user draws or deletes. A popup is only the note +/// window of the annotation that owns it and carries no markup of its own, so listing it would +/// offer the same annotation twice. +fn markup_annotation_kind(subtype: c_int) -> Option { + match subtype { + FPDF_ANNOT_HIGHLIGHT => Some(PdfAnnotationKind::Highlight), + FPDF_ANNOT_INK => Some(PdfAnnotationKind::Ink), + FPDF_ANNOT_LINK | FPDF_ANNOT_WIDGET | FPDF_ANNOT_POPUP => None, + _ => Some(PdfAnnotationKind::Other), + } +} + impl PdfiumEngine { /// Adds a highlight annotation covering `rects` to the zero-indexed page `page_index`, and /// returns the serialized document. @@ -2314,6 +2373,172 @@ impl PdfiumEngine { } } +impl PdfiumEngine { + /// Lists the markup annotations of the zero-indexed page `page_index`, in pdfium's annotation + /// order. + /// + /// Each entry carries the annotation's index in the page's annotation array, its kind, and its + /// `/Rect` - the box pdfium reports through `FPDFAnnot_GetRect`, which is the rectangle the + /// creators above write and the box an annotation's normal appearance is drawn into. Links, + /// form widgets and popups are left out; see [`markup_annotation_kind`]. The rectangle is + /// normalized exactly as in [`PdfCharBox`] and [`PdfLinkAnnotation`], so it is the inverse of + /// the conversion the creators apply and compares equal to the rectangle they were given. + /// + /// An annotation whose rectangle pdfium does not report is still listed, with a zero-sized box, + /// so the indices stay usable for [`delete_annotation`](Self::delete_annotation). A page + /// without usable dimensions yields zero-sized boxes rather than an error, matching + /// [`normalize_coordinates`]. A document without annotations, and an engine without a native + /// pdfium library, produce an empty list rather than an error. + pub fn extract_annotations_from_bytes( + &self, + bytes: &[u8], + page_index: usize, + ) -> Result> { + let guard = self.inner.lock(); + let Some(pdfium) = guard.0.as_ref() else { + return Ok(Vec::new()); + }; + let bindings = pdfium.bindings(); + + let document = bindings.FPDF_LoadMemDocument64(bytes, None); + if document.is_null() { + anyhow::bail!("Pdfium failed to load PDF document for annotation extraction"); + } + // Declared before the page guard below, so that is released first: pdfium requires the + // pages to be closed before the document that owns them. + let _document_guard = PdfiumDocumentGuard { bindings, document }; + + let page = bindings.FPDF_LoadPage(document, page_index as c_int); + if page.is_null() { + anyhow::bail!( + "Requested page index {page_index} out of bounds for annotation extraction" + ); + } + let _page_guard = PdfiumPageGuard { + bindings, + page, + form: std::ptr::null_mut(), + }; + + let page_width = bindings.FPDF_GetPageWidthF(page); + let page_height = bindings.FPDF_GetPageHeightF(page); + let mut annotations = Vec::new(); + + for annot_index in 0..bindings.FPDFPage_GetAnnotCount(page) { + let annot = bindings.FPDFPage_GetAnnot(page, annot_index); + if annot.is_null() { + continue; + } + // Held for its `Drop`; every path out of this iteration closes the annotation. + let _annot_guard = PdfiumAnnotGuard { bindings, annot }; + + let Some(kind) = markup_annotation_kind(bindings.FPDFAnnot_GetSubtype(annot)) else { + continue; + }; + + let mut rect = FS_RECTF { + left: 0.0, + top: 0.0, + right: 0.0, + bottom: 0.0, + }; + let rect_known = bindings.is_true(bindings.FPDFAnnot_GetRect(annot, &mut rect)); + let (x, y, width, height) = if rect_known { + normalize_coordinates( + rect.left, + rect.right, + rect.bottom, + rect.top, + page_width, + page_height, + ) + } else { + (0.0, 0.0, 0.0, 0.0) + }; + + annotations.push(PdfAnnotationInfo { + index: annot_index as usize, + kind, + x, + y, + width, + height, + }); + } + + Ok(annotations) + } + + /// Removes the annotation at `index` from the zero-indexed page `page_index` and returns the + /// serialized document. + /// + /// `index` is a position in pdfium's annotation array, the same value + /// [`extract_annotations_from_bytes`](Self::extract_annotations_from_bytes) reports, and covers + /// every annotation the page has, markup or not. An index the page does not have is an error + /// rather than a no-op, so a stale caller state can never leave a document looking unchanged + /// when the user asked for a deletion. The returned bytes are a complete rewrite of the + /// document (see [`save_document_to_bytes`]). + pub fn delete_annotation( + &self, + bytes: &[u8], + page_index: usize, + index: usize, + ) -> Result> { + let guard = self.inner.lock(); + let Some(pdfium) = guard.0.as_ref() else { + return Ok(bytes.to_vec()); + }; + let bindings = pdfium.bindings(); + + let document = bindings.FPDF_LoadMemDocument64(bytes, None); + if document.is_null() { + anyhow::bail!("Pdfium failed to load PDF document for annotation deletion"); + } + // Declared before the page guard below, so that is released first: pdfium requires the + // pages to be closed before the document that owns them. + let _document_guard = PdfiumDocumentGuard { bindings, document }; + + let page = bindings.FPDF_LoadPage(document, page_index as c_int); + if page.is_null() { + anyhow::bail!( + "Requested page index {page_index} out of bounds for annotation deletion" + ); + } + let _page_guard = PdfiumPageGuard { + bindings, + page, + form: std::ptr::null_mut(), + }; + + // Both the range and the conversion are checked before the index reaches the C API: the + // index is a `usize` here but a `c_int` there, and one wider than `c_int` must not wrap + // onto a real annotation. + let annot_count = bindings.FPDFPage_GetAnnotCount(page) as usize; + if index >= annot_count { + anyhow::bail!( + "Requested annotation index {index} out of bounds for page {page_index}, which has \ + {annot_count} annotations" + ); + } + + let annot = bindings.FPDFPage_GetAnnot(page, index as c_int); + if annot.is_null() { + anyhow::bail!("Pdfium failed to open annotation {index} of page {page_index}"); + } + // Held for its `Drop`; every path out of this function closes the annotation. The removal + // below only drops the page's own reference to the annotation dictionary, so the handle + // stays valid until this guard closes it. + let _annot_guard = PdfiumAnnotGuard { bindings, annot }; + + let removed = bindings.FPDFPage_RemoveAnnot(page, index as c_int); + if !bindings.is_true(removed) { + anyhow::bail!("Pdfium failed to remove annotation {index} from page {page_index}"); + } + + save_document_to_bytes(bindings, document) + } +} + #[cfg(test)] mod tests { use super::*;