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.
This commit is contained in:
yenru0 committed 2026-09-24 12:36:13 +09:00
1 parent 37750c24e2
commit 8cbbb66e97
6 files changed
+381 -11

No files matched your search

+1
View File
@@ -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"
},
},
+1
View File
@@ -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"
},
},
+1
View File
@@ -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"
},
},
+150 -8
View File
@@ -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<Arc<Vec<u8>>>,
/// 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<HashMap<usize, Arc<Vec<pdf_viewer::PdfAnnotationInfo>>>>,
thumbnail_scroll_handle: gpui::ScrollHandle,
pdf_thumbnail_cache: PageLruCache<CachedPageImage>,
pdf_page_cache: PageLruCache<CachedPageImage>,
@@ -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<Self>,
) {
// 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<u8>, cx: &mut Context<Self>) {
/// 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<u8>, cx: &mut Context<Self>) {
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<Self>) {
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<Vec<pdf_viewer::PdfAnnotationInfo>> {
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<Pixels>,
cx: &App,
) -> Option<pdf_viewer::PdfAnnotationInfo> {
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<Self>,
) {
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<Self>) {
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()
+3 -3
View File
@@ -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};
+225
View File
@@ -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<PdfAnnotationKind> {
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<Vec<PdfAnnotationInfo>> {
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<Vec<u8>> {
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::*;