From 935e2b0bd034b7ae46832e691e6ba7bc053edcb5 Mon Sep 17 00:00:00 2001 From: yenru0 Date: Thu, 8 Oct 2026 14:28:45 +0900 Subject: [PATCH] image_viewer: Remove PDF annotation and form editing The annotation and form-filling surface is being dropped, so remove it end to end rather than leave it half-maintained. pdf_viewer loses the editing APIs and everything only they used: add_ink_annotation, add_highlight_annotation, delete_annotation, apply_form_edits, the form widget and annotation extraction, the PdfFormWidget/PdfFormEdit/PdfAnnotation types, the document serialization helper, and the pdfium guards and constants those functions were the sole users of. Rendering, text, links, outline and metadata are untouched; annotation rendering stays on because it paints markup already embedded in viewed documents. image_viewer loses the tool palette, form widget overlays, inline form editors, pen canvas, option menus, the annotate toolbar button, the Undo Edit and Delete Annotation actions and their key bindings, plus the associated state and handlers. Text selection, find, links, thumbnails, outline, properties, rotation, presentation and Save a Copy remain. --- 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 | 1157 +------------------- crates/pdf_viewer/README.md | 3 +- crates/pdf_viewer/src/pdf_viewer.rs | 5 +- crates/pdf_viewer/src/pdfium.rs | 1337 ----------------------- 7 files changed, 29 insertions(+), 2476 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index c92a257dfd..fcce8a28c0 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1630,7 +1630,6 @@ "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 6083631125..aee8d2530b 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1682,7 +1682,6 @@ "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 02e74126e6..155ed81756 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -1605,7 +1605,6 @@ "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 bd0b1480b7..6fbc8da19d 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -86,12 +86,6 @@ actions!( RotateClockwise, /// Rotate the pages a quarter turn counterclockwise (PDF). 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). @@ -158,31 +152,6 @@ const OUTLINE_PANEL_MAX_WIDTH: f32 = 480.0; const OUTLINE_RESIZE_HANDLE_WIDTH: f32 = 6.0; const OUTLINE_INDENT: f32 = 12.0; const PROPERTIES_PANEL_WIDTH: f32 = 220.0; -const HIGHLIGHT_COLORS: [pdf_viewer::PdfAnnotationColor; 4] = [ - pdf_viewer::PdfAnnotationColor { - red: 255, - green: 235, - blue: 59, - }, - pdf_viewer::PdfAnnotationColor { - red: 129, - green: 199, - blue: 132, - }, - pdf_viewer::PdfAnnotationColor { - red: 244, - green: 143, - blue: 177, - }, - pdf_viewer::PdfAnnotationColor { - red: 100, - green: 181, - blue: 246, - }, -]; -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. @@ -244,25 +213,6 @@ impl Render for OutlinePanelResize { } } -/// Which annotation tool the viewer is armed with. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PdfAnnotationTool { - /// Text selection and form filling. - Select, - /// Drag over text to highlight it. - Highlight, - /// Freehand ink strokes. - Pen, -} - -/// Inline editor for the text form field currently being filled in. -struct FormTextEdit { - editor: Entity, - page_index: usize, - widget: pdf_viewer::PdfFormWidget, - _subscription: Subscription, -} - /// Find-in-document bar state: the query editor, its matches, and the active match. struct PdfFindState { editor: Entity, @@ -320,24 +270,6 @@ pub struct ImageView { page_rotation: u16, pdf_outline: Option>, pdf_metadata: Option, - /// Document bytes with form edits applied; preferred over the file's own bytes for - /// rendering and saving once the user fills anything in. - pdf_edited_bytes: Option>>, - /// Form widgets per page, filled on demand for overlays and hit testing. - pdf_form_widgets: RefCell>>>, - form_text_edit: Option, - form_option_menu: Option<(Point, pdf_viewer::PdfFormWidget)>, - show_annotation_tools: bool, - annotation_tool: PdfAnnotationTool, - 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, @@ -486,17 +418,6 @@ impl ImageView { page_rotation: 0, pdf_outline: None, pdf_metadata: None, - pdf_edited_bytes: None, - pdf_form_widgets: RefCell::new(HashMap::default()), - form_text_edit: None, - form_option_menu: None, - show_annotation_tools: false, - 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), @@ -526,12 +447,6 @@ impl ImageView { self.pdf_outline = None; 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; if self.pdf_find.is_some() { self.refresh_pdf_find_matches(cx); } @@ -545,12 +460,6 @@ impl ImageView { self.pdf_outline = None; 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; if self.pdf_find.is_some() { self.refresh_pdf_find_matches(cx); } @@ -750,8 +659,6 @@ 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, _, _| { @@ -768,13 +675,6 @@ 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() @@ -1211,13 +1111,6 @@ impl ImageView { return; } - if let Some((page_index, _)) = self.pen_stroke.as_ref().map(|(page, _)| (*page, ())) { - if let Some(stroke_point) = self.page_space_point(page_index, cursor_pos) { - self.extend_pen_stroke(page_index, stroke_point, cx); - } - return; - } - self.pdf_drag_position = Some(cursor_pos); if self.pdf_selection.is_none() { let hit = { @@ -1248,16 +1141,6 @@ impl ImageView { click_count: usize, cx: &mut Context, ) { - if self.annotation_tool == PdfAnnotationTool::Pen { - if let Some(page_point) = self.page_space_point(page_index, click_pos) { - self.pdf_mouse_down = true; - self.pdf_drag_position = None; - self.begin_pen_stroke(page_index, page_point); - cx.notify(); - } - return; - } - let bounds = self.page_bounds.borrow().get(&page_index).copied(); let Some(bounds) = bounds else { return; @@ -1321,23 +1204,12 @@ impl ImageView { } fn handle_pdf_mouse_up(&mut self, cx: &mut Context) { - if self.pen_stroke.is_some() { - self.pdf_mouse_down = false; - self.pdf_drag_position = None; - self.finish_pen_stroke(cx); - return; - } - if self.pdf_mouse_down { self.pdf_mouse_down = false; self.pdf_drag_position = None; self.pdf_autoscroll_task = None; self.ensure_pdf_selection_text(cx); - if self.annotation_tool == PdfAnnotationTool::Highlight { - self.highlight_current_selection(cx); - } else { - cx.notify(); - } + cx.notify(); } } @@ -1348,8 +1220,6 @@ 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 { @@ -1377,12 +1247,6 @@ 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 { @@ -1786,12 +1650,6 @@ impl ImageView { cx: &mut Context, ) { let modifiers = event.keystroke.modifiers; - if event.keystroke.key.as_str() == "escape" && self.form_option_menu.is_some() { - cx.stop_propagation(); - self.form_option_menu = None; - cx.notify(); - return; - } if event.keystroke.key.as_str() == "escape" && self.presentation_mode { cx.stop_propagation(); self.toggle_presentation(&TogglePresentation, window, cx); @@ -2217,16 +2075,13 @@ impl ImageView { elements } - /// Bytes used for rendering and saving: the form-edited copy when the user has filled - /// in fields, otherwise the file's own bytes. + /// Bytes used for rendering and saving: the PDF's own file bytes. fn active_pdf_bytes(&self, cx: &App) -> Option>> { - self.pdf_edited_bytes.clone().or_else(|| { - self.image_item - .read(cx) - .pdf_info - .as_ref() - .map(|info| info.pdf_bytes.clone()) - }) + self.image_item + .read(cx) + .pdf_info + .as_ref() + .map(|info| info.pdf_bytes.clone()) } /// Page whose content the viewport currently shows. Presentation mode lays out a @@ -2357,458 +2212,6 @@ impl ImageView { .into_any_element() } - fn toggle_annotation_tools( - &mut self, - _: &ToggleAnnotationTools, - _window: &mut Window, - cx: &mut Context, - ) { - self.show_annotation_tools = !self.show_annotation_tools; - if !self.show_annotation_tools { - self.annotation_tool = PdfAnnotationTool::Select; - self.pen_stroke = None; - } - cx.notify(); - } - - fn set_annotation_tool(&mut self, tool: PdfAnnotationTool, cx: &mut Context) { - self.annotation_tool = tool; - self.pen_stroke = None; - cx.notify(); - } - - fn set_annotation_color( - &mut self, - color: pdf_viewer::PdfAnnotationColor, - cx: &mut Context, - ) { - self.annotation_color = color; - cx.notify(); - } - - /// Page-space point for a window position, honouring the current page rotation. - fn page_space_point(&self, page_index: usize, position: Point) -> Option<(f32, f32)> { - let bounds = self.page_bounds.borrow().get(&page_index).copied()?; - let width: f32 = bounds.size.width.into(); - let height: f32 = bounds.size.height.into(); - if width <= 0.0 || height <= 0.0 { - return None; - } - - let norm_x = (f32::from(position.x) - f32::from(bounds.origin.x)) / width; - let norm_y = (f32::from(position.y) - f32::from(bounds.origin.y)) / height; - Some(Self::unrotate_normalized_point( - norm_x.clamp(0.0, 1.0), - norm_y.clamp(0.0, 1.0), - self.page_rotation, - )) - } - - /// 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 { - return; - }; - let (min_page, min_char) = selection.min_endpoint(); - let (max_page, max_char) = selection.max_endpoint(); - - let mut edited = None; - for page_index in min_page..=max_page { - let Some(bytes) = edited - .clone() - .or_else(|| self.active_pdf_bytes(cx).map(|bytes| bytes.to_vec())) - else { - return; - }; - let Some(boxes) = self.char_boxes_for_page(&bytes, page_index) else { - continue; - }; - if boxes.is_empty() { - continue; - } - - let first = if page_index == min_page { - min_char.min(boxes.len() - 1) - } else { - 0 - }; - let last = if page_index == max_page { - max_char.min(boxes.len() - 1) - } else { - boxes.len() - 1 - }; - if first > last { - continue; - } - - let rects: Vec<(f32, f32, f32, f32)> = Self::merge_char_runs(&boxes, first, last) - .into_iter() - .map(|rect| (rect.x, rect.y, rect.width, rect.height)) - .collect(); - if rects.is_empty() { - continue; - } - - match PdfiumEngine::new().add_highlight_annotation( - &bytes, - page_index, - &rects, - self.annotation_color, - ) { - Ok(updated) => edited = Some(updated), - Err(error) => { - log::error!("Failed to add PDF highlight: {error:#}"); - return; - } - } - } - - if let Some(edited) = edited { - self.pdf_selection = None; - self.commit_edited_bytes(edited, cx); - } - } - - fn begin_pen_stroke(&mut self, page_index: usize, point: (f32, f32)) { - self.pdf_selection = None; - self.pen_stroke = Some((page_index, vec![point])); - } - - fn extend_pen_stroke(&mut self, page_index: usize, point: (f32, f32), cx: &mut Context) { - if let Some((stroke_page, points)) = self.pen_stroke.as_mut() { - if *stroke_page != page_index { - return; - } - points.push(point); - cx.notify(); - } - } - - /// Commits the in-progress ink stroke into the document. - fn finish_pen_stroke(&mut self, cx: &mut Context) { - let Some((page_index, points)) = self.pen_stroke.take() else { - return; - }; - if points.len() < 2 { - cx.notify(); - return; - } - - let Some(bytes) = self.active_pdf_bytes(cx).map(|bytes| bytes.to_vec()) else { - return; - }; - match PdfiumEngine::new().add_ink_annotation( - &bytes, - page_index, - std::slice::from_ref(&points), - self.annotation_color, - PEN_LINE_WIDTH, - ) { - Ok(edited) => self.commit_edited_bytes(edited, cx), - Err(error) => log::error!("Failed to add PDF ink annotation: {error:#}"), - } - } - - /// Form widgets of a page, extracted through the engine on first use. Cached even - /// when empty so documents without forms do not re-parse on every frame. - fn form_widgets_for_page( - &self, - pdf_bytes: &[u8], - page_index: usize, - ) -> Arc> { - if let Some(cached) = self.pdf_form_widgets.borrow().get(&page_index) { - return cached.clone(); - } - - let widgets = Arc::new( - PdfiumEngine::new() - .extract_form_widgets_from_bytes(pdf_bytes, page_index) - .log_err() - .unwrap_or_default(), - ); - self.pdf_form_widgets - .borrow_mut() - .insert(page_index, widgets.clone()); - widgets - } - - /// Routes a click on a widget to the right editing affordance: checkboxes and radios - /// toggle, dropdowns open their option list, and text fields get an inline editor. - fn begin_form_widget_edit( - &mut self, - page_index: usize, - widget: &pdf_viewer::PdfFormWidget, - position: Point, - window: &mut Window, - cx: &mut Context, - ) { - match widget.kind { - pdf_viewer::PdfFormWidgetKind::Text => { - self.start_form_text_edit(page_index, widget, window, cx); - } - pdf_viewer::PdfFormWidgetKind::Checkbox => { - self.apply_form_edit( - pdf_viewer::PdfFormEdit::Toggle { - name: widget.name.clone(), - index_in_group: widget.index_in_group, - checked: !widget.checked, - }, - cx, - ); - } - pdf_viewer::PdfFormWidgetKind::Radio => { - if !widget.checked { - self.apply_form_edit( - pdf_viewer::PdfFormEdit::Toggle { - name: widget.name.clone(), - index_in_group: widget.index_in_group, - checked: true, - }, - cx, - ); - } - } - pdf_viewer::PdfFormWidgetKind::ComboBox | pdf_viewer::PdfFormWidgetKind::ListBox => { - if !widget.options.is_empty() { - self.form_option_menu = Some((position, widget.clone())); - cx.notify(); - } - } - _ => {} - } - } - - /// Applies one form edit to the in-memory document and swaps the renders over to the - /// rewritten bytes. Each committed edit rewrites the document once, which is the cost - /// of showing filled values through the rasterized page. - fn apply_form_edit(&mut self, edit: pdf_viewer::PdfFormEdit, cx: &mut Context) { - let Some(bytes) = self.active_pdf_bytes(cx) else { - return; - }; - - match PdfiumEngine::new().apply_form_edits(&bytes, std::slice::from_ref(&edit)) { - Ok(edited) => { - self.pdf_form_widgets.borrow_mut().clear(); - self.commit_edited_bytes(edited, cx); - } - Err(error) => log::error!("Failed to apply PDF form edit: {error:#}"), - } - } - - fn start_form_text_edit( - &mut self, - page_index: usize, - widget: &pdf_viewer::PdfFormWidget, - window: &mut Window, - cx: &mut Context, - ) { - self.form_text_edit = None; - self.form_option_menu = None; - if widget.name.is_empty() { - return; - } - - let initial = widget.value.clone(); - let editor = cx.new(|cx| { - let mut editor = Editor::single_line(window, cx); - editor.set_text(initial, window, cx); - editor.select_all(&SelectAll, window, cx); - editor - }); - let subscription = cx.subscribe_in(&editor, window, { - let widget = widget.clone(); - move |this, editor, event, window, cx| { - if matches!(event, EditorEvent::Blurred) { - let value = editor.read(cx).text(cx); - if this.form_text_edit.is_some() { - this.form_text_edit = None; - window.focus(&this.focus_handle, cx); - this.apply_form_edit( - pdf_viewer::PdfFormEdit::Text { - name: widget.name.clone(), - value, - }, - cx, - ); - } - } - } - }); - - let handle = editor.read(cx).focus_handle(cx); - window.focus(&handle, cx); - self.form_text_edit = Some(FormTextEdit { - editor, - page_index, - widget: widget.clone(), - _subscription: subscription, - }); - cx.notify(); - } - - /// Writes the text field's value on Enter, leaving the field on Escape. - fn handle_form_editor_key_down( - &mut self, - event: &KeyDownEvent, - window: &mut Window, - cx: &mut Context, - ) { - match event.keystroke.key.as_str() { - "enter" => { - cx.stop_propagation(); - self.commit_form_text_edit(window, cx); - } - "escape" => { - cx.stop_propagation(); - self.cancel_form_text_edit(window, cx); - } - _ => {} - } - } - - fn commit_form_text_edit(&mut self, window: &mut Window, cx: &mut Context) { - let Some(edit) = self.form_text_edit.take() else { - return; - }; - let value = edit.editor.read(cx).text(cx); - window.focus(&self.focus_handle, cx); - self.apply_form_edit( - pdf_viewer::PdfFormEdit::Text { - name: edit.widget.name, - value, - }, - cx, - ); - } - - fn cancel_form_text_edit(&mut self, window: &mut Window, cx: &mut Context) { - if self.form_text_edit.take().is_none() { - return; - } - window.focus(&self.focus_handle, cx); - cx.notify(); - } - - fn select_form_option( - &mut self, - widget: &pdf_viewer::PdfFormWidget, - option: String, - cx: &mut Context, - ) { - self.form_option_menu = None; - self.apply_form_edit( - pdf_viewer::PdfFormEdit::Option { - name: widget.name.clone(), - value: option, - }, - cx, - ); - } - /// Rotates every page a quarter turn, invalidating the rendered bitmaps since /// rotation is baked into the rasterized output. fn rotate_pdf_pages(&mut self, delta: u16, cx: &mut Context) { @@ -3439,17 +2842,6 @@ impl Item for ImageView { page_rotation: 0, pdf_outline: None, pdf_metadata: None, - pdf_edited_bytes: None, - pdf_form_widgets: RefCell::new(HashMap::default()), - form_text_edit: None, - form_option_menu: None, - show_annotation_tools: false, - 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), @@ -3622,9 +3014,6 @@ 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)) .on_action(cx.listener(Self::find_next_match)) @@ -3721,7 +3110,6 @@ impl Render for ImageView { this.pdf_selection = None; this.pdf_drag_position = None; this.pdf_autoscroll_task = None; - this.form_option_menu = None; cx.notify(); }), ) @@ -3756,25 +3144,6 @@ impl Render for ImageView { // since scroll offsets cannot go positive. let page_fits_viewport = page_w <= viewport_width; let links = page.links; - // While a drawing tool is armed, clicks belong to the tool - // rather than to links or form fields. - let interaction_enabled = - self.annotation_tool == PdfAnnotationTool::Select; - let form_widgets = self.form_widgets_for_page(&pdf_bytes, page_idx); - let form_editor = self.form_text_edit.as_ref().and_then(|edit| { - (edit.page_index == page_idx).then(|| { - ( - edit.editor.clone(), - Self::rotate_normalized_box( - edit.widget.x, - edit.widget.y, - edit.widget.width, - edit.widget.height, - rotation, - ), - ) - }) - }); let page_img = page_images.remove(&page_idx).or_else(|| page.image.take()); v_flex() @@ -3793,12 +3162,7 @@ impl Render for ImageView { .border_1() .border_color(border_color) .bg(gpui::white()) - .cursor(match self.annotation_tool { - PdfAnnotationTool::Pen => { - gpui::CursorStyle::Crosshair - } - _ => gpui::CursorStyle::IBeam, - }) + .cursor(gpui::CursorStyle::IBeam) .on_mouse_down( MouseButton::Left, cx.listener(move |this, event: &MouseDownEvent, window, cx| { @@ -3962,7 +3326,6 @@ impl Render for ImageView { div() .id(("pdf-link", page_idx * 100_000 + link_idx)) .absolute() - .when(!interaction_enabled, |this| this.cursor_default()) .left(link_left) .top(link_top) .w(link_w) @@ -3975,126 +3338,17 @@ impl Render for ImageView { .rounded_xs() }) .tooltip(Tooltip::text(link_tooltip)) - .when(interaction_enabled, |this| { - this.on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { - cx.stop_propagation(); - match link_dest_page { - Some(page) => this.jump_to_pdf_page(page, cx), - None if Self::is_safe_pdf_url(&link_url) => cx.open_url(&link_url), - None => log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url), - } - }), - ) - }) - })) - .children(form_widgets.iter().enumerate().map(|(widget_index, widget)| { - let (widget_x, widget_y, widget_w, widget_h) = - Self::rotate_normalized_box( - widget.x, widget.y, widget.width, widget.height, rotation, - ); - let widget = widget.clone(); - let cursor = match widget.kind { - pdf_viewer::PdfFormWidgetKind::Text => gpui::CursorStyle::IBeam, - pdf_viewer::PdfFormWidgetKind::PushButton - | pdf_viewer::PdfFormWidgetKind::Signature => gpui::CursorStyle::Arrow, - _ => gpui::CursorStyle::PointingHand, - }; - let tooltip = if widget.name.is_empty() { - "Form field".to_string() - } else { - widget.name.clone() - }; - - div() - .id(("pdf-form-widget", page_idx * 100_000 + widget_index)) - .absolute() - .left(px(widget_x * page_w_val)) - .top(px(widget_y * page_h_val)) - .w(px(widget_w * page_w_val)) - .h(px(widget_h * page_h_val)) - .cursor(cursor) - .when(interaction_enabled, |this| { - this.on_mouse_down( - MouseButton::Left, - cx.listener(move |this, event: &MouseDownEvent, window, cx| { - cx.stop_propagation(); - window.focus(&this.focus_handle, cx); - this.begin_form_widget_edit( - page_idx, - &widget, - event.position, - window, - cx, - ); - }), - ) - }) - .tooltip(Tooltip::text(tooltip)) - })) - .children(self.pen_stroke.as_ref().filter(|(stroke_page, _)| { - *stroke_page == page_idx - }).map(|(_, points)| { - let points: Vec<(f32, f32)> = points.clone(); - let color = self.annotation_color; - canvas( - |_, _, _| {}, - move |bounds: Bounds, _state: (), window: &mut Window, _cx: &mut App| { - if points.len() < 2 { - return; - } - let origin = bounds.origin; - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - let mut builder = gpui::PathBuilder::stroke(px(PEN_LINE_WIDTH * 2.0)); - let rotated: Vec<(f32, f32)> = points - .iter() - .map(|(x, y)| { - let (rx, ry, ..) = - ImageView::rotate_normalized_box(*x, *y, 0.0, 0.0, rotation); - (rx, ry) - }) - .collect(); - builder.move_to(point( - origin.x + px(rotated[0].0 * width), - origin.y + px(rotated[0].1 * height), - )); - for (x, y) in rotated.iter().skip(1) { - builder.line_to(point( - origin.x + px(x * width), - origin.y + px(y * height), - )); - } - if let Ok(path) = builder.build() { - window.paint_path( - path, - gpui::rgb( - ((color.red as u32) << 16) - | ((color.green as u32) << 8) - | color.blue as u32, - ), - ); - } - }, - ) - .absolute() - .size_full() - })) - .children(form_editor.map(|(editor, (wx, wy, ww, wh))| { - div() - .id(("pdf-form-editor", page_idx)) - .absolute() - .left(px(wx * page_w_val)) - .top(px(wy * page_h_val)) - .w(px(ww * page_w_val)) - .h(px(wh * page_h_val)) - .occlude() - .bg(cx.theme().colors().editor_background) - .border_1() - .border_color(cx.theme().colors().border_focused) - .capture_key_down(cx.listener(Self::handle_form_editor_key_down)) - .child(editor) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _event: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + match link_dest_page { + Some(page) => this.jump_to_pdf_page(page, cx), + None if Self::is_safe_pdf_url(&link_url) => cx.open_url(&link_url), + None => log::warn!("Blocked opening potentially unsafe PDF link: {}", link_url), + } + }), + ) })) ) .when(!presentation_mode, |this| { @@ -4119,127 +3373,6 @@ impl Render for ImageView { window, cx, ) - .children(self.show_annotation_tools.then(|| { - let label = |tool: PdfAnnotationTool| match tool { - PdfAnnotationTool::Select => "Select", - PdfAnnotationTool::Highlight => "Highlight", - PdfAnnotationTool::Pen => "Pen", - }; - let tool_button = |tool: PdfAnnotationTool, cx: &mut Context| { - let is_active = self.annotation_tool == tool; - Button::new(("pdf-annotation-tool", tool as usize), label(tool)) - .label_size(LabelSize::XSmall) - .toggle_state(is_active) - .on_click(cx.listener(move |this, _, _, cx| { - this.set_annotation_tool(tool, cx); - })) - }; - - h_flex() - .id("pdf-annotation-tools") - .debug_selector(|| "pdf-annotation-tools".into()) - .absolute() - .top_2() - .left_2() - .occlude() - .items_center() - .gap_1() - .px_2() - .py_1() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) - .shadow_lg() - .child(tool_button(PdfAnnotationTool::Select, cx)) - .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() - .id(( - "pdf-annotation-color", - color.red as usize * 65_536 - + color.green as usize * 256 - + color.blue as usize, - )) - .w(px(14.0)) - .h(px(14.0)) - .rounded_sm() - .cursor_pointer() - .bg(gpui::rgb( - ((color.red as u32) << 16) - | ((color.green as u32) << 8) - | color.blue as u32, - )) - .when(is_active, |this| { - this.border_2() - .border_color(cx.theme().colors().border_focused) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.set_annotation_color(color, cx); - })) - })) - .into_any_element() - })) - .children(self.form_option_menu.as_ref().map(|(position, widget)| { - let options = widget.options.clone(); - let selected = widget.value.clone(); - let widget = widget.clone(); - deferred( - anchored() - .position(*position) - .anchor(gpui::Anchor::TopLeft) - .child( - v_flex() - .id("pdf-form-options") - .occlude() - .w(px(160.0)) - .max_h(px(240.0)) - .overflow_y_scroll() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().elevated_surface_background) - .shadow_lg() - .py_1() - .children(options.into_iter().enumerate().map( - |(option_index, option)| { - let is_selected = option == selected; - let option_value = option.clone(); - let widget = widget.clone(); - - div() - .id(("pdf-form-option", option_index)) - .px_2() - .py_1() - .cursor_pointer() - .hover(|style| { - style.bg(cx.theme().colors().element_hover) - }) - .when(is_selected, |this| { - this.bg(cx.theme().colors().element_selected) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.select_form_option(&widget, option_value.clone(), cx); - })) - .child(Label::new(option).size(LabelSize::Small)) - }, - )), - ), - ) - .with_priority(3) - })) .children(self.pdf_find.as_ref().filter(|_| !presentation_mode).map(|find| { let status = if find.matches.is_empty() { if find.editor.read(cx).text(cx).trim().is_empty() { @@ -4521,21 +3654,14 @@ impl Render for ImageViewToolbarControls { }; let image_item = image_view.read(cx).image_item.clone(); - let (is_pdf, current_page, total_pages, show_text_panel, show_annotation_tools) = { + let (is_pdf, current_page, total_pages, show_text_panel) = { let item = image_item.read(cx); let view = image_view.read(cx); let show_text = view.show_text_panel; - let show_annotation_tools = view.show_annotation_tools; let current_page = view .visible_pdf_page(cx) .min(item.total_pages().saturating_sub(1)); - ( - item.is_pdf(), - current_page, - item.total_pages(), - show_text, - show_annotation_tools, - ) + (item.is_pdf(), current_page, item.total_pages(), show_text) }; h_flex() @@ -4589,7 +3715,6 @@ impl Render for ImageViewToolbarControls { let image_view_outline = image_view.downgrade(); let image_view_presentation = image_view.downgrade(); let image_view_properties = image_view.downgrade(); - let image_view_annotations = image_view.downgrade(); let image_view_rotate_ccw = image_view.downgrade(); let image_view_rotate_cw = image_view.downgrade(); this.child( @@ -4638,25 +3763,6 @@ impl Render for ImageViewToolbarControls { } }), ) - .child( - IconButton::new("toggle-annotations", IconName::Pencil) - .icon_size(IconSize::Small) - .toggle_state(show_annotation_tools) - .tooltip(|_window, cx| { - Tooltip::for_action("Annotate", &ToggleAnnotationTools, cx) - }) - .on_click(move |_, window, cx| { - if let Some(view) = image_view_annotations.upgrade() { - view.update(cx, |this, cx| { - this.toggle_annotation_tools( - &ToggleAnnotationTools, - window, - cx, - ); - }); - } - }), - ) .child( IconButton::new("rotate-counterclockwise", IconName::RotateCcw) .icon_size(IconSize::Small) @@ -5697,9 +4803,8 @@ mod tests { assert!(cache.is_empty()); } - /// Single-page PDF with one text line ("Hello annotation world") near the - /// top, at the same spot the annotation drag test targets. - fn build_annotation_fixture() -> Vec { + /// Single-page PDF with one text line near the top, used as the test document. + fn build_test_pdf() -> Vec { let contents = b"BT /F1 24 Tf 72 700 Td (Hello annotation world) Tj ET"; let bodies: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), @@ -5742,7 +4847,7 @@ mod tests { #[gpui::test] async fn test_pdf_zoom_renders_asynchronously(cx: &mut TestAppContext) { init_test(cx); - let pdf_bytes = build_annotation_fixture(); + let pdf_bytes = build_test_pdf(); let fs = FakeFs::new(cx.executor()); fs.create_dir(Path::new("/root")) .await @@ -5814,7 +4919,7 @@ mod tests { #[gpui::test] async fn test_pdf_wheel_zoom_and_middle_drag_pan(cx: &mut TestAppContext) { init_test(cx); - let pdf_bytes = build_annotation_fixture(); + let pdf_bytes = build_test_pdf(); let fs = FakeFs::new(cx.executor()); fs.create_dir(Path::new("/root")) .await @@ -6036,218 +5141,6 @@ mod tests { "pinch delta 0.5 should scale zoom by 1.5x, got {zoom_before_pinch} -> {zoom_after_pinch}" ); } - - #[gpui::test] - async fn test_annotation_tools_apply_via_mouse(cx: &mut TestAppContext) { - init_test(cx); - // Highlighting requires real text extraction; the mock fallback pages carry - // no character boxes, so only a native engine can exercise this flow. - if !PdfiumEngine::new().is_native_available() { - eprintln!("skipping: native libpdfium not available on this machine"); - return; - } - let pdf_bytes = build_annotation_fixture(); - let fs = FakeFs::new(cx.executor()); - fs.create_dir(Path::new("/root")) - .await - .expect("test root should be created"); - fs.insert_file("/root/annot.pdf", pdf_bytes).await; - - let project = Project::test(fs, [Path::new("/root")], cx).await; - let worktree_id = cx.update(|cx| { - project - .read(cx) - .worktrees(cx) - .next() - .expect("test project should contain a worktree") - .read(cx) - .id() - }); - let image_item = project - .update(cx, |project, cx| { - project.open_image( - ProjectPath { - worktree_id, - path: rel_path("annot.pdf").into(), - }, - cx, - ) - }) - .await - .expect("test pdf should open"); - - let (image_view, cx) = cx - .add_window_view(|window, cx| ImageView::new(image_item.clone(), project, window, cx)); - cx.run_until_parked(); - cx.update(|window, _| window.refresh()); - cx.run_until_parked(); - - let original_len = cx - .read(|cx| { - image_item - .read(cx) - .pdf_info - .as_ref() - .map(|i| i.pdf_bytes.len()) - }) - .expect("pdf should be loaded"); - - cx.update(|window, cx| { - image_view.update(cx, |view, cx| { - view.toggle_annotation_tools(&ToggleAnnotationTools, window, cx) - }); - }); - cx.run_until_parked(); - cx.update(|window, _| window.refresh()); - cx.run_until_parked(); - - // Click the palette's Highlight button (second tool button) like a user would. - let palette = cx - .debug_bounds("pdf-annotation-tools") - .expect("the annotation palette should be laid out"); - cx.simulate_event(MouseDownEvent { - position: point(palette.origin.x + px(70.), palette.center().y), - button: MouseButton::Left, - modifiers: Modifiers::default(), - click_count: 1, - first_mouse: false, - }); - cx.run_until_parked(); - cx.simulate_event(MouseUpEvent { - position: point(palette.origin.x + px(70.), palette.center().y), - button: MouseButton::Left, - modifiers: Modifiers::default(), - click_count: 1, - }); - cx.run_until_parked(); - let armed = cx.read(|cx| image_view.read(cx).annotation_tool); - assert_eq!( - armed, - PdfAnnotationTool::Highlight, - "clicking the palette button should arm the highlight tool" - ); - - let canvas = cx - .debug_bounds("pdf-page-canvas-0") - .expect("page canvas should be laid out"); - let drag = |cx: &mut VisualTestContext, from: Point, to: Point| { - cx.simulate_event(MouseDownEvent { - position: from, - button: MouseButton::Left, - modifiers: Modifiers::default(), - click_count: 1, - first_mouse: false, - }); - cx.run_until_parked(); - cx.simulate_event(MouseMoveEvent { - position: from + point(px(2.), px(0.)), - pressed_button: Some(MouseButton::Left), - modifiers: Modifiers::default(), - }); - cx.run_until_parked(); - cx.simulate_event(MouseMoveEvent { - position: to, - pressed_button: Some(MouseButton::Left), - modifiers: Modifiers::default(), - }); - cx.run_until_parked(); - cx.simulate_event(MouseUpEvent { - position: to, - button: MouseButton::Left, - modifiers: Modifiers::default(), - click_count: 1, - }); - cx.run_until_parked(); - }; - - let text_start = point( - canvas.origin.x + canvas.size.width * 0.25, - canvas.origin.y + canvas.size.height * 0.10, - ); - let text_end = point( - canvas.origin.x + canvas.size.width * 0.55, - canvas.origin.y + canvas.size.height * 0.10, - ); - drag(cx, text_start, text_end); - - let (highlight_len, selection) = cx.read(|cx| { - ( - image_view - .read(cx) - .pdf_edited_bytes - .as_ref() - .map(|bytes| bytes.len()), - image_view.read(cx).pdf_selection.clone(), - ) - }); - assert!( - highlight_len.is_some_and(|len| len > original_len), - "highlighting over text should commit edited bytes, got {highlight_len:?} vs {original_len}" - ); - assert!( - selection.is_none(), - "the selection should clear after highlighting" - ); - - cx.update(|_window, cx| { - image_view.update(cx, |view, cx| { - view.set_annotation_tool(PdfAnnotationTool::Pen, cx); - }); - }); - cx.run_until_parked(); - - let stroke_start = point( - canvas.origin.x + canvas.size.width * 0.3, - canvas.origin.y + canvas.size.height * 0.4, - ); - let stroke_end = point( - canvas.origin.x + canvas.size.width * 0.6, - canvas.origin.y + canvas.size.height * 0.5, - ); - drag(cx, stroke_start, stroke_end); - - let pen_len = cx.read(|cx| { - image_view - .read(cx) - .pdf_edited_bytes - .as_ref() - .map(|bytes| bytes.len()) - }); - assert!( - pen_len.is_some_and(|len| len > highlight_len.unwrap_or(0)), - "pen stroke should grow the edited document further, got {pen_len:?} vs {highlight_len:?}" - ); - - // A highlight drag that starts on blank space must still commit once it - // reaches the text line. - cx.update(|_window, cx| { - image_view.update(cx, |view, cx| { - view.set_annotation_tool(PdfAnnotationTool::Highlight, cx) - }); - }); - cx.run_until_parked(); - let blank_start = point( - canvas.origin.x + canvas.size.width * 0.5, - canvas.origin.y + canvas.size.height * 0.5, - ); - let text_end = point( - canvas.origin.x + canvas.size.width * 0.5, - canvas.origin.y + canvas.size.height * 0.10, - ); - drag(cx, blank_start, text_end); - - let blank_drag_len = cx.read(|cx| { - image_view - .read(cx) - .pdf_edited_bytes - .as_ref() - .map(|bytes| bytes.len()) - }); - assert!( - blank_drag_len.is_some_and(|len| len > pen_len.unwrap_or(0)), - "a drag starting on blank space should still highlight text, got {blank_drag_len:?} vs {pen_len:?}" - ); - } } mod persistence { diff --git a/crates/pdf_viewer/README.md b/crates/pdf_viewer/README.md index a1531c5184..e92dd16203 100644 --- a/crates/pdf_viewer/README.md +++ b/crates/pdf_viewer/README.md @@ -52,8 +52,9 @@ A native, high performance PDF viewing engine and GPUI workspace item engineered ## 2. Scope Boundaries - **Continuous Scroll & Text Selection**: Supports fluid high frame rate document browsing and visual text selection. -- **Excluded from initial scope**: +- **Excluded from scope**: - Interactive PDF form field editing. + - Annotation authoring, editing and deletion. - *Rationale*: Delivering an ultra fast, stutter free viewing experience with zero UI locks takes precedence. --- diff --git a/crates/pdf_viewer/src/pdf_viewer.rs b/crates/pdf_viewer/src/pdf_viewer.rs index 9203eb4601..af9caf419b 100644 --- a/crates/pdf_viewer/src/pdf_viewer.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -22,9 +22,8 @@ pub mod watcher; pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ - PdfAnnotationColor, PdfAnnotationInfo, PdfAnnotationKind, PdfCharBox, PdfDocumentDetails, - PdfFormEdit, PdfFormWidget, PdfFormWidgetKind, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, - PdfPageDetails, PdfTextSegment, PdfiumEngine, + PdfCharBox, PdfDocumentDetails, 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 dc95883564..644055f15d 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -6,7 +6,6 @@ use anyhow::{Context as _, Result}; use parking_lot::Mutex; use pdfium_render::prelude::*; -use std::os::raw::{c_int, c_uint, c_ulong, c_void}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -1274,1301 +1273,6 @@ impl PdfiumEngine { } } -// --------------------------------------------------------------------------- -// Form (AcroForm) widgets -// --------------------------------------------------------------------------- -// -// pdfium-render's high-level API exposes no way to walk a page's form widgets, no widget -// bounds, and no way to write a value back into a document, so the form access below calls the -// FPDF C API directly through the trait object returned by `Pdfium::bindings()`. Every handle -// pdfium hands out is owned by a guard type, so annotations, pages, form fill environments and -// documents are released on error and unwind paths as well as on the happy path. - -/// The kind of an interactive form widget, mirroring pdfium's `FPDF_FORMFIELD_*` values. -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum PdfFormWidgetKind { - Text, - Checkbox, - Radio, - ComboBox, - ListBox, - PushButton, - Signature, - Unknown, -} - -/// A single form widget on a page. -/// -/// Coordinates live in the same normalized page space as [`PdfCharBox`] and -/// [`PdfLinkAnnotation`]: fractions of the page's unrotated width and height measured from the -/// top-left corner (see [`normalize_coordinates`]). -#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] -pub struct PdfFormWidget { - /// Fully qualified field name; empty when the widget belongs to no form field. - pub name: String, - pub kind: PdfFormWidgetKind, - /// Current value of a text, combo box or list box field; empty for every other kind. - pub value: String, - /// Current checked state of a check box or radio button; `false` for every other kind. - pub checked: bool, - /// Index of the widget within its button control group; `0` for every other kind. - pub index_in_group: u32, - /// Selectable options of a combo box or list box; empty for every other kind. - pub options: Vec, - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, -} - -/// A single form edit made by the user, applied to a document by -/// [`PdfiumEngine::apply_form_edits`]. -#[derive(Debug, Clone, PartialEq)] -pub enum PdfFormEdit { - /// Sets the value of a text field. - Text { name: String, value: String }, - /// Sets the checked state of the check box or radio button widget at - /// [`PdfFormWidget::index_in_group`] within its control group. - Toggle { - name: String, - index_in_group: u32, - checked: bool, - }, - /// Sets the selected value of a combo box or list box field. - Option { name: String, value: String }, -} - -/// `FPDF_FORMFIELD_*` field types from pdfium's `public/fpdf_formfill.h`. -const FPDF_FORMFIELD_PUSHBUTTON: c_int = 1; -const FPDF_FORMFIELD_CHECKBOX: c_int = 2; -const FPDF_FORMFIELD_RADIOBUTTON: c_int = 3; -const FPDF_FORMFIELD_COMBOBOX: c_int = 4; -const FPDF_FORMFIELD_LISTBOX: c_int = 5; -const FPDF_FORMFIELD_TEXTFIELD: c_int = 6; -const FPDF_FORMFIELD_SIGNATURE: c_int = 7; - -/// `FPDF_ANNOT_WIDGET` annotation subtype from pdfium's `public/fpdf_annot.h`. -const FPDF_ANNOT_WIDGET: c_int = 20; - -/// Value reported in `FPDF_FORMFILLINFO::version`. pdfium accepts 1 or 2, and rejects 1 outright -/// when it is built with XFA support (`CheckFormfillVersion`). -const FORM_FILL_INFO_VERSION: c_int = 2; - -/// Value reported in `FPDF_FILEWRITE::version`; pdfium requires 1. -const FILE_WRITE_VERSION: c_int = 1; - -/// Key of a form field's current value in an annotation dictionary. -const ANNOT_KEY_VALUE: &str = "V"; - -/// Key of a button widget's current appearance state in an annotation dictionary. -const ANNOT_KEY_APPEARANCE_STATE: &str = "AS"; - -/// Appearance state selecting a button widget's "not selected" appearance stream. -const BUTTON_STATE_OFF: &str = "Off"; - -/// A pdfium `FPDF_FILEWRITE` paired with the buffer its write callback fills. -/// -/// `file_write` must remain the first field: [`save_document_to_bytes`] hands pdfium a pointer to -/// it, and pdfium passes that same pointer back to the callback, which recovers the whole struct -/// from it. -#[repr(C)] -struct PdfiumSaveTarget { - file_write: FPDF_FILEWRITE, - buffer: Vec, -} - -impl PdfiumSaveTarget { - /// Returns the `FPDF_FILEWRITE` pointer pdfium expects, relying on `file_write` being the - /// first field of a `#[repr(C)]` struct. This is the same layout trick pdfium-render uses for - /// its own `FpdfFileWriteExt`. - fn as_file_write_mut_ptr(&mut self) -> *mut FPDF_FILEWRITE { - std::ptr::from_mut(self).cast::() - } -} - -/// Appends one block of the serialized document to the buffer of the [`PdfiumSaveTarget`] whose -/// `FPDF_FILEWRITE` pdfium was handed, reporting failure by returning zero as pdfium requires. -/// -/// # Safety -/// `p_this` must be the `file_write` field of a live [`PdfiumSaveTarget`], and `data` must point -/// to `size` readable bytes (or be null when `size` is zero). pdfium guarantees both: it only -/// ever calls this callback with the pointer it was given in -/// [`PdfiumSaveTarget::as_file_write_mut_ptr`], which never escapes `save_document_to_bytes`. -unsafe extern "C" fn write_document_block( - p_this: *mut FPDF_FILEWRITE, - data: *const c_void, - size: c_ulong, -) -> c_int { - // SAFETY: as documented above, `p_this` points at the first field of a live - // `PdfiumSaveTarget`, whose address is the address of the struct itself; the struct outlives - // this call because pdfium only invokes the callback from inside the synchronous - // `FPDF_SaveAsCopy` call. `data`/`size` describe a pdfium-owned block that stays readable for - // the duration of the call. - unsafe { - let target = &mut *p_this.cast::(); - - if size == 0 { - return 1; - } - if data.is_null() { - return 0; - } - - let block = std::slice::from_raw_parts(data.cast::(), size as usize); - target.buffer.extend_from_slice(block); - - 1 - } -} - -/// Owns a loaded pdfium document, closing it on drop so no handle escapes the scope that loaded -/// it. -struct PdfiumDocumentGuard<'a> { - bindings: &'a dyn PdfiumLibraryBindings, - document: FPDF_DOCUMENT, -} - -impl Drop for PdfiumDocumentGuard<'_> { - fn drop(&mut self) { - if !self.document.is_null() { - self.bindings.FPDF_CloseDocument(self.document); - } - } -} - -/// Owns a pdfium form fill environment, releasing it on drop. -struct PdfiumFormGuard<'a> { - bindings: &'a dyn PdfiumLibraryBindings, - form: FPDF_FORMHANDLE, -} - -impl Drop for PdfiumFormGuard<'_> { - fn drop(&mut self) { - if !self.form.is_null() { - self.bindings.FPDFDOC_ExitFormFillEnvironment(self.form); - } - } -} - -/// Owns a loaded pdfium page, closing it on drop. -/// -/// The page is detached from the form fill environment first, because that environment keeps a -/// page view for every page it has seen and pdfium requires the view to be gone before the page -/// itself is closed. -struct PdfiumPageGuard<'a> { - bindings: &'a dyn PdfiumLibraryBindings, - page: FPDF_PAGE, - form: FPDF_FORMHANDLE, -} - -impl Drop for PdfiumPageGuard<'_> { - fn drop(&mut self) { - if self.page.is_null() { - return; - } - - if !self.form.is_null() { - self.bindings.FORM_OnBeforeClosePage(self.page, self.form); - } - - self.bindings.FPDF_ClosePage(self.page); - } -} - -/// Owns an annotation handle obtained from a page, closing it on drop. -struct PdfiumAnnotGuard<'a> { - bindings: &'a dyn PdfiumLibraryBindings, - annot: FPDF_ANNOTATION, -} - -impl Drop for PdfiumAnnotGuard<'_> { - fn drop(&mut self) { - if !self.annot.is_null() { - self.bindings.FPDFPage_CloseAnnot(self.annot); - } - } -} - -/// Builds the form fill environment descriptor pdfium expects. -/// -/// Every optional callback is left unset. That is safe because pdfium null-checks each callback -/// before invoking it (`CPDFSDK_FormFillEnvironment`), and because this engine only queries and -/// writes field state instead of driving pdfium's interactive form filler. It is also the shape -/// pdfium-render itself passes from `PdfForm::from_pdfium`. -fn empty_form_fill_info() -> FPDF_FORMFILLINFO { - // SAFETY: `FPDF_FORMFILLINFO` is a `#[repr(C)]` struct of integers, one raw pointer - // (`m_pJsPlatform`) and `Option` callback slots. The all-zero bit pattern is a - // valid value for every one of them (null pointer, `None`). - let mut info: FPDF_FORMFILLINFO = unsafe { std::mem::zeroed() }; - info.version = FORM_FILL_INFO_VERSION; - info -} - -/// Reads a UTF-16LE string out of pdfium by calling an accessor twice: once with a null buffer to -/// measure it, then once with a buffer big enough to receive it. -/// -/// pdfium's string accessors report the byte length of the string *including* its two-byte NUL -/// terminator and only copy when the caller's buffer is at least that large -/// (`Utf16EncodeMaybeCopyAndReturnLength`), which is why the receiving buffer is allocated one -/// code unit beyond the measured length. -fn read_pdfium_wide_string(read: impl Fn(*mut FPDF_WCHAR, c_ulong) -> c_ulong) -> String { - let required_bytes = read(std::ptr::null_mut(), 0) as usize; - if required_bytes == 0 { - return String::new(); - } - - let mut buffer = vec![0u16; required_bytes / 2 + 1]; - let capacity = (buffer.len() * std::mem::size_of::()) as c_ulong; - let written_bytes = read(buffer.as_mut_ptr(), capacity) as usize; - if written_bytes == 0 { - return String::new(); - } - - // `written_bytes` counts the NUL terminator, which is not part of the string itself. - let mut units = (written_bytes / 2).min(buffer.len()); - if units > 0 && buffer[units - 1] == 0 { - units -= 1; - } - - String::from_utf16_lossy(&buffer[..units]) -} - -/// Maps an `FPDF_FORMFIELD_*` value onto the viewer's widget kinds. -/// -/// The `XFA_*` values describe XFA form fields, which this engine does not load, and anything -/// pdfium does not define is reported as [`PdfFormWidgetKind::Unknown`]. -fn form_widget_kind(field_type: c_int) -> PdfFormWidgetKind { - match field_type { - FPDF_FORMFIELD_PUSHBUTTON => PdfFormWidgetKind::PushButton, - FPDF_FORMFIELD_CHECKBOX => PdfFormWidgetKind::Checkbox, - FPDF_FORMFIELD_RADIOBUTTON => PdfFormWidgetKind::Radio, - FPDF_FORMFIELD_COMBOBOX => PdfFormWidgetKind::ComboBox, - FPDF_FORMFIELD_LISTBOX => PdfFormWidgetKind::ListBox, - FPDF_FORMFIELD_TEXTFIELD => PdfFormWidgetKind::Text, - FPDF_FORMFIELD_SIGNATURE => PdfFormWidgetKind::Signature, - _ => PdfFormWidgetKind::Unknown, - } -} - -/// Returns the widget's index within its button control group, or `0` for a widget pdfium cannot -/// place in one. -/// -/// [`PdfiumEngine::extract_form_widgets_from_bytes`] reports this as -/// [`PdfFormWidget::index_in_group`] and [`PdfiumEngine::apply_form_edits`] resolves the index of -/// an edit's target through the same function, so the two always agree. -fn form_control_index( - bindings: &dyn PdfiumLibraryBindings, - form: FPDF_FORMHANDLE, - annot: FPDF_ANNOTATION, -) -> u32 { - let index = bindings.FPDFAnnot_GetFormControlIndex(form, annot); - - if index < 0 { 0 } else { index as u32 } -} - -/// Reads the option labels of a combo box or list box widget's `/Opt` array, in stored order. -fn read_form_widget_options( - bindings: &dyn PdfiumLibraryBindings, - form: FPDF_FORMHANDLE, - annot: FPDF_ANNOTATION, -) -> Vec { - let count = bindings.FPDFAnnot_GetOptionCount(form, annot); - if count <= 0 { - return Vec::new(); - } - - (0..count) - .map(|index| { - read_pdfium_wide_string(|buffer, length| { - bindings.FPDFAnnot_GetOptionLabel(form, annot, index, buffer, length) - }) - }) - .collect() -} - -/// Writes `value` into the `V` entry of the widget's annotation dictionary. -/// -/// pdfium resolves a field's value from the dictionary carrying the field's `/FT` entry, which is -/// the annotation dictionary itself for the single-widget fields this viewer edits (text boxes, -/// combo boxes, list boxes and single-widget check boxes). -fn set_form_widget_value( - bindings: &dyn PdfiumLibraryBindings, - annot: FPDF_ANNOTATION, - value: &str, -) -> Result<()> { - if !bindings.is_true(bindings.FPDFAnnot_SetStringValue_str(annot, ANNOT_KEY_VALUE, value)) { - anyhow::bail!("Pdfium failed to write a form field value"); - } - - Ok(()) -} - -/// Writes `state` into the widget's `AS` entry and reports whether pdfium then agrees that the -/// widget's checked state is `expected`. -fn set_button_appearance_state( - bindings: &dyn PdfiumLibraryBindings, - form: FPDF_FORMHANDLE, - annot: FPDF_ANNOTATION, - state: &str, - expected: bool, -) -> bool { - if !bindings.is_true(bindings.FPDFAnnot_SetStringValue_str( - annot, - ANNOT_KEY_APPEARANCE_STATE, - state, - )) { - return false; - } - - bindings.is_true(bindings.FPDFAnnot_IsChecked(form, annot)) == expected -} - -/// Selects the appearance state that makes pdfium report the widget as checked or unchecked. -/// -/// pdfium reads a button widget's state from its appearance state rather than from the field -/// value: `CPDF_FormControl::IsChecked()` compares the widget's `/AS` entry against the non-`Off` -/// key of its `/AP /N` dictionary, and the same `/AS` entry selects the appearance stream pdfium -/// draws (`GetAnnotAPInternal`, which falls back to `/V` only when `/AS` is absent). Writing -/// `/AS` is therefore both what pdfium itself does when a user clicks a check box -/// (`CPDF_FormControl::CheckControl`) and what makes the saved document render the new state, so -/// `FPDFAnnot_SetAP` is deliberately not called: that would replace the appearance stream itself -/// instead of choosing between the streams the document already carries. -/// -/// The name of the "on" state is the widget's export value for every button field without an -/// `/Opt` array (`CPDF_FormControl::GetExportValue` falls back to the state name); fields that do -/// carry one name their appearance states by control index instead -/// (`CPDF_FormControl::GetCheckedAPState`), so that index is tried second. Returns `true` once -/// pdfium confirms the requested state. -fn set_button_checked( - bindings: &dyn PdfiumLibraryBindings, - form: FPDF_FORMHANDLE, - annot: FPDF_ANNOTATION, - checked: bool, -) -> bool { - if !checked { - return set_button_appearance_state(bindings, form, annot, BUTTON_STATE_OFF, false); - } - - let mut candidates = Vec::new(); - - let export_value = read_pdfium_wide_string(|buffer, length| { - bindings.FPDFAnnot_GetFormFieldExportValue(form, annot, buffer, length) - }); - if !export_value.is_empty() { - candidates.push(export_value); - } - - let control_index = bindings.FPDFAnnot_GetFormControlIndex(form, annot); - if control_index >= 0 { - let index_state = control_index.to_string(); - if !candidates.contains(&index_state) { - candidates.push(index_state); - } - } - - if candidates.is_empty() { - // pdfium's own last-resort name for an on state (`CPDF_FormControl::GetExportValue`). - candidates.push("Yes".to_string()); - } - - for state in candidates { - if set_button_appearance_state(bindings, form, annot, &state, true) { - return true; - } - } - - false -} - -/// Unchecks every other widget of the same button field on `page`. -/// -/// One button field owns all of its widgets, so selecting one of them has to clear the rest: -/// that is what pdfium itself does through `CPDF_FormField::SetCheckValue`, which walks the -/// field's controls and calls `CheckControl()` on each. Widgets are addressed here by fully -/// qualified field name plus control index, the only identity the annotation API exposes. -fn uncheck_other_button_widgets( - bindings: &dyn PdfiumLibraryBindings, - form: FPDF_FORMHANDLE, - page: FPDF_PAGE, - field_name: &str, - checked_index: u32, -) { - 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 }; - - if bindings.FPDFAnnot_GetSubtype(annot) != FPDF_ANNOT_WIDGET { - continue; - } - if !matches!( - form_widget_kind(bindings.FPDFAnnot_GetFormFieldType(form, annot)), - PdfFormWidgetKind::Checkbox | PdfFormWidgetKind::Radio - ) { - continue; - } - if read_pdfium_wide_string(|buffer, length| { - bindings.FPDFAnnot_GetFormFieldName(form, annot, buffer, length) - }) != field_name - { - continue; - } - if form_control_index(bindings, form, annot) == checked_index { - continue; - } - - if !set_button_appearance_state(bindings, form, annot, BUTTON_STATE_OFF, false) { - log::warn!("Pdfium did not clear the checked state of a form widget"); - } - } -} - -/// Serializes `document` into a fresh byte buffer through pdfium's write callback. -/// -/// The save is requested with flags `0`, which pdfium normalizes to "no flags" (`DoDocSave`) and -/// therefore performs as a complete rewrite instead of an incremental update - the only form -/// valid for a document loaded from a memory buffer, because an incremental save would need the -/// byte offsets of the original file. -fn save_document_to_bytes( - bindings: &dyn PdfiumLibraryBindings, - document: FPDF_DOCUMENT, -) -> Result> { - let mut target = PdfiumSaveTarget { - file_write: FPDF_FILEWRITE { - version: FILE_WRITE_VERSION, - WriteBlock: Some(write_document_block), - }, - buffer: Vec::new(), - }; - - if !bindings.is_true(bindings.FPDF_SaveAsCopy(document, target.as_file_write_mut_ptr(), 0)) { - anyhow::bail!("Pdfium failed to serialize the edited PDF document"); - } - if target.buffer.is_empty() { - anyhow::bail!("Pdfium serialized the edited PDF document into an empty buffer"); - } - - Ok(target.buffer) -} - -impl PdfiumEngine { - /// Lists the form widgets of a zero-indexed page, in pdfium's annotation order. - /// - /// Coordinates are normalized exactly as in [`PdfCharBox`] and [`PdfLinkAnnotation`]. Widgets - /// whose rectangle pdfium will not report are still listed, with a zero-sized box, so that - /// widget names and control indices stay stable for callers. Documents without a form, and - /// engines without a native pdfium library, produce an empty list rather than an error. - pub fn extract_form_widgets_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 form widget extraction"); - } - // Declared before the form and page guards below, so those are released first: pdfium - // requires the pages to be closed before the document that owns them. - let _document_guard = PdfiumDocumentGuard { bindings, document }; - - let mut form_info = empty_form_fill_info(); - let form = bindings.FPDFDOC_InitFormFillEnvironment(document, &mut form_info); - if form.is_null() { - anyhow::bail!("Pdfium failed to initialize a form fill environment"); - } - // `form_info` must outlive the environment: pdfium keeps the pointer handed to it above. - let _form_guard = PdfiumFormGuard { bindings, form }; - - 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 form widget extraction" - ); - } - let _page_guard = PdfiumPageGuard { - bindings, - page, - form, - }; - - // pdfium only builds the widget objects that `FPDFAnnot_IsChecked()` and its siblings - // look up once the page is announced to the form fill environment, so this call is - // required before any field state is read. - bindings.FORM_OnAfterLoadPage(page, form); - - let page_width = bindings.FPDF_GetPageWidthF(page); - let page_height = bindings.FPDF_GetPageHeightF(page); - let mut widgets = 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 }; - - if bindings.FPDFAnnot_GetSubtype(annot) != FPDF_ANNOT_WIDGET { - continue; - } - - let kind = form_widget_kind(bindings.FPDFAnnot_GetFormFieldType(form, annot)); - let name = read_pdfium_wide_string(|buffer, length| { - bindings.FPDFAnnot_GetFormFieldName(form, annot, buffer, length) - }); - let value = match kind { - PdfFormWidgetKind::Text - | PdfFormWidgetKind::ComboBox - | PdfFormWidgetKind::ListBox => read_pdfium_wide_string(|buffer, length| { - bindings.FPDFAnnot_GetFormFieldValue(form, annot, buffer, length) - }), - _ => String::new(), - }; - let is_button = matches!(kind, PdfFormWidgetKind::Checkbox | PdfFormWidgetKind::Radio); - let checked = is_button && bindings.is_true(bindings.FPDFAnnot_IsChecked(form, annot)); - let index_in_group = if is_button { - form_control_index(bindings, form, annot) - } else { - 0 - }; - let options = match kind { - PdfFormWidgetKind::ComboBox | PdfFormWidgetKind::ListBox => { - read_form_widget_options(bindings, form, annot) - } - _ => Vec::new(), - }; - - 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) - }; - - widgets.push(PdfFormWidget { - name, - kind, - value, - checked, - index_in_group, - options, - x, - y, - width, - height, - }); - } - - Ok(widgets) - } - - /// Applies `edits` to the form widgets of a document and returns the serialized result. - /// - /// Edits are matched to widgets by fully qualified field name and, for check boxes and radio - /// buttons, by the widget's index within its control group. An edit matching no widget is - /// reported as an error instead of being dropped, so a stale caller state can never silently - /// discard a user's change; the returned bytes are a complete rewrite of the document (see - /// [`save_document_to_bytes`]). An empty edit list, and an engine without a native pdfium - /// library, return the input bytes untouched. - pub fn apply_form_edits(&self, bytes: &[u8], edits: &[PdfFormEdit]) -> Result> { - if edits.is_empty() { - return Ok(bytes.to_vec()); - } - - 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 form editing"); - } - // Declared before the form and page guards below, so those are released first: pdfium - // requires the pages to be closed before the document that owns them. - let _document_guard = PdfiumDocumentGuard { bindings, document }; - - let mut form_info = empty_form_fill_info(); - let form = bindings.FPDFDOC_InitFormFillEnvironment(document, &mut form_info); - if form.is_null() { - anyhow::bail!("Pdfium failed to initialize a form fill environment"); - } - // `form_info` must outlive the environment: pdfium keeps the pointer handed to it above. - let _form_guard = PdfiumFormGuard { bindings, form }; - - let mut applied = vec![false; edits.len()]; - - for page_index in 0..bindings.FPDF_GetPageCount(document) { - if applied.iter().all(|applied| *applied) { - break; - } - - let page = bindings.FPDF_LoadPage(document, page_index); - if page.is_null() { - continue; - } - let _page_guard = PdfiumPageGuard { - bindings, - page, - form, - }; - - // Required for the same reason as in `extract_form_widgets_from_bytes`. - bindings.FORM_OnAfterLoadPage(page, form); - - 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 }; - - if bindings.FPDFAnnot_GetSubtype(annot) != FPDF_ANNOT_WIDGET { - continue; - } - - let kind = form_widget_kind(bindings.FPDFAnnot_GetFormFieldType(form, annot)); - let name = read_pdfium_wide_string(|buffer, length| { - bindings.FPDFAnnot_GetFormFieldName(form, annot, buffer, length) - }); - - for (edit_index, edit) in edits.iter().enumerate() { - if applied[edit_index] { - continue; - } - - match edit { - PdfFormEdit::Text { name: field, value } - if kind == PdfFormWidgetKind::Text && field == &name => - { - set_form_widget_value(bindings, annot, value)?; - applied[edit_index] = true; - } - PdfFormEdit::Option { name: field, value } - if matches!( - kind, - PdfFormWidgetKind::ComboBox | PdfFormWidgetKind::ListBox - ) && field == &name => - { - set_form_widget_value(bindings, annot, value)?; - applied[edit_index] = true; - } - PdfFormEdit::Toggle { - name: field, - index_in_group, - checked, - } if matches!( - kind, - PdfFormWidgetKind::Checkbox | PdfFormWidgetKind::Radio - ) && field == &name - && form_control_index(bindings, form, annot) == *index_in_group => - { - if !set_button_checked(bindings, form, annot, *checked) { - anyhow::bail!( - "Pdfium did not apply the checked state of form widget {field:?} \ - at index {index_in_group}" - ); - } - if *checked { - uncheck_other_button_widgets( - bindings, - form, - page, - field, - *index_in_group, - ); - } - applied[edit_index] = true; - } - _ => {} - } - } - } - } - - if let Some((edit, _)) = edits.iter().zip(&applied).find(|(_, applied)| !**applied) { - anyhow::bail!("No form widget matched form edit {edit:?}"); - } - - save_document_to_bytes(bindings, document) - } -} - -// --------------------------------------------------------------------------- -// Markup annotations (highlights and ink) -// --------------------------------------------------------------------------- -// -// As with the form layer above, pdfium-render's high-level API cannot create annotations, so the -// two creators below go through the same FPDF C API, the same RAII guards and the same save -// helper. They write only the entries the specification defines for these subtypes - `/Rect`, -// `/QuadPoints`, `/InkList`, `/C` and the border width - and deliberately author no appearance -// stream: pdfium builds one on demand from exactly those entries when it draws the annotation -// (`CPDF_Annot::GenerateAPIfNeeded` calls `CPDF_GenerateAP::GenerateHighlightAP` or -// `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)] -pub struct PdfAnnotationColor { - pub red: u8, - pub green: u8, - 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; - -/// Opacity handed to `FPDFAnnot_SetColor`, which writes it into `/CA` as `A / 255`. The colors a -/// viewer picks for markup are fully opaque. -const ANNOT_COLOR_OPACITY: c_uint = 255; - -/// Converts one viewer-normalized rectangle into pdfium page-space coordinates. -/// -/// Normalized rectangles are fractions of the page's unrotated width and height measured from the -/// top-left corner, the convention [`normalize_coordinates`] produces for [`PdfCharBox`], -/// [`PdfLinkAnnotation`] and [`PdfFormWidget`]; this is that conversion inverted, flipping `y` -/// back into pdfium's bottom-left origin. The edges are ordered so that `left <= right` and -/// `bottom <= top`, the order both `/Rect` and the quadpoints built from it have to be in. -fn fs_rect_from_normalized( - (x, y, width, height): (f32, f32, f32, f32), - page_width: f32, - page_height: f32, -) -> FS_RECTF { - let left = x * page_width; - let right = (x + width) * page_width; - let top = page_height - y * page_height; - let bottom = page_height - (y + height) * page_height; - - FS_RECTF { - left: left.min(right), - top: top.max(bottom), - right: left.max(right), - bottom: top.min(bottom), - } -} - -/// Converts one viewer-normalized point into pdfium page-space coordinates, using the same -/// convention as [`fs_rect_from_normalized`]. -fn fs_point_from_normalized((x, y): (f32, f32), page_width: f32, page_height: f32) -> FS_POINTF { - FS_POINTF { - x: x * page_width, - y: page_height - y * page_height, - } -} - -/// Returns the smallest rectangle containing both inputs. -fn union_rect(a: FS_RECTF, b: FS_RECTF) -> FS_RECTF { - FS_RECTF { - left: a.left.min(b.left), - top: a.top.max(b.top), - right: a.right.max(b.right), - bottom: a.bottom.min(b.bottom), - } -} - -/// Reads the unrotated page dimensions pdfium reports, rejecting a page without usable -/// dimensions so that the normalized-to-point conversions above can never produce garbage. -fn positive_page_dimensions( - bindings: &dyn PdfiumLibraryBindings, - page: FPDF_PAGE, - page_index: usize, -) -> Result<(f32, f32)> { - let width = bindings.FPDF_GetPageWidthF(page); - let height = bindings.FPDF_GetPageHeightF(page); - if width <= 0.0 || height <= 0.0 { - anyhow::bail!("Page {page_index} reports no usable dimensions ({width} x {height})"); - } - Ok((width, height)) -} - -/// Writes an annotation's `/C` entry and its opacity through `FPDFAnnot_SetColor`. -/// -/// The call only fails for a null annotation, an out-of-range color component, or an annotation -/// that already carries an appearance stream; none of those can happen for the annotations built -/// here, and failing loudly keeps a silently colorless annotation from reaching the document. -fn set_annotation_color( - bindings: &dyn PdfiumLibraryBindings, - annot: FPDF_ANNOTATION, - color: PdfAnnotationColor, -) -> Result<()> { - let applied = bindings.FPDFAnnot_SetColor( - annot, - FPDFANNOT_COLORTYPE_COLOR, - color.red as c_uint, - color.green as c_uint, - color.blue as c_uint, - ANNOT_COLOR_OPACITY, - ); - if !bindings.is_true(applied) { - anyhow::bail!("Pdfium failed to set an 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. - /// - /// `rects` are `(x, y, width, height)` rectangles in the same normalized page coordinates as - /// [`PdfCharBox`], [`PdfLinkAnnotation`] and [`PdfFormWidget`]: fractions of the page's - /// unrotated width and height measured from the top-left corner. Each rectangle becomes one - /// set of quadpoints, and the annotation's `/Rect` is the bounding box of all of them - - /// the box pdfium's generated appearance carries as its `BBox` and scales onto `/Rect`, so the - /// two have to agree for the highlight to land where it was drawn. The returned bytes are a - /// complete rewrite of the document (see [`save_document_to_bytes`]). An empty `rects`, and an - /// engine without a native pdfium library, return the input bytes untouched. - pub fn add_highlight_annotation( - &self, - bytes: &[u8], - page_index: usize, - rects: &[(f32, f32, f32, f32)], - color: PdfAnnotationColor, - ) -> Result> { - if rects.is_empty() { - return Ok(bytes.to_vec()); - } - - let guard = self.inner.lock(); - let Some(pdfium) = guard.0.as_ref() else { - anyhow::bail!("PDF highlight editing requires the native Pdfium engine"); - }; - 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 editing"); - } - // 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 editing"); - } - // No form fill environment is involved: a markup annotation is written straight into the - // page's annotation array and owns no widget object. - let _page_guard = PdfiumPageGuard { - bindings, - page, - form: std::ptr::null_mut(), - }; - - let (page_width, page_height) = positive_page_dimensions(bindings, page, page_index)?; - - let annot = bindings.FPDFPage_CreateAnnot(page, FPDF_ANNOT_HIGHLIGHT); - if annot.is_null() { - anyhow::bail!("Pdfium failed to create a highlight annotation"); - } - // Held for its `Drop`; every path out of this function closes the annotation. - let _annot_guard = PdfiumAnnotGuard { bindings, annot }; - - let mut bounds = fs_rect_from_normalized(rects[0], page_width, page_height); - for rect in rects { - let rect = fs_rect_from_normalized(*rect, page_width, page_height); - bounds = union_rect(bounds, rect); - - // One set of quadpoints per rectangle, in the order the specification defines them: - // upper-left, upper-right, lower-left, lower-right. - let quad_points = FS_QUADPOINTSF { - x1: rect.left, - y1: rect.top, - x2: rect.right, - y2: rect.top, - x3: rect.left, - y3: rect.bottom, - x4: rect.right, - y4: rect.bottom, - }; - // `FPDFAnnot_AppendAttachmentPoints` creates the `/QuadPoints` array on the first call - // and appends a further eight numbers to it on each call after that. - let appended = bindings.FPDFAnnot_AppendAttachmentPoints(annot, &quad_points); - if !bindings.is_true(appended) { - anyhow::bail!("Pdfium failed to add the quadpoints of a highlight annotation"); - } - } - - let rect_set = bindings.FPDFAnnot_SetRect(annot, &bounds); - if !bindings.is_true(rect_set) { - anyhow::bail!("Pdfium failed to set the rectangle of a highlight annotation"); - } - set_annotation_color(bindings, annot, color)?; - - save_document_to_bytes(bindings, document) - } - - /// Adds an ink (freehand) annotation made of `strokes` to the zero-indexed page `page_index`, - /// and returns the serialized document. - /// - /// Each entry of `strokes` is one path of points in the same normalized page coordinates as - /// [`PdfCharBox`], [`PdfLinkAnnotation`] and [`PdfFormWidget`], and becomes one `/InkList` - /// stroke. `line_width` is the stroke width in page points; the annotation's `/Rect` is the - /// bounding box of every point of every stroke. Paths without points carry no geometry and are - /// dropped, so a `strokes` that contributes nothing returns the input bytes untouched, as does - /// an engine without a native pdfium library. The returned bytes are a complete rewrite of the - /// document (see [`save_document_to_bytes`]). - pub fn add_ink_annotation( - &self, - bytes: &[u8], - page_index: usize, - strokes: &[Vec<(f32, f32)>], - color: PdfAnnotationColor, - line_width: f32, - ) -> Result> { - let paths: Vec<&Vec<(f32, f32)>> = strokes.iter().filter(|path| !path.is_empty()).collect(); - if paths.is_empty() { - return Ok(bytes.to_vec()); - } - // pdfium's ink appearance generator bails out on a stroke width of zero or less - // (`CPDF_GenerateAP::GenerateInkAP` returns false when `GetBorderWidth()` is not positive) - // and the annotation would then never render, so a width that cannot draw is an error - // rather than a document change with no visible effect. - if !line_width.is_finite() || line_width <= 0.0 { - anyhow::bail!("Ink annotation line width must be positive, got {line_width}"); - } - - let guard = self.inner.lock(); - let Some(pdfium) = guard.0.as_ref() else { - anyhow::bail!("PDF ink annotation editing requires the native Pdfium engine"); - }; - 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 editing"); - } - // 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 editing"); - } - // No form fill environment is involved: a markup annotation is written straight into the - // page's annotation array and owns no widget object. - let _page_guard = PdfiumPageGuard { - bindings, - page, - form: std::ptr::null_mut(), - }; - - let (page_width, page_height) = positive_page_dimensions(bindings, page, page_index)?; - - let converted: Vec> = paths - .iter() - .map(|path| { - path.iter() - .map(|point| fs_point_from_normalized(*point, page_width, page_height)) - .collect() - }) - .collect(); - - // The annotation's rectangle is the bounding box of every point of every stroke, in the - // page space the strokes themselves are stored in. - let bounds = converted - .iter() - .flatten() - .map(|point| FS_RECTF { - left: point.x, - top: point.y, - right: point.x, - bottom: point.y, - }) - .reduce(union_rect) - .context("Ink annotation carries no points to bound")?; - - let annot = bindings.FPDFPage_CreateAnnot(page, FPDF_ANNOT_INK); - if annot.is_null() { - anyhow::bail!("Pdfium failed to create an ink annotation"); - } - // Held for its `Drop`; every path out of this function closes the annotation. - let _annot_guard = PdfiumAnnotGuard { bindings, annot }; - - for points in &converted { - // The stroke is stored in `/InkList` verbatim: pdfium's own generator draws those - // numbers in page space, the same space `/Rect` is expressed in. - let added = bindings.FPDFAnnot_AddInkStroke(annot, points.as_ptr(), points.len()); - if added < 0 { - anyhow::bail!("Pdfium failed to add a stroke to an ink annotation"); - } - } - - let rect_set = bindings.FPDFAnnot_SetRect(annot, &bounds); - if !bindings.is_true(rect_set) { - anyhow::bail!("Pdfium failed to set the rectangle of an ink annotation"); - } - set_annotation_color(bindings, annot, color)?; - - // `FPDFAnnot_SetBorder` writes the stroke width pdfium's ink appearance generator reads. - // It stores the legacy `/Border` array `[horizontal_radius vertical_radius border_width]` - // and removes any appearance stream, which is exactly the entry `GetBorderWidth()` falls - // back to once `/BS` is absent. - let border_set = bindings.FPDFAnnot_SetBorder(annot, 0.0, 0.0, line_width); - if !bindings.is_true(border_set) { - anyhow::bail!("Pdfium failed to set the border of an ink annotation"); - } - - save_document_to_bytes(bindings, document) - } -} - -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 { - anyhow::bail!("PDF annotation deletion requires the native Pdfium engine"); - }; - 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::*; @@ -2641,47 +1345,6 @@ mod tests { assert_eq!(page.height_pt, 792.0); } - #[test] - fn test_annotation_edits_fail_without_native_library() { - let engine = PdfiumEngine { - inner: Arc::new(Mutex::new(SerializedPdfium(None))), - }; - let dummy_pdf = b"%PDF-1.7\nSample"; - let color = PdfAnnotationColor { - red: 255, - green: 0, - blue: 0, - }; - - // The engine must not silently no-op: edits without a native engine return - // errors so callers never commit unchanged bytes as if a change had landed. - let ink = engine.add_ink_annotation( - dummy_pdf, - 0, - &[vec![(10.0, 10.0), (20.0, 20.0)]], - color, - 2.0, - ); - assert!(ink.is_err(), "ink editing must fail without native pdfium"); - - let highlight = engine.add_highlight_annotation( - dummy_pdf, - 0, - &[(10.0, 10.0, 20.0, 20.0)], - color, - ); - assert!( - highlight.is_err(), - "highlight editing must fail without native pdfium" - ); - - let deletion = engine.delete_annotation(dummy_pdf, 0, 0); - assert!( - deletion.is_err(), - "annotation deletion must fail without native pdfium" - ); - } - #[test] fn test_extract_outline_without_native_library() { let engine = PdfiumEngine {