use std::collections::HashSet;
use crate::diagnostics::{LocationHint, ParsePhase, Severity, WarningSink};
use crate::metadata::pdf_string_to_rust_pub;
use crate::objects::{PdfDict, PdfObj};
use crate::resolver::Resolver;
const MAX_FIELD_DEPTH: u32 = 32;
const MAX_FORM_FIELDS: usize = 100_000;
#[derive(Debug, Clone, Default)]
pub struct FormCatalog {
pub fields: Vec<FormField>,
pub need_appearances: bool,
pub sig_flags: SigFlags,
pub calculation_order: Vec<String>,
pub default_appearance: Option<String>,
pub quadding: u8,
pub has_xfa: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SigFlags {
pub signatures_exist: bool,
pub append_only: bool,
}
impl SigFlags {
fn from_bits(bits: i64) -> Self {
Self {
signatures_exist: bits & 0x01 != 0,
append_only: bits & 0x02 != 0,
}
}
}
#[derive(Debug, Clone)]
pub struct FormField {
pub name: String,
pub partial_name: String,
pub alternate_name: Option<String>,
pub mapping_name: Option<String>,
pub flags: FieldFlags,
pub kind: FieldKind,
pub value: FieldValue,
pub default_value: FieldValue,
pub widget_obj_nums: Vec<u32>,
pub children: Vec<FormField>,
pub has_additional_actions: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FieldFlags {
pub read_only: bool,
pub required: bool,
pub no_export: bool,
}
impl FieldFlags {
fn from_bits(bits: i64) -> Self {
Self {
read_only: bits & 0x0000_0001 != 0,
required: bits & 0x0000_0002 != 0,
no_export: bits & 0x0000_0004 != 0,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum FieldKind {
Button(ButtonField),
Text(TextField),
Choice(ChoiceField),
Signature(SignatureField),
Container,
Other { ft: String },
}
#[derive(Debug, Clone, Default)]
pub struct ButtonField {
pub button_type: ButtonType,
pub options: Vec<String>,
pub no_toggle_to_off: bool,
pub is_radio: bool,
pub is_pushbutton: bool,
pub radios_in_unison: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum ButtonType {
#[default]
Checkbox,
Radio,
Pushbutton,
}
#[derive(Debug, Clone, Default)]
pub struct TextField {
pub max_length: Option<u32>,
pub multiline: bool,
pub password: bool,
pub file_select: bool,
pub do_not_spell_check: bool,
pub do_not_scroll: bool,
pub comb: bool,
pub rich_text: bool,
pub default_appearance: Option<String>,
pub quadding: Option<u8>,
pub rich_value: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ChoiceField {
pub options: Vec<ChoiceOption>,
pub top_index: u32,
pub selected_indices: Vec<u32>,
pub combo: bool,
pub edit: bool,
pub sort: bool,
pub multi_select: bool,
pub do_not_spell_check: bool,
pub commit_on_sel_change: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChoiceOption {
pub export: String,
pub display: String,
}
#[derive(Debug, Clone, Default)]
pub struct SignatureField {
pub has_lock: bool,
pub has_seed_value: bool,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub enum FieldValue {
#[default]
None,
Text(String),
Name(String),
Array(Vec<String>),
Bool(bool),
Integer(i64),
}
impl FieldValue {
fn from_pdf_object(obj: &PdfObj) -> FieldValue {
match obj {
PdfObj::Null => FieldValue::None,
PdfObj::Bool(b) => FieldValue::Bool(*b),
PdfObj::Int(n) => FieldValue::Integer(*n),
PdfObj::Real(r) => FieldValue::Integer(*r as i64),
PdfObj::Name(n) => FieldValue::Name(String::from_utf8_lossy(n).into_owned()),
PdfObj::Str(s) => FieldValue::Text(crate::metadata::decode_pdf_text_string_pub(s)),
PdfObj::Array(arr) => {
let strings: Vec<String> = arr
.iter()
.filter_map(|o| match o {
PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
PdfObj::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
_ => None,
})
.collect();
if strings.is_empty() {
FieldValue::None
} else {
FieldValue::Array(strings)
}
}
_ => FieldValue::None,
}
}
}
pub fn parse_acroform(resolver: &Resolver, sink: &WarningSink) -> Option<FormCatalog> {
let catalog = catalog_dict(resolver)?;
let acroform_obj = catalog.get(b"AcroForm")?;
let acroform = resolver.deref(acroform_obj).ok()?;
let dict = acroform.as_dict()?;
let mut form = FormCatalog {
need_appearances: dict
.get(b"NeedAppearances")
.and_then(as_bool)
.unwrap_or(false),
sig_flags: SigFlags::from_bits(dict.get_int(b"SigFlags").unwrap_or(0)),
default_appearance: dict.get(b"DA").and_then(pdf_string_to_rust_pub),
quadding: dict.get_int(b"Q").unwrap_or(0).clamp(0, 2) as u8,
has_xfa: dict.get(b"XFA").is_some(),
..Default::default()
};
if let Some(co_arr) = dict.get_array(b"CO") {
form.calculation_order = co_arr
.iter()
.filter_map(|o| match o {
PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
_ => None,
})
.collect();
}
if let Some(fields_arr) = dict.get_array(b"Fields") {
let mut visited = HashSet::new();
let mut total_fields = 0usize;
let mut roots = Vec::with_capacity(fields_arr.len());
for field_obj in fields_arr {
if let Some(field) = walk_field(
resolver,
field_obj,
"",
&FieldDefaults::from_form(&form),
&mut visited,
&mut total_fields,
0,
sink,
) {
roots.push(field);
}
}
form.fields = roots;
}
Some(form)
}
#[derive(Clone, Default)]
struct FieldDefaults {
da: Option<String>,
q: u8,
}
impl FieldDefaults {
fn from_form(form: &FormCatalog) -> Self {
Self {
da: form.default_appearance.clone(),
q: form.quadding,
}
}
fn merge(&self, dict: &PdfDict) -> Self {
Self {
da: dict
.get(b"DA")
.and_then(pdf_string_to_rust_pub)
.or_else(|| self.da.clone()),
q: dict
.get_int(b"Q")
.map(|n| n.clamp(0, 2) as u8)
.unwrap_or(self.q),
}
}
}
#[allow(clippy::too_many_arguments)] fn walk_field(
resolver: &Resolver,
field_obj: &PdfObj,
parent_qualified_name: &str,
parent_defaults: &FieldDefaults,
visited: &mut HashSet<u32>,
total_fields: &mut usize,
depth: u32,
sink: &WarningSink,
) -> Option<FormField> {
if depth >= MAX_FIELD_DEPTH {
sink.record(
ParsePhase::Form,
Some(LocationHint::FieldName(parent_qualified_name.to_string())),
Severity::Error,
format!(
"form-field depth limit {MAX_FIELD_DEPTH} reached; \
deeper sub-fields dropped"
),
);
return None;
}
if *total_fields >= MAX_FORM_FIELDS {
sink.record(
ParsePhase::Form,
None,
Severity::Error,
format!(
"form-field count limit {MAX_FORM_FIELDS} reached; \
remaining fields dropped"
),
);
return None;
}
let obj_num = field_obj.as_ref().map(|(n, _)| n);
if let Some(n) = obj_num
&& !visited.insert(n)
{
sink.record(
ParsePhase::Form,
Some(LocationHint::Object {
obj_num: n,
gen_num: 0,
}),
Severity::Warning,
"form-field cycle detected; sub-tree truncated",
);
return None;
}
let resolved = resolver.deref(field_obj).ok()?;
let dict = resolved.as_dict()?;
*total_fields += 1;
let partial_name = dict
.get(b"T")
.and_then(pdf_string_to_rust_pub)
.unwrap_or_default();
let qualified_name = if parent_qualified_name.is_empty() {
partial_name.clone()
} else if partial_name.is_empty() {
parent_qualified_name.to_string()
} else {
format!("{parent_qualified_name}.{partial_name}")
};
let alternate_name = dict.get(b"TU").and_then(pdf_string_to_rust_pub);
let mapping_name = dict.get(b"TM").and_then(pdf_string_to_rust_pub);
let ff_bits = dict.get_int(b"Ff").unwrap_or(0);
let flags = FieldFlags::from_bits(ff_bits);
let has_additional_actions = dict.get(b"AA").is_some();
let value = dict
.get(b"V")
.map(FieldValue::from_pdf_object)
.unwrap_or_default();
let default_value = dict
.get(b"DV")
.map(FieldValue::from_pdf_object)
.unwrap_or_default();
let merged_defaults = parent_defaults.merge(dict);
let mut widget_obj_nums = Vec::new();
let mut children = Vec::new();
let ft = dict.get_name(b"FT").map(|n| n.to_vec());
let kind = match ft.as_deref() {
Some(b"Btn") => FieldKind::Button(parse_button(dict, ff_bits)),
Some(b"Tx") => FieldKind::Text(parse_text(dict, ff_bits, &merged_defaults)),
Some(b"Ch") => FieldKind::Choice(parse_choice(dict, ff_bits)),
Some(b"Sig") => FieldKind::Signature(SignatureField {
has_lock: dict.get(b"Lock").is_some(),
has_seed_value: dict.get(b"SV").is_some(),
}),
Some(other) => FieldKind::Other {
ft: String::from_utf8_lossy(other).into_owned(),
},
None => FieldKind::Container,
};
if dict.get_name(b"Subtype") == Some(b"Widget")
&& let Some(n) = obj_num
{
widget_obj_nums.push(n);
}
if let Some(kids_arr) = dict.get_array(b"Kids") {
for kid_obj in kids_arr {
let Ok(kid_resolved) = resolver.deref(kid_obj) else {
continue;
};
let Some(kid_dict) = kid_resolved.as_dict() else {
continue;
};
let kid_obj_num = kid_obj.as_ref().map(|(n, _)| n);
let kid_is_widget = kid_dict.get_name(b"Subtype") == Some(b"Widget");
let kid_has_field_keys = kid_dict.get(b"T").is_some() || kid_dict.get(b"FT").is_some();
if kid_is_widget && !kid_has_field_keys {
if let Some(n) = kid_obj_num {
widget_obj_nums.push(n);
}
continue;
}
if let Some(child) = walk_field(
resolver,
kid_obj,
&qualified_name,
&merged_defaults,
visited,
total_fields,
depth + 1,
sink,
) {
children.push(child);
}
}
}
Some(FormField {
name: qualified_name,
partial_name,
alternate_name,
mapping_name,
flags,
kind,
value,
default_value,
widget_obj_nums,
children,
has_additional_actions,
})
}
fn parse_button(dict: &PdfDict, ff: i64) -> ButtonField {
let no_toggle_to_off = ff & (1 << 14) != 0;
let is_radio = ff & (1 << 15) != 0;
let is_pushbutton = ff & (1 << 16) != 0;
let radios_in_unison = ff & (1 << 25) != 0;
let button_type = if is_pushbutton {
ButtonType::Pushbutton
} else if is_radio {
ButtonType::Radio
} else {
ButtonType::Checkbox
};
let options = dict
.get_array(b"Opt")
.map(|arr| {
arr.iter()
.filter_map(|o| match o {
PdfObj::Str(s) => Some(crate::metadata::decode_pdf_text_string_pub(s)),
PdfObj::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
_ => None,
})
.collect()
})
.unwrap_or_default();
ButtonField {
button_type,
options,
no_toggle_to_off,
is_radio,
is_pushbutton,
radios_in_unison,
}
}
fn parse_text(dict: &PdfDict, ff: i64, defaults: &FieldDefaults) -> TextField {
TextField {
max_length: dict.get_int(b"MaxLen").and_then(|n| u32::try_from(n).ok()),
multiline: ff & (1 << 12) != 0,
password: ff & (1 << 13) != 0,
file_select: ff & (1 << 20) != 0,
do_not_spell_check: ff & (1 << 22) != 0,
do_not_scroll: ff & (1 << 23) != 0,
comb: ff & (1 << 24) != 0,
rich_text: ff & (1 << 25) != 0,
default_appearance: dict
.get(b"DA")
.and_then(pdf_string_to_rust_pub)
.or_else(|| defaults.da.clone()),
quadding: dict.get_int(b"Q").map(|n| n.clamp(0, 2) as u8),
rich_value: dict.get(b"RV").and_then(pdf_string_to_rust_pub),
}
}
fn parse_choice(dict: &PdfDict, ff: i64) -> ChoiceField {
let options = dict
.get_array(b"Opt")
.map(|arr| {
arr.iter()
.filter_map(|o| match o {
PdfObj::Str(s) => {
let v = crate::metadata::decode_pdf_text_string_pub(s);
Some(ChoiceOption {
export: v.clone(),
display: v,
})
}
PdfObj::Name(n) => {
let v = String::from_utf8_lossy(n).into_owned();
Some(ChoiceOption {
export: v.clone(),
display: v,
})
}
PdfObj::Array(pair) if pair.len() == 2 => {
let export = match &pair[0] {
PdfObj::Str(s) => crate::metadata::decode_pdf_text_string_pub(s),
PdfObj::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
let display = match &pair[1] {
PdfObj::Str(s) => crate::metadata::decode_pdf_text_string_pub(s),
PdfObj::Name(n) => String::from_utf8_lossy(n).into_owned(),
_ => return None,
};
Some(ChoiceOption { export, display })
}
_ => None,
})
.collect()
})
.unwrap_or_default();
let selected_indices = dict
.get_array(b"I")
.map(|arr| {
arr.iter()
.filter_map(|o| o.as_int().and_then(|n| u32::try_from(n).ok()))
.collect()
})
.unwrap_or_default();
ChoiceField {
options,
top_index: dict
.get_int(b"TI")
.and_then(|n| u32::try_from(n).ok())
.unwrap_or(0),
selected_indices,
combo: ff & (1 << 17) != 0,
edit: ff & (1 << 18) != 0,
sort: ff & (1 << 19) != 0,
multi_select: ff & (1 << 21) != 0,
do_not_spell_check: ff & (1 << 22) != 0,
commit_on_sel_change: ff & (1 << 26) != 0,
}
}
fn as_bool(obj: &PdfObj) -> Option<bool> {
match obj {
PdfObj::Bool(b) => Some(*b),
_ => None,
}
}
fn catalog_dict(resolver: &Resolver) -> Option<PdfDict> {
if let Some((num, gen_num)) = resolver.trailer().get_ref(b"Root")
&& let Ok(obj) = resolver.resolve(num, gen_num)
&& let Some(dict) = obj.as_dict()
{
return Some(dict.clone());
}
crate::find_catalog(resolver).and_then(|obj| obj.as_dict().cloned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_flags_decode() {
let f = FieldFlags::from_bits(0x07);
assert!(f.read_only && f.required && f.no_export);
let f = FieldFlags::from_bits(0x02);
assert!(!f.read_only && f.required && !f.no_export);
}
#[test]
fn sig_flags_decode() {
let s = SigFlags::from_bits(0x03);
assert!(s.signatures_exist && s.append_only);
let s = SigFlags::from_bits(0x01);
assert!(s.signatures_exist && !s.append_only);
}
#[test]
fn button_type_classification() {
let b = parse_button(&PdfDict::new(), (1 << 15) | (1 << 16));
assert_eq!(b.button_type, ButtonType::Pushbutton);
let b = parse_button(&PdfDict::new(), 1 << 15);
assert_eq!(b.button_type, ButtonType::Radio);
let b = parse_button(&PdfDict::new(), 0);
assert_eq!(b.button_type, ButtonType::Checkbox);
}
#[test]
fn text_field_flags() {
let t = parse_text(
&PdfDict::new(),
(1 << 12) | (1 << 13),
&FieldDefaults::default(),
);
assert!(t.multiline && t.password);
assert!(!t.comb);
}
#[test]
fn choice_options_pair_form() {
let mut dict = PdfDict::new();
dict.insert(
b"Opt".to_vec(),
PdfObj::Array(vec![
PdfObj::Str(b"R".to_vec()),
PdfObj::Array(vec![
PdfObj::Str(b"G".to_vec()),
PdfObj::Str(b"Green".to_vec()),
]),
PdfObj::Str(b"B".to_vec()),
]),
);
let ch = parse_choice(&dict, 0);
assert_eq!(ch.options.len(), 3);
assert_eq!(ch.options[0].export, "R");
assert_eq!(ch.options[0].display, "R");
assert_eq!(ch.options[1].export, "G");
assert_eq!(ch.options[1].display, "Green");
assert_eq!(ch.options[2].export, "B");
}
#[test]
fn field_value_from_pdf() {
assert_eq!(FieldValue::from_pdf_object(&PdfObj::Null), FieldValue::None);
assert_eq!(
FieldValue::from_pdf_object(&PdfObj::Str(b"Scott".to_vec())),
FieldValue::Text("Scott".to_string())
);
assert_eq!(
FieldValue::from_pdf_object(&PdfObj::Name(b"Yes".to_vec())),
FieldValue::Name("Yes".to_string())
);
assert_eq!(
FieldValue::from_pdf_object(&PdfObj::Array(vec![
PdfObj::Str(b"a".to_vec()),
PdfObj::Str(b"b".to_vec()),
])),
FieldValue::Array(vec!["a".to_string(), "b".to_string()])
);
}
}