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 {