Files
pdfcraft/crates/forms/src/lib.rs
T
aiongg 4362d705e6 refactor(forms): split lib.rs into read, tab_order, values, appearances
New modules: read.rs, tab_order.rs, values.rs, appearances.rs.
2026-10-26 22:18:50 +00:00

294 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! pdfcraft-forms — interactive forms (AcroForm, ISO 32000-2 §12.7), execution plan M6.1–M6.2.
//!
//! - [`fields`]: the field tree flattened to terminal fields, each with its widgets (page,
//! rectangle, on-state), inherited attributes (`/FT`, `/Ff`, `/V`, `/DV`, `/DA`, `/Q`,
//! `/MaxLen`) and choice options.
//! - [`set_value`]: fill a field. Text and choice fields get new appearance streams
//! ([`appearance`]); check boxes and radio buttons switch `/V` and each widget's `/AS` between
//! the states their appearances already define.
//! - [`reset`]: Acrobat's Clear form (back to `/DV`).
//! - [`table`]: a table of text fields, laid out and named before any of it is added.
//!
//! Not yet: JavaScript actions (format, keystroke, validate, calculate — M6.4/M6.5) and rich text
//! values (`/RV`, which is removed when a value is set so it can't contradict `/V`).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString};
mod actions;
pub mod af;
pub mod appearance;
mod author;
mod calc_order;
pub mod detect;
mod scripting;
pub mod table;
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
pub use author::{
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
};
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
pub use scripting::{
FieldChange, FieldEvent, NoScripts, ScriptResult, Scripts, apply_script_changes, document_scripts, document_scripts_named, set_document_script,
set_field_script,
};
mod appearances;
mod read;
mod tab_order;
mod values;
pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached};
pub(crate) use read::page_refs;
pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted};
#[cfg(test)]
use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads};
use read::{script, script_budget};
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order};
#[cfg(test)]
use values::has_password_flag;
use values::write_value;
pub(crate) use values::{ask_viewers_to_redraw, merged_appearance};
pub use values::{
calculations_changed, clear_password_values, has_password_values, password_values, recalculate, recalculate_with, reset, restore_password_values,
set_value, set_value_with, set_values,
};
#[cfg(test)]
mod tests;
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum FormError {
#[error("the document has no form fields")]
NoForm,
#[error("there is no field named {0:?}")]
NoSuchField(String),
#[error("{0:?} is read-only")]
ReadOnly(String),
#[error("{0}")]
Invalid(String),
/// The value doesn't fit the field's format (its keystroke check refused it), which is
/// checked before any of the field's scripts run.
#[error("{0}")]
Format(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FieldKind {
Text,
CheckBox,
Radio,
PushButton,
Combo,
List,
Signature,
}
/// Field flags (§12.7.4, Tables 226, 228, 229, 231).
pub mod flags {
pub const READ_ONLY: u32 = 1;
pub const REQUIRED: u32 = 1 << 1;
pub const MULTILINE: u32 = 1 << 12;
pub const PASSWORD: u32 = 1 << 13;
pub const NO_TOGGLE_TO_OFF: u32 = 1 << 14;
pub const RADIO: u32 = 1 << 15;
pub const PUSH_BUTTON: u32 = 1 << 16;
pub const COMBO: u32 = 1 << 17;
pub const EDIT: u32 = 1 << 18;
pub const SORT: u32 = 1 << 19;
pub const FILE_SELECT: u32 = 1 << 20;
pub const MULTI_SELECT: u32 = 1 << 21;
pub const DO_NOT_SPELL_CHECK: u32 = 1 << 22;
pub const DO_NOT_SCROLL: u32 = 1 << 23;
pub const COMB: u32 = 1 << 24;
/// Buttons: radios in unison. Text fields use the same bit for rich text.
pub const RADIOS_IN_UNISON: u32 = 1 << 25;
pub const RICH_TEXT: u32 = 1 << 25;
pub const COMMIT_ON_SEL_CHANGE: u32 = 1 << 26;
}
/// One widget (the field's appearance on a page).
#[derive(Clone, Debug, PartialEq)]
pub struct Widget {
pub obj: ObjRef,
/// 0-based page index, when the widget is on a page.
pub page: Option<usize>,
/// In user space, normalized.
pub rect: [f64; 4],
/// Check boxes and radio buttons: the name of the "on" appearance state.
pub on_state: Option<String>,
/// The current appearance state (`/AS`).
pub state: Option<String>,
/// Position in the document's tab order (pages in order; within a page by its `/Tabs`:
/// rows, columns, or annotation/structure order). `usize::MAX` when not on a page.
pub tab: usize,
/// The widget's Locked flag (`/F` bit 8): its properties can't be changed.
pub locked: bool,
/// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input.
pub hidden: bool,
/// Read-only: the field is, or the widget's own `/Ff` says so (a radio button of a group can
/// be locked on its own, as an XFA exclusion group's layout writes it).
pub read_only: bool,
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
pub rotation: i64,
/// Push buttons: the normal label (`/MK /CA`), separate from the field's name.
pub caption: Option<String>,
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
pub list: Option<appearance::ListRows>,
/// Drawn round: a radio button, or a check box laid out for a round XFA check button
/// (`/PCXfaRound true`).
pub round: bool,
}
/// A terminal form field.
#[derive(Clone, Debug, PartialEq)]
pub struct Field {
/// Fully qualified name (`parent.child`).
pub name: String,
pub obj: ObjRef,
pub kind: FieldKind,
/// Text: the text. Check box / radio: the selected state (none when off). Choice: the
/// selected export values.
pub value: Vec<String>,
pub default: Vec<String>,
pub flags: u32,
pub max_len: Option<usize>,
/// Choice options: (export value, display text).
pub options: Vec<(String, String)>,
/// Default appearance string (`/DA`, inherited from the form if absent).
pub da: String,
/// Quadding: 0 left, 1 centred, 2 right.
pub quadding: i64,
pub tooltip: Option<String>,
pub widgets: Vec<Widget>,
/// Format, validate and calculate scripts (Acrobat's AF functions), see [`af`].
pub actions: af::Actions,
/// Push buttons: what a click does.
pub button: Option<af::ButtonAction>,
}
impl Field {
pub fn has(&self, flag: u32) -> bool {
self.flags & flag != 0
}
pub fn read_only(&self) -> bool {
self.has(flags::READ_ONLY)
}
/// Locked (Field Properties ▸ General ▸ Locked): its properties can't be changed.
pub fn locked(&self) -> bool {
self.widgets.iter().any(|w| w.locked)
}
/// Check boxes and radio groups with `/Opt` (PDF 1.4): one export value per widget, in widget
/// order. Their on states are then often just positions (`/0`, `/1` …), as pdf-lib writes them.
fn button_exports(&self) -> Option<&[(String, String)]> {
(matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) && !self.options.is_empty() && self.options.len() == self.widgets.len())
.then_some(self.options.as_slice())
}
/// The export value of widget `i` of a check box or radio group: its `/Opt` entry when the
/// field has one per widget, otherwise the widget's on-state name.
pub fn export_of(&self, i: usize) -> Option<&str> {
let on = self.widgets.get(i)?.on_state.as_deref()?;
Some(self.button_exports().and_then(|o| o.get(i)).map_or(on, |(e, _)| e.as_str()))
}
/// The on-state name that selects `choice` in a check box or radio group, where `choice` is an
/// on-state name or an export value from `/Opt`.
pub fn state_for(&self, choice: &str) -> Option<&str> {
self.widgets.iter().filter_map(|w| w.on_state.as_deref()).find(|s| *s == choice).or_else(|| {
let i = (0..self.widgets.len()).find(|&i| self.export_of(i) == Some(choice))?;
self.widgets.get(i)?.on_state.as_deref()
})
}
/// The export value for an on-state name such as the field's value; the name itself when the
/// field has no `/Opt`.
pub fn export_for_state<'a>(&'a self, state: &'a str) -> &'a str {
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
}
/// The value as one string: text, the state name, or the selected display texts.
pub fn display_value(&self) -> String {
match self.kind {
FieldKind::Combo | FieldKind::List => self
.value
.iter()
.map(|v| self.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone()))
.collect::<Vec<_>>()
.join(", "),
_ => self.value.join(", "),
}
}
}
/// A value to put in a field.
#[derive(Clone, Debug, PartialEq)]
pub enum FieldValue {
Text(String),
/// Check box: checked or not.
Check(bool),
/// Radio group: the on-state to select (`None`: none selected).
Radio(Option<String>),
/// Combo box or list box: export values (several only for multi-select lists).
Choice(Vec<String>),
}
// ── reading ─────────────────────────────────────────────────────────────────────────────────
fn text_of(o: &Object) -> Option<String> {
match o {
Object::String(s) => Some(s.to_text()),
Object::Name(n) => Some(name_text(n)),
_ => None,
}
}
/// A name object's bytes as text that [`name_bytes`] turns back into the same bytes. UTF-8 names
/// read as themselves; other bytes (Shift-JIS check box states such as 「はい」 in Japanese
/// forms) and `#` are written `#XX`, as in PDF name syntax.
pub fn name_text(bytes: &[u8]) -> String {
let escape = |b: u8| format!("#{b:02X}");
match std::str::from_utf8(bytes) {
Ok(s) => s.replace('#', "#23"),
Err(_) => bytes.iter().map(|&b| if (0x21..=0x7e).contains(&b) && b != b'#' { char::from(b).to_string() } else { escape(b) }).collect(),
}
}
/// The bytes of the name written as `text` by [`name_text`] (`#XX` is the byte XX).
pub fn name_bytes(text: &str) -> Vec<u8> {
let b = text.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while let Some(&c) = b.get(i) {
let hex = |j: usize| b.get(j).and_then(|h| char::from(*h).to_digit(16));
match (c, hex(i + 1), hex(i + 2)) {
// Both digits are below 16, so the byte fits.
(b'#', Some(hi), Some(lo)) => {
out.push(u8::try_from(hi * 16 + lo).unwrap_or(b'#'));
i += 3;
}
_ => {
out.push(c);
i += 1;
}
}
}
out
}
/// A name object from text written by [`name_text`].
fn name_obj(text: &str) -> Object {
Object::Name(name_bytes(text))
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
Err(FormError::Invalid(m.into()))
}