diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index a89a8c3c9f..703ffc031c 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -171,6 +171,14 @@ struct NormRect { height: f32, } +/// 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, @@ -222,6 +230,13 @@ 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_form_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)>, thumbnail_scroll_handle: gpui::ScrollHandle, pdf_thumbnail_cache: PageLruCache, pdf_page_cache: PageLruCache, @@ -362,6 +377,10 @@ impl ImageView { page_rotation: 0, pdf_outline: None, pdf_metadata: None, + pdf_form_bytes: None, + pdf_form_widgets: RefCell::new(HashMap::default()), + form_text_edit: None, + form_option_menu: None, thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -389,6 +408,10 @@ impl ImageView { self.pdf_outline = None; self.pdf_metadata = None; self.pdf_char_cache.borrow_mut().clear(); + self.pdf_form_bytes = None; + 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); } @@ -402,6 +425,10 @@ impl ImageView { self.pdf_outline = None; self.pdf_metadata = None; self.pdf_char_cache.borrow_mut().clear(); + self.pdf_form_bytes = None; + 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); } @@ -1446,6 +1473,12 @@ 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); @@ -1832,6 +1865,18 @@ 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. + fn active_pdf_bytes(&self, cx: &App) -> Option>> { + self.pdf_form_bytes.clone().or_else(|| { + 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 /// single page, so the scroll handle's item index no longer maps to a page. fn visible_pdf_page(&self, cx: &App) -> usize { @@ -1960,6 +2005,205 @@ impl ImageView { .into_any_element() } + /// 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_bytes = Some(Arc::new(edited)); + self.pdf_page_cache.clear(); + self.pdf_thumbnail_cache.clear(); + self.pdf_form_widgets.borrow_mut().clear(); + cx.notify(); + } + 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) { @@ -2023,13 +2267,7 @@ impl ImageView { /// browser viewer's download action. The opened file is left untouched. fn save_copy_as(&mut self, _: &SaveCopyAs, _window: &mut Window, cx: &mut Context) { let source_path = self.image_item.read(cx).abs_path(cx); - let Some(bytes) = self - .image_item - .read(cx) - .pdf_info - .as_ref() - .map(|info| info.pdf_bytes.clone()) - else { + let Some(bytes) = self.active_pdf_bytes(cx) else { return; }; @@ -2542,6 +2780,10 @@ impl Item for ImageView { page_rotation: 0, pdf_outline: None, pdf_metadata: None, + pdf_form_bytes: None, + pdf_form_widgets: RefCell::new(HashMap::default()), + form_text_edit: None, + form_option_menu: None, thumbnail_scroll_handle: gpui::ScrollHandle::new(), pdf_thumbnail_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), pdf_page_cache: PageLruCache::new(DEFAULT_MEMORY_BUDGET_BYTES), @@ -2753,7 +2995,9 @@ impl Render for ImageView { let total_pages = pdf_info.total_pages; let presentation_mode = self.presentation_mode; let rotation = self.page_rotation; - let pdf_bytes = pdf_info.pdf_bytes.clone(); + let pdf_bytes = self + .active_pdf_bytes(cx) + .unwrap_or_else(|| pdf_info.pdf_bytes.clone()); let current_page = self.visible_pdf_page(cx); let main_pdf_view = div() @@ -2774,6 +3018,7 @@ 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(); }), ) @@ -2800,6 +3045,21 @@ impl Render for ImageView { let page_h = px(page_h_val); let page_idx = page.page_index; let links = page.links; + 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() @@ -3004,6 +3264,63 @@ impl Render for ImageView { }), ) })) + .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) + .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(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) + })) ) .when(!presentation_mode, |this| { this.child( @@ -3027,6 +3344,54 @@ impl Render for ImageView { window, cx, ) + .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() { @@ -3048,7 +3413,8 @@ impl Render for ImageView { { let sidebar = if self.show_thumbnail_panel { Some(self.render_thumbnail_panel( - pdf_info.pdf_bytes.clone(), + self.active_pdf_bytes(cx) + .unwrap_or_else(|| pdf_info.pdf_bytes.clone()), total_pages, current_page, window, diff --git a/crates/pdf_viewer/src/pdf_viewer.rs b/crates/pdf_viewer/src/pdf_viewer.rs index af9caf419b..4c1b81b5b7 100644 --- a/crates/pdf_viewer/src/pdf_viewer.rs +++ b/crates/pdf_viewer/src/pdf_viewer.rs @@ -22,8 +22,8 @@ pub mod watcher; pub use cache::{CacheKey, CacheablePage, DEFAULT_MEMORY_BUDGET_BYTES, PageLruCache, RenderedPage}; pub use document::{PageDimensions, PdfDocument}; pub use pdfium::{ - PdfCharBox, PdfDocumentDetails, PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, - PdfTextSegment, PdfiumEngine, + PdfCharBox, PdfDocumentDetails, PdfFormEdit, PdfFormWidget, PdfFormWidgetKind, + PdfLinkAnnotation, PdfMetadata, PdfOutlineItem, PdfPageDetails, PdfTextSegment, PdfiumEngine, }; pub use rasterizer::{LuminosityToneMapper, PageRasterizer, RasterizerOptions}; pub use settings::{DefaultZoomPolicy, PageLayoutMode, PdfViewerSettings}; diff --git a/crates/pdf_viewer/src/pdfium.rs b/crates/pdf_viewer/src/pdfium.rs index ccba5c98b5..edc900dce6 100644 --- a/crates/pdf_viewer/src/pdfium.rs +++ b/crates/pdf_viewer/src/pdfium.rs @@ -6,6 +6,7 @@ use anyhow::{Context as _, Result}; use parking_lot::Mutex; use pdfium_render::prelude::*; +use std::os::raw::{c_int, c_ulong, c_void}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -553,19 +554,18 @@ impl PdfiumEngine { for ch in chars.iter() { let character = ch.unicode_char().unwrap_or(UNKNOWN_TEXT_CHAR); - let (x, y, width, height) = - match ch.loose_bounds().or_else(|_| ch.tight_bounds()) { - Ok(bounds) => { - let left = bounds.left().value.min(bounds.right().value); - let right = bounds.left().value.max(bounds.right().value); - let bottom = bounds.bottom().value.min(bounds.top().value); - let top = bounds.bottom().value.max(bounds.top().value); + let (x, y, width, height) = match ch.loose_bounds().or_else(|_| ch.tight_bounds()) { + Ok(bounds) => { + let left = bounds.left().value.min(bounds.right().value); + let right = bounds.left().value.max(bounds.right().value); + let bottom = bounds.bottom().value.min(bounds.top().value); + let top = bounds.bottom().value.max(bounds.top().value); - normalize_coordinates(left, right, bottom, top, page_w, page_h) - } - // Preserve the slot so index alignment survives geometry-less characters. - Err(_) => (0.0, 0.0, 0.0, 0.0), - }; + normalize_coordinates(left, right, bottom, top, page_w, page_h) + } + // Preserve the slot so index alignment survives geometry-less characters. + Err(_) => (0.0, 0.0, 0.0, 0.0), + }; boxes.push(PdfCharBox { character, @@ -1244,6 +1244,750 @@ 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) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pdf_viewer/src/rasterizer.rs b/crates/pdf_viewer/src/rasterizer.rs index b5d3b5810d..0f96d25165 100644 --- a/crates/pdf_viewer/src/rasterizer.rs +++ b/crates/pdf_viewer/src/rasterizer.rs @@ -330,7 +330,10 @@ mod tests { expected, "rotation {rotation_degrees} should swap width and height" ); - assert_eq!(page.rgba_buffer.len(), (expected.0 * expected.1 * 4) as usize); + assert_eq!( + page.rgba_buffer.len(), + (expected.0 * expected.1 * 4) as usize + ); } // Half turns keep the original footprint.