use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Dict, Name, ObjRef, Object, Resolve};
use crate::ap::{self, GeneratedAp};
use crate::form::attr::{field_attr, full_name};
use crate::names;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FieldKind {
Text,
Check,
Radio,
Button,
Combo,
List,
Signature,
}
impl FieldKind {
#[must_use]
pub fn classify(field_type: &[u8], flags: FieldFlags) -> Option<FieldKind> {
match field_type {
b"Tx" => Some(FieldKind::Text),
b"Sig" => Some(FieldKind::Signature),
b"Btn" => Some(if flags.is_push_button() {
FieldKind::Button
} else if flags.is_radio() {
FieldKind::Radio
} else {
FieldKind::Check
}),
b"Ch" => Some(if flags.is_combo() {
FieldKind::Combo
} else {
FieldKind::List
}),
_ => None,
}
}
#[must_use]
pub fn is_writable(self) -> bool {
!matches!(self, FieldKind::Button | FieldKind::Signature)
}
#[must_use]
pub fn is_toggle(self) -> bool {
matches!(self, FieldKind::Check | FieldKind::Radio)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct FieldFlags(i64);
impl FieldFlags {
#[must_use]
pub const fn bits(self) -> i64 {
self.0
}
#[must_use]
pub const fn from_bits(bits: i64) -> Self {
Self(bits)
}
#[must_use]
pub const fn is_read_only(self) -> bool {
self.0 & (1 << 0) != 0
}
#[must_use]
pub const fn is_required(self) -> bool {
self.0 & (1 << 1) != 0
}
#[must_use]
pub const fn is_radio(self) -> bool {
self.0 & (1 << 15) != 0
}
#[must_use]
pub const fn is_push_button(self) -> bool {
self.0 & (1 << 16) != 0
}
#[must_use]
pub const fn is_combo(self) -> bool {
self.0 & (1 << 17) != 0
}
#[must_use]
pub const fn is_editable_combo(self) -> bool {
self.0 & (1 << 18) != 0
}
#[must_use]
pub const fn is_multi_select(self) -> bool {
self.0 & (1 << 21) != 0
}
#[must_use]
pub const fn is_multiline(self) -> bool {
self.0 & (1 << 12) != 0
}
#[must_use]
pub const fn is_password(self) -> bool {
self.0 & (1 << 13) != 0
}
#[must_use]
#[doc(alias = "Comb")]
pub const fn is_comb(self) -> bool {
self.0 & (1 << 24) != 0
}
#[must_use]
#[doc(alias = "DoNotScroll")]
pub const fn scrolls(self) -> bool {
self.0 & (1 << 23) == 0
}
#[must_use]
#[doc(alias = "DoNotSpellCheck")]
pub const fn spell_checks(self) -> bool {
self.0 & (1 << 22) == 0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Field {
pub dict: Dict,
pub reference: Option<ObjRef>,
pub name: String,
pub kind: FieldKind,
pub flags: FieldFlags,
pub widgets: Vec<Widget>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Widget {
pub dict: Dict,
pub reference: Option<ObjRef>,
}
impl Field {
#[must_use]
pub fn value<R: Resolve>(&self, values: Option<&FieldValues>, r: &R) -> String {
if let Some(edited) = values.and_then(|values| values.get(&self.name)) {
return edited.to_owned();
}
self.stored_value(r)
}
#[must_use]
pub fn stored_value<R: Resolve>(&self, r: &R) -> String {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
field_attr(&self.dict, names::V, r, &limits, &mut diags)
.map(|value| value.to_text())
.unwrap_or_default()
}
#[must_use]
pub fn default_value<R: Resolve>(&self, r: &R) -> String {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
field_attr(&self.dict, names::DV, r, &limits, &mut diags)
.map(|value| value.to_text())
.unwrap_or_default()
}
#[must_use]
pub fn is_checked<R: Resolve>(&self, values: Option<&FieldValues>, r: &R) -> bool {
if !self.kind.is_toggle() {
return false;
}
let value = self.value(values, r);
!value.is_empty() && value != "Off"
}
#[must_use]
pub fn states<R: Resolve>(&self, r: &R) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for widget in &self.widgets {
let Some(ap) = widget.dict.dict(names::AP, r) else {
continue;
};
let Some(normal) = ap.dict(names::N, r) else {
continue;
};
for key in normal.keys() {
let state = String::from_utf8_lossy(key.as_bytes()).into_owned();
if !out.contains(&state) {
out.push(state);
}
}
}
out
}
#[must_use]
pub fn options<R: Resolve>(&self, r: &R) -> Vec<String> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let Some(opt) = field_attr(&self.dict, names::OPT, r, &limits, &mut diags) else {
return Vec::new();
};
let Some(array) = opt.as_array() else {
return Vec::new();
};
(0..array.len())
.map(|index| {
let Some(entry) = array.get(index, r) else {
return String::new();
};
match entry.get() {
Object::Array(pair) => pair
.raw_at(1)
.or_else(|| pair.raw_at(0))
.map(Object::to_text)
.unwrap_or_default(),
object => object.to_text(),
}
})
.collect()
}
#[must_use]
pub fn tooltip<R: Resolve>(&self, r: &R) -> Option<String> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
field_attr(&self.dict, names::TU, r, &limits, &mut diags).map(|value| value.to_text())
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Form {
pub fields: Vec<Field>,
pub need_appearances: bool,
}
const MAX_FIELD_DEPTH: u32 = 32;
impl Form {
#[must_use]
pub fn load<R: Resolve>(
catalog: &Dict,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) -> Option<Form> {
let acro = catalog.dict(names::ACRO_FORM, r)?;
let need_appearances = acro
.get(names::NEED_APPEARANCES, r)
.and_then(|value| value.as_direct().and_then(Object::as_bool))
.unwrap_or(false);
let mut form = Form {
fields: Vec::new(),
need_appearances,
};
let Some(fields) = acro.array(names::FIELDS, r) else {
return Some(form);
};
let mut seen: Vec<ObjRef> = Vec::new();
for index in 0..fields.len() {
let reference = fields.reference_at(index);
let Some(dict) = fields.dict_at(index, r) else {
continue;
};
visit(
&dict,
reference,
0,
&mut seen,
&mut form.fields,
r,
limits,
diags,
);
}
Some(form)
}
#[must_use]
pub fn len(&self) -> usize {
self.fields.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
#[must_use]
pub fn field(&self, name: &str) -> Option<&Field> {
self.fields.iter().find(|field| field.name == name)
}
#[must_use]
pub fn calculation_order<R: Resolve>(&self, catalog: &Dict, r: &R) -> Vec<usize> {
let Some(acro) = catalog.dict(names::ACRO_FORM, r) else {
return Vec::new();
};
let Some(order) = acro.array(names::CALCULATION_ORDER, r) else {
return Vec::new();
};
let mut out = Vec::new();
for index in 0..order.len() {
let reference = order.reference_at(index);
let dict = order.dict_at(index, r);
let found = self
.fields
.iter()
.position(|field| match (reference, &dict) {
(Some(reference), _) if field.reference == Some(reference) => true,
(_, Some(dict)) => field.reference.is_none() && &field.dict == dict,
_ => false,
});
if let Some(found) = found {
out.push(found);
}
}
out
}
}
#[allow(clippy::too_many_arguments)]
fn visit<R: Resolve>(
dict: &Dict,
reference: Option<ObjRef>,
depth: u32,
seen: &mut Vec<ObjRef>,
out: &mut Vec<Field>,
r: &R,
limits: &Limits,
diags: &mut Diagnostics,
) {
if depth > MAX_FIELD_DEPTH {
diags.record(
pdfrum_common::Severity::Suspicious,
pdfrum_common::DiagKind::TreeDepthExceeded,
None,
);
return;
}
if let Some(reference) = reference {
if seen.contains(&reference) {
diags.record(
pdfrum_common::Severity::Recovered,
pdfrum_common::DiagKind::NavigationCycle,
None,
);
return;
}
seen.push(reference);
}
let kids = dict.array(names::KIDS, r);
let field_type = field_attr(dict, names::FT, r, limits, diags)
.map(|value| value.to_byte_string())
.unwrap_or_default();
let flags = FieldFlags::from_bits(
field_attr(dict, names::FF, r, limits, diags)
.and_then(|value| value.as_int())
.unwrap_or(0),
);
let kids_are_fields = kids.as_ref().is_some_and(|kids| {
(0..kids.len()).any(|index| {
kids.dict_at(index, r)
.is_some_and(|kid| kid.contains_key(names::T))
})
});
if let Some(kind) = FieldKind::classify(&field_type, flags)
&& !kids_are_fields
{
let name = full_name(dict, r);
if name.is_empty() {
diags.record(
pdfrum_common::Severity::Suspicious,
pdfrum_common::DiagKind::FieldSkippedNoName,
None,
);
return;
}
let widgets = widgets_of(dict, reference, kids.as_ref(), r);
if let Some(existing) = out.iter_mut().find(|field| field.name == name) {
existing.widgets.extend(widgets);
return;
}
out.push(Field {
name,
kind,
flags,
widgets,
dict: dict.clone(),
reference,
});
return;
}
let Some(kids) = kids else {
if field_type.is_empty() {
diags.record(
pdfrum_common::Severity::Suspicious,
pdfrum_common::DiagKind::FieldSkippedNoType,
None,
);
}
return;
};
for index in 0..kids.len() {
let kid_ref = kids.reference_at(index);
let Some(kid) = kids.dict_at(index, r) else {
continue;
};
visit(&kid, kid_ref, depth + 1, seen, out, r, limits, diags);
}
}
fn widgets_of<R: Resolve>(
dict: &Dict,
reference: Option<ObjRef>,
kids: Option<&pdfrum_object::Array>,
r: &R,
) -> Vec<Widget> {
if let Some(kids) = kids
&& !kids.is_empty()
{
let found: Vec<Widget> = (0..kids.len())
.filter_map(|index| {
let kid = kids.dict_at(index, r)?;
Some(Widget {
reference: kids.reference_at(index),
dict: kid,
})
})
.collect();
if !found.is_empty() {
return found;
}
}
if dict.byte_string(names::SUBTYPE, r).as_deref() == Some(b"Widget") {
return vec![Widget {
dict: dict.clone(),
reference,
}];
}
Vec::new()
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FieldValues {
entries: Vec<(String, String)>,
}
impl FieldValues {
#[must_use]
pub fn new() -> FieldValues {
FieldValues::default()
}
pub fn set(&mut self, name: impl Into<String>, value: impl Into<String>) {
let name = name.into();
let value = value.into();
match self.entries.iter_mut().find(|(key, _)| *key == name) {
Some(entry) => entry.1 = value,
None => self.entries.push((name, value)),
}
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&str> {
self.entries
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
self.entries
.iter()
.map(|(name, value)| (name.as_str(), value.as_str()))
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FieldEdit {
pub reference: ObjRef,
pub dict: Dict,
pub widgets: Vec<(ObjRef, Dict, GeneratedAp)>,
}
#[must_use]
pub fn apply<R: Resolve>(
form: &Form,
values: &FieldValues,
r: &R,
diags: &mut Diagnostics,
) -> Vec<FieldEdit> {
let mut out = Vec::new();
for (name, value) in values.iter() {
let Some(field) = form.field(name) else {
continue;
};
let Some(reference) = field.reference else {
continue;
};
if !field.kind.is_writable() {
continue;
}
let dict = rewrite(&field.dict, names::V, value_object(field.kind, value));
let mut widgets = Vec::new();
for widget in &field.widgets {
let Some(widget_ref) = widget.reference else {
continue;
};
let source = if widget_ref == reference {
&dict
} else {
&widget.dict
};
let widget_dict = if field.kind.is_toggle() {
rewrite(
source,
names::AS,
Object::Name(Name::from(value.as_bytes())),
)
} else {
source.clone()
};
if let Some(generated) = ap::widget::generate(&widget_dict, r) {
widgets.push((widget_ref, widget_dict, generated));
} else if widget_ref != reference && field.kind.is_toggle() {
widgets.push((
widget_ref,
widget_dict,
GeneratedAp {
stream: Vec::new(),
bbox: kurbo::Rect::ZERO,
matrix: kurbo::Affine::IDENTITY,
resources: Dict::new(),
rect_override: None,
as_override: None,
},
));
}
}
let _ = diags;
out.push(FieldEdit {
reference,
dict,
widgets,
});
}
out
}
fn value_object(kind: FieldKind, value: &str) -> Object {
if kind.is_toggle() {
Object::Name(Name::from(value.as_bytes()))
} else {
Object::Str(pdfrum_object::PdfString::literal(value.as_bytes()))
}
}
fn rewrite(dict: &Dict, key: &Name, value: Object) -> Dict {
let mut out = Dict::new();
let mut replaced = false;
for (existing, held) in dict.iter() {
if existing == key {
if !replaced {
out.push(existing.clone(), value.clone());
replaced = true;
}
} else {
out.push(existing.clone(), held.clone());
}
}
if !replaced {
out.push(key.clone(), value);
}
out
}
#[must_use]
pub fn selected_indices_for_interaction<R: Resolve>(
dict: &Dict,
options: &[String],
r: &R,
) -> Vec<usize> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let value = field_attr(dict, names::V, r, &limits, &mut diags);
let indices = field_attr(dict, names::I, r, &limits, &mut diags);
if let Some(indices) = indices.as_ref()
&& indices_are_usable(indices, value.as_ref(), options, r)
{
return listed_indices(indices, r)
.into_iter()
.filter_map(|index| usize::try_from(index).ok())
.filter(|index| *index < options.len())
.collect();
}
let Some(value) = value else {
return Vec::new();
};
let wanted: Vec<String> = match value.as_array() {
Some(array) => (0..array.len())
.map(|slot| {
array
.get(slot, r)
.as_deref()
.map(Object::to_text)
.unwrap_or_default()
})
.collect(),
None => vec![value.to_text()],
};
wanted
.into_iter()
.filter_map(|text| options.iter().position(|option| *option == text))
.collect()
}
fn listed_indices<R: Resolve>(indices: &Object, r: &R) -> Vec<i64> {
match indices.as_array() {
Some(array) => (0..array.len())
.map(|slot| array.get(slot, r).as_deref().and_then(Object::as_int))
.collect::<Option<Vec<i64>>>()
.unwrap_or_default(),
None => indices.as_int().into_iter().collect(),
}
}
fn indices_are_usable<R: Resolve>(
indices: &Object,
value: Option<&Object>,
options: &[String],
r: &R,
) -> bool {
let Some(value) = value else {
return true;
};
let listed = listed_indices(indices, r);
let declared = match indices.as_array() {
Some(array) => array.len(),
None => usize::from(indices.as_int().is_some()),
};
if listed.len() != declared || declared == 0 {
return false;
}
let mut wanted: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
if let Some(array) = value.as_array() {
if array.len() != listed.len() {
return false;
}
for slot in 0..array.len() {
if let Some(object) = array.get(slot, r)
&& object.as_string().is_some()
{
*wanted.entry(object.to_text()).or_default() += 1;
}
}
} else {
if listed.len() != 1 {
return false;
}
if value.as_string().is_some() {
*wanted.entry(value.to_text()).or_default() += 1;
}
}
for index in listed {
let Ok(index) = usize::try_from(index) else {
return false;
};
let Some(option) = options.get(index) else {
return false;
};
let Some(count) = wanted.get_mut(option) else {
return false;
};
*count -= 1;
if *count == 0 {
wanted.remove(option);
}
}
wanted.is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
use pdfrum_object::{Array, NoResolve, PdfString};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn text(value: &str) -> Object {
Object::Str(PdfString::literal(value.as_bytes()))
}
fn name(value: &str) -> Object {
Object::Name(Name::from(value))
}
fn opts() -> Vec<String> {
["Albania", "Belgium", "Croatia", "Denmark", "Estonia"]
.iter()
.map(|s| (*s).to_owned())
.collect()
}
fn selected(pairs: &[(&str, Object)]) -> Vec<usize> {
selected_indices_for_interaction(&dict(pairs), &opts(), &NoResolve)
}
fn strings(values: &[&str]) -> Object {
Object::Array(Array::of(
values.iter().map(|v| text(v)).collect::<Vec<_>>(),
))
}
#[test]
fn indices_alone_are_believed_because_nothing_contradicts_them() {
assert_eq!(
selected(&[(
"I",
Object::Array(Array::of([Object::Int(1), Object::Int(3)]))
)]),
vec![1, 3]
);
assert_eq!(selected(&[("I", Object::Int(2))]), vec![2]);
}
#[test]
fn a_value_alone_selects_every_option_it_names() {
assert_eq!(
selected(&[("V", strings(&["Belgium", "Denmark"]))]),
vec![1, 3]
);
assert_eq!(selected(&[("V", text("Croatia"))]), vec![2]);
assert_eq!(selected(&[("V", text("Zambia"))]), Vec::<usize>::new());
}
#[test]
fn consistent_indices_win_over_the_values_they_agree_with() {
assert_eq!(
selected(&[
("V", strings(&["Belgium", "Denmark"])),
(
"I",
Object::Array(Array::of([Object::Int(1), Object::Int(3)]))
),
]),
vec![1, 3]
);
assert_eq!(
selected(&[
("V", strings(&["Denmark", "Belgium"])),
(
"I",
Object::Array(Array::of([Object::Int(3), Object::Int(1)]))
),
]),
vec![3, 1]
);
}
#[test]
fn inconsistent_indices_are_discarded_whole_and_the_values_decide() {
assert_eq!(
selected(&[
("V", strings(&["Albania", "Croatia"])),
(
"I",
Object::Array(Array::of([Object::Int(1), Object::Int(3), Object::Int(4),])),
),
]),
vec![0, 2]
);
assert_eq!(
selected(&[
("V", strings(&["Albania"])),
("I", Object::Array(Array::of([Object::Int(1)]))),
]),
vec![0]
);
assert_eq!(
selected(&[
("V", strings(&["Albania", "Croatia"])),
(
"I",
Object::Array(Array::of([Object::Int(0), Object::Int(9)]))
),
]),
vec![0, 2]
);
assert_eq!(
selected(&[
("V", strings(&["Albania", "Belgium"])),
(
"I",
Object::Array(Array::of([Object::Int(0), Object::Int(0)]))
),
]),
vec![0, 1]
);
assert_eq!(
selected(&[
("V", strings(&["Albania"])),
("I", Object::Array(Array::of([text("0")]))),
]),
vec![0]
);
}
#[test]
fn a_field_declaring_neither_selects_nothing() {
assert_eq!(selected(&[]), Vec::<usize>::new());
}
fn load(catalog: &Dict) -> Option<Form> {
load_with_diags(catalog).0
}
fn load_with_diags(catalog: &Dict) -> (Option<Form>, Diagnostics) {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let form = Form::load(catalog, &NoResolve, &limits, &mut diags);
(form, diags)
}
fn only_field(form: &Form) -> &Field {
assert_eq!(form.len(), 1, "the fixture has exactly one field");
form.fields.first().expect("one field")
}
fn catalog_with(fields: Vec<Object>) -> Dict {
let acro = dict(&[("Fields", Object::Array(Array::of(fields)))]);
dict(&[("AcroForm", Object::Dict(acro))])
}
#[test]
fn a_catalog_without_an_acroform_has_no_form() {
assert_eq!(load(&Dict::new()), None);
}
#[test]
fn an_acroform_without_fields_is_an_empty_form_not_an_absent_one() {
let catalog = dict(&[("AcroForm", Object::Dict(Dict::new()))]);
let form = load(&catalog).expect("an AcroForm is a form");
assert!(form.is_empty());
}
#[test]
fn a_terminal_field_is_classified_from_its_type_and_flags() {
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("greeting")),
("V", text("hello")),
]))]);
let form = load(&catalog).expect("form");
assert_eq!(form.len(), 1);
let field = &only_field(&form);
assert_eq!(field.name, "greeting");
assert_eq!(field.kind, FieldKind::Text);
assert_eq!(field.stored_value(&NoResolve), "hello");
}
#[test]
fn the_button_flags_split_the_three_button_kinds() {
assert_eq!(
FieldKind::classify(b"Btn", FieldFlags::from_bits(0)),
Some(FieldKind::Check)
);
assert_eq!(
FieldKind::classify(b"Btn", FieldFlags::from_bits(1 << 15)),
Some(FieldKind::Radio)
);
assert_eq!(
FieldKind::classify(b"Btn", FieldFlags::from_bits((1 << 16) | (1 << 15))),
Some(FieldKind::Button)
);
}
#[test]
fn a_choice_field_splits_on_the_combo_bit() {
assert_eq!(
FieldKind::classify(b"Ch", FieldFlags::from_bits(0)),
Some(FieldKind::List)
);
assert_eq!(
FieldKind::classify(b"Ch", FieldFlags::from_bits(1 << 17)),
Some(FieldKind::Combo)
);
}
#[test]
fn a_node_with_no_field_type_is_not_a_field() {
assert_eq!(FieldKind::classify(b"", FieldFlags::from_bits(0)), None);
assert_eq!(
FieldKind::classify(b"Nonsense", FieldFlags::from_bits(0)),
None
);
}
#[test]
fn a_naming_node_contributes_its_children_and_its_name_prefix() {
let parent_dict = dict(&[("T", text("address"))]);
let kid = dict(&[
("FT", name("Tx")),
("T", text("street")),
("Parent", Object::Dict(parent_dict)),
]);
let parent = dict(&[
("T", text("address")),
("Kids", Object::Array(Array::of([Object::Dict(kid)]))),
]);
let catalog = catalog_with(vec![Object::Dict(parent)]);
let form = load(&catalog).expect("form");
assert_eq!(form.len(), 1);
assert_eq!(only_field(&form).name, "address.street");
}
#[test]
fn a_field_whose_kids_are_widgets_stays_one_field() {
let on = dict(&[("Subtype", name("Widget")), ("AS", name("A"))]);
let off = dict(&[("Subtype", name("Widget")), ("AS", name("Off"))]);
let group = dict(&[
("FT", name("Btn")),
("Ff", Object::Int(1 << 15)),
("T", text("choice")),
("V", name("A")),
(
"Kids",
Object::Array(Array::of([Object::Dict(on), Object::Dict(off)])),
),
]);
let catalog = catalog_with(vec![Object::Dict(group)]);
let form = load(&catalog).expect("form");
assert_eq!(form.len(), 1, "a radio group is one field, not two");
let field = &only_field(&form);
assert_eq!(field.kind, FieldKind::Radio);
assert_eq!(field.widgets.len(), 2);
assert!(field.is_checked(None, &NoResolve));
}
#[test]
fn a_merged_field_and_widget_reports_itself_as_its_widget() {
let merged = dict(&[
("FT", name("Tx")),
("T", text("box")),
("Subtype", name("Widget")),
]);
let catalog = catalog_with(vec![Object::Dict(merged)]);
let form = load(&catalog).expect("form");
assert_eq!(only_field(&form).widgets.len(), 1);
assert_eq!(
only_field(&form).widgets.first().expect("one widget").dict,
only_field(&form).dict
);
}
#[test]
fn a_toggle_reads_off_and_absent_as_clear_and_everything_else_as_set() {
let make = |value: Option<Object>| {
let mut pairs = vec![("FT", name("Btn")), ("T", text("t"))];
if value.is_some() {
pairs.push(("V", value.clone().unwrap_or(Object::Null)));
}
let catalog = catalog_with(vec![Object::Dict(dict(&pairs))]);
let form = load(&catalog).expect("form");
only_field(&form).is_checked(None, &NoResolve)
};
assert!(!make(None), "absent is clear");
assert!(!make(Some(name("Off"))), "Off is clear");
assert!(make(Some(name("Yes"))), "any other state is set");
}
#[test]
fn a_push_button_and_a_signature_hold_no_writable_value() {
assert!(!FieldKind::Button.is_writable());
assert!(!FieldKind::Signature.is_writable());
assert!(FieldKind::Text.is_writable());
assert!(FieldKind::Check.is_writable());
}
#[test]
fn writing_a_value_records_it_and_reading_sees_it() {
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("greeting")),
("V", text("hello")),
]))]);
let form = load(&catalog).expect("form");
let mut values = FieldValues::new();
values.set("greeting", "goodbye");
assert_eq!(
only_field(&form).value(Some(&values), &NoResolve),
"goodbye"
);
assert_eq!(only_field(&form).stored_value(&NoResolve), "hello");
}
#[test]
fn setting_the_same_field_twice_keeps_the_last_write_and_one_entry() {
let mut values = FieldValues::new();
values.set("a", "one");
values.set("a", "two");
assert_eq!(values.len(), 1);
assert_eq!(values.get("a"), Some("two"));
}
#[test]
fn an_option_pair_reports_its_label_rather_than_its_export_value() {
let pair = Object::Array(Array::of([text("export"), text("Label")]));
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Ch")),
("T", text("pick")),
("Opt", Object::Array(Array::of([text("Plain"), pair]))),
]))]);
let form = load(&catalog).expect("form");
assert_eq!(only_field(&form).options(&NoResolve), ["Plain", "Label"]);
}
#[test]
fn the_states_of_a_toggle_come_from_its_widgets_appearances() {
let normal = dict(&[("Off", Object::Null), ("Yes", Object::Null)]);
let ap = dict(&[("N", Object::Dict(normal))]);
let widget = dict(&[
("FT", name("Btn")),
("T", text("t")),
("Subtype", name("Widget")),
("AP", Object::Dict(ap)),
]);
let catalog = catalog_with(vec![Object::Dict(widget)]);
let form = load(&catalog).expect("form");
assert_eq!(only_field(&form).states(&NoResolve), ["Off", "Yes"]);
}
#[test]
fn a_field_with_no_reference_cannot_be_written_back() {
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("inline")),
]))]);
let form = load(&catalog).expect("form");
assert_eq!(only_field(&form).reference, None);
let mut values = FieldValues::new();
values.set("inline", "x");
let mut diags = Diagnostics::default();
assert!(
apply(&form, &values, &NoResolve, &mut diags).is_empty(),
"an unnamed field produces no replacement"
);
}
#[test]
fn rewriting_a_key_keeps_the_dictionary_order() {
let source = dict(&[
("A", Object::Int(1)),
("V", text("old")),
("B", Object::Int(2)),
]);
let out = rewrite(&source, &Name::from("V"), text("new"));
let keys: Vec<&[u8]> = out.keys().map(pdfrum_object::Name::as_bytes).collect();
assert_eq!(keys, [b"A".as_slice(), b"V".as_slice(), b"B".as_slice()]);
assert_eq!(
out.text(&Name::from("V"), &NoResolve).as_deref(),
Some("new")
);
}
#[test]
fn rewriting_an_absent_key_appends_it() {
let out = rewrite(&Dict::new(), &Name::from("V"), text("v"));
assert_eq!(out.len(), 1);
}
#[test]
fn a_field_with_no_name_anywhere_in_its_ancestry_is_dropped() {
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Tx")),
("V", text("unreachable")),
]))]);
let (form, diags) = load_with_diags(&catalog);
let form = form.expect("an AcroForm is still a form");
assert!(form.is_empty(), "an unnamed terminal field is not a field");
assert!(diags.contains(&pdfrum_common::DiagKind::FieldSkippedNoName));
}
#[test]
fn unnamed_fields_do_not_collapse_into_one() {
let unnamed = || Object::Dict(dict(&[("FT", name("Tx")), ("V", text("a"))]));
let catalog = catalog_with(vec![
unnamed(),
unnamed(),
Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("real")),
("V", text("b")),
])),
]);
let form = load(&catalog).expect("a form");
assert_eq!(only_field(&form).name, "real");
}
#[test]
fn a_field_named_only_by_an_ancestor_survives() {
let kid = Object::Dict(dict(&[
("Subtype", name("Widget")),
(
"Parent",
Object::Dict(dict(&[("FT", name("Tx")), ("T", text("parent"))])),
),
]));
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("parent")),
("Kids", Object::Array(Array::of([kid]))),
]))]);
let form = load(&catalog).expect("a form");
assert_eq!(only_field(&form).name, "parent");
}
#[test]
fn two_fields_entries_sharing_a_name_are_one_field_with_two_widgets() {
let widget = |value: &str| {
Object::Dict(dict(&[
("Type", name("Annot")),
("Subtype", name("Widget")),
("FT", name("Tx")),
("T", text("SharedField")),
("V", text(value)),
]))
};
let catalog = dict(&[(
"AcroForm",
Object::Dict(dict(&[(
"Fields",
Object::Array(Array::of([widget("Hello, world"), widget("")])),
)])),
)]);
let form = load(&catalog).expect("a form");
let field = only_field(&form);
assert_eq!(field.name, "SharedField");
assert_eq!(field.widgets.len(), 2);
assert_eq!(field.value(None, &NoResolve), "Hello, world");
}
#[test]
fn two_fields_entries_with_different_names_stay_two_fields() {
let widget = |field_name: &str| {
Object::Dict(dict(&[
("Type", name("Annot")),
("Subtype", name("Widget")),
("FT", name("Tx")),
("T", text(field_name)),
]))
};
let catalog = dict(&[(
"AcroForm",
Object::Dict(dict(&[(
"Fields",
Object::Array(Array::of([widget("one"), widget("two")])),
)])),
)]);
assert_eq!(load(&catalog).expect("a form").len(), 2);
}
#[test]
fn the_need_appearances_flag_is_read_off_the_acroform() {
let acro = dict(&[("NeedAppearances", Object::Bool(true))]);
let catalog = dict(&[("AcroForm", Object::Dict(acro))]);
assert!(load(&catalog).expect("form").need_appearances);
let catalog = dict(&[("AcroForm", Object::Dict(Dict::new()))]);
assert!(!load(&catalog).expect("form").need_appearances);
}
#[test]
fn the_flag_word_accessors_read_the_documented_bits() {
let f = FieldFlags::from_bits;
assert!(f(1).is_read_only());
assert!(f(2).is_required());
assert!(f(1 << 12).is_multiline());
assert!(f(1 << 13).is_password());
assert!(f(1 << 18).is_editable_combo());
assert!(f(1 << 21).is_multi_select());
assert!(f(1 << 24).is_comb());
assert!(!f(0).is_read_only());
assert!(!f(0).is_editable_combo());
assert!(!f(0).is_multi_select());
assert!(!f(0).is_comb());
assert!(!f(1 << 17).is_editable_combo());
}
#[test]
fn the_negative_spec_bits_read_positively() {
let f = FieldFlags::from_bits;
assert!(f(0).scrolls());
assert!(!f(1 << 23).scrolls());
assert!(f(1 << 22).scrolls());
assert!(f(0).spell_checks());
assert!(!f(1 << 22).spell_checks());
assert!(f(1 << 23).spell_checks());
}
#[test]
fn the_flag_word_round_trips_unknown_bits() {
let raw = (1 << 40) | (1 << 25) | 1;
let f = FieldFlags::from_bits(raw);
assert_eq!(f.bits(), raw);
assert!(f.is_read_only());
assert_eq!(FieldFlags::default().bits(), 0);
}
struct Store(std::collections::HashMap<u32, std::sync::Arc<Object>>);
impl Store {
fn of(pairs: impl IntoIterator<Item = (u32, Object)>) -> Store {
Store(
pairs
.into_iter()
.map(|(num, obj)| (num, std::sync::Arc::new(obj)))
.collect(),
)
}
}
impl Resolve for Store {
fn fetch(&self, r: ObjRef) -> Result<std::sync::Arc<Object>, pdfrum_object::Error> {
self.0
.get(&r.num)
.map(std::sync::Arc::clone)
.ok_or(pdfrum_object::Error::UnresolvedRef(r))
}
}
fn reference(num: u32) -> Object {
Object::Ref(ObjRef { num, generation: 0 })
}
#[test]
fn a_junk_first_kid_costs_that_kid_and_not_its_siblings() {
let store = Store::of([(
2,
Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("real")),
("V", text("kept")),
])),
)]);
let catalog = catalog_with(vec![Object::Dict(dict(&[(
"Kids",
Object::Array(Array::of([reference(9), reference(2)])),
)]))]);
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let form = Form::load(&catalog, &store, &limits, &mut diags).expect("a form");
let field = only_field(&form);
assert_eq!(field.name, "real");
assert_eq!(field.stored_value(&store), "kept");
}
#[test]
fn a_junk_first_kid_does_not_blind_the_terminal_probe() {
let parent = dict(&[("FT", name("Tx")), ("T", text("parent"))]);
let store = Store::of([(
2,
Object::Dict(dict(&[
("T", text("kid")),
("V", text("v")),
("Parent", Object::Dict(parent.clone())),
])),
)]);
let catalog = catalog_with(vec![Object::Dict(dict(&[
("FT", name("Tx")),
("T", text("parent")),
(
"Kids",
Object::Array(Array::of([reference(9), reference(2)])),
),
]))]);
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let form = Form::load(&catalog, &store, &limits, &mut diags).expect("a form");
assert_eq!(only_field(&form).name, "parent.kid");
}
fn three_fields(co: Option<Object>) -> (Dict, Store) {
let field_of = |title: &str| {
Object::Dict(dict(&[
("FT", name("Tx")),
("T", text(title)),
("V", text("")),
]))
};
let store = Store::of([(1, field_of("a")), (2, field_of("b")), (3, field_of("c"))]);
let mut acro = vec![(
"Fields",
Object::Array(Array::of([reference(1), reference(2), reference(3)])),
)];
if let Some(co) = co {
acro.push(("CO", co));
}
let catalog = dict(&[("AcroForm", Object::Dict(dict(&acro)))]);
(catalog, store)
}
fn order_of(co: Option<Object>) -> Vec<usize> {
let (catalog, store) = three_fields(co);
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let form = Form::load(&catalog, &store, &limits, &mut diags).expect("form");
assert_eq!(form.len(), 3, "the fixture has three fields");
form.calculation_order(&catalog, &store)
}
#[test]
fn a_document_with_no_calculation_order_calculates_nothing() {
assert_eq!(order_of(None), Vec::<usize>::new());
assert_eq!(order_of(Some(Object::Array(Array::of([])))), Vec::new());
}
#[test]
fn the_array_is_the_order() {
assert_eq!(
order_of(Some(Object::Array(Array::of([
reference(3),
reference(1),
reference(2),
])))),
vec![2, 0, 1]
);
assert_eq!(
order_of(Some(Object::Array(Array::of([reference(2)])))),
vec![1]
);
}
#[test]
fn entries_that_resolve_to_nothing_are_dropped() {
assert_eq!(
order_of(Some(Object::Array(Array::of([
reference(9), Object::Int(7), reference(2),
])))),
vec![1]
);
assert_eq!(order_of(Some(Object::Int(1))), Vec::<usize>::new());
}
#[test]
fn duplicates_are_kept_because_the_array_is_indexed_positionally() {
assert_eq!(
order_of(Some(Object::Array(Array::of(
[reference(1), reference(1),]
)))),
vec![0, 0]
);
}
}