image_viewer: Fill PDF forms

- Form widgets appear as overlays over the rasterized field appearances:
  text fields take an inline editor (Enter commits, Escape leaves),
  checkboxes and radios toggle on click, and dropdowns open their options.
- Committed edits rewrite the document once and swap renders over to the
  rewritten bytes, so filled values are visible immediately and travel
  into the saved copy.
- The engine layer drives pdfium's form API through the public bindings
  trait, with RAII guards for the document, form environment, page, and
  annotation handles, and it reports unmatched field names instead of
  dropping edits silently.
This commit is contained in:
yenru0 committed 2026-09-24 04:37:23 +09:00
1 parent 46d628e9c7
commit 0fd48144f9
4 files changed
+1137 -24

No files matched your search

+375 -9
View File
@@ -171,6 +171,14 @@ struct NormRect {
height: f32,
}
/// Inline editor for the text form field currently being filled in.
struct FormTextEdit {
editor: Entity<Editor>,
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<Editor>,
@@ -222,6 +230,13 @@ pub struct ImageView {
page_rotation: u16,
pdf_outline: Option<Vec<pdf_viewer::PdfOutlineItem>>,
pdf_metadata: Option<pdf_viewer::PdfMetadata>,
/// 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<Arc<Vec<u8>>>,
/// Form widgets per page, filled on demand for overlays and hit testing.
pdf_form_widgets: RefCell<HashMap<usize, Arc<Vec<pdf_viewer::PdfFormWidget>>>>,
form_text_edit: Option<FormTextEdit>,
form_option_menu: Option<(Point<Pixels>, pdf_viewer::PdfFormWidget)>,
thumbnail_scroll_handle: gpui::ScrollHandle,
pdf_thumbnail_cache: PageLruCache<CachedPageImage>,
pdf_page_cache: PageLruCache<CachedPageImage>,
@@ -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<Self>,
) {
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<Arc<Vec<u8>>> {
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<Vec<pdf_viewer::PdfFormWidget>> {
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<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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>,
) {
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<Self>,
) {
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<Self>) {
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<Self>) {
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>,
) {
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<Self>) {
@@ -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<Self>) {
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,
+2 -2
View File
@@ -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};
+756 -12
View File
@@ -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<String>,
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<u8>,
}
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::<FPDF_FILEWRITE>()
}
}
/// 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::<PdfiumSaveTarget>();
if size == 0 {
return 1;
}
if data.is_null() {
return 0;
}
let block = std::slice::from_raw_parts(data.cast::<u8>(), 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<extern "C" fn>` 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::<u16>()) 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<String> {
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<Vec<u8>> {
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<Vec<PdfFormWidget>> {
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<Vec<u8>> {
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::*;
+4 -1
View File
@@ -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.