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:
1 parent
37750c24e2
commit
8cbbb66e97
6 files changed
+381
-11
No files matched your search
@@ -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"
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in new issue
Block a user