use pdfrum_common::{Diagnostics, Limits, PageIndex};
use pdfrum_doc::form::{FieldFlags, FieldKind};
use pdfrum_doc::{Subtype, ap};
use pdfrum_object::{Dict, Name, Resolve, names as obj_names};
use crate::field::{ChoiceConfig, ChoiceOption, TextConfig};
const MAX_LEN: &Name = &Name::from_static(b"MaxLen");
const TI: &Name = &Name::from_static(b"TI");
const FIELDS: &Name = &Name::from_static(b"Fields");
const TABS: &Name = &Name::from_static(b"Tabs");
use crate::geom::Rotation;
use crate::hit::{Candidate, LayoutBand, WidgetHit};
use crate::session::{AnnotId, FieldId};
use crate::tab::{Focusable, Rect, TabOrder};
#[derive(Debug, Clone, Default)]
pub struct PageForm {
pub page: PageIndex,
pub(crate) candidates: Vec<Candidate>,
pub(crate) focusables: Vec<(Subtype, Focusable)>,
pub tab_order: TabOrder,
pub widgets: Vec<WidgetInfo>,
pub dicts: std::collections::BTreeMap<u32, Dict>,
pub page_height: f32,
}
impl PageForm {
#[must_use]
pub fn field_of_index(&self, index: u32) -> Option<FieldId> {
self.widgets
.iter()
.find(|widget| widget.field_index == Some(index))
.map(|widget| widget.field)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct WidgetInfo {
pub id: AnnotId,
pub field: FieldId,
pub field_index: Option<u32>,
pub name: String,
pub kind: Option<FieldKind>,
pub flags: FieldFlags,
pub(crate) rect: Rect,
pub rotation: Rotation,
pub dict: Dict,
pub valued: Dict,
}
impl WidgetInfo {
#[must_use]
pub fn value<R: Resolve>(&self, r: &R) -> String {
ap::field_body::field_value(&self.valued, r)
}
#[must_use]
pub fn options<R: Resolve>(&self, r: &R) -> Vec<ChoiceOption> {
ap::field_body::options(&self.valued, r)
.into_iter()
.map(|choice| ChoiceOption {
label: choice.label,
value: choice.value,
})
.collect()
}
#[must_use]
pub fn selected<R: Resolve>(&self, r: &R) -> Vec<usize> {
let values: Vec<String> = ap::field_body::options(&self.valued, r)
.into_iter()
.map(|choice| choice.value)
.collect();
pdfrum_doc::form::selected_indices_for_interaction(&self.valued, &values, r)
}
#[must_use]
pub fn text_config<R: Resolve>(&self, r: &R) -> TextConfig {
let max_len =
inherited_int(&self.dict, MAX_LEN, r).and_then(|value| u32::try_from(value).ok());
TextConfig::read(self.flags, max_len)
}
#[must_use]
pub fn choice_config(&self) -> ChoiceConfig {
ChoiceConfig::read(self.flags)
}
#[must_use]
pub fn top_index<R: Resolve>(&self, r: &R) -> usize {
inherited_int(&self.dict, TI, r)
.and_then(|value| usize::try_from(value).ok())
.unwrap_or(0)
}
}
#[must_use]
pub fn read<R: Resolve>(
page: impl Into<PageIndex>,
page_dict: &Dict,
catalog: &Dict,
r: &R,
) -> PageForm {
let page = page.into();
let mut form = PageForm {
page,
tab_order: TabOrder::from_tabs(page_dict.byte_string(TABS, r).as_deref()),
page_height: page_height(page_dict, r),
..PageForm::default()
};
let Some(annots) = page_dict.array(obj_names::ANNOTS, r) else {
return form;
};
let mut names: Vec<String> = Vec::new();
let form_fields = document_field_names(catalog, r);
for index in 0..annots.len() {
let Some(dict) = annots.dict_at(index, r) else {
continue;
};
let id = AnnotId::new(page, u32::try_from(index).unwrap_or(u32::MAX));
let subtype =
Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
let rect = to_rect(dict.rect(obj_names::RECT, r));
let band = band_of(subtype);
let widget = (subtype == Subtype::Widget).then(|| read_widget(&dict, r));
form.candidates.push(Candidate {
id,
rect,
band,
widget: widget.as_ref().map(|(hit, _)| *hit),
});
if let Some((_, info)) = widget {
let name = pdfrum_doc::form::full_name(&dict, r);
let field = field_id_of(&mut names, &name, index);
let field_index = position_in_form(&form_fields, &name);
let valued = value_dict_of(&dict, catalog, r).unwrap_or_else(|| dict.clone());
form.widgets.push(WidgetInfo {
id,
field,
field_index,
name,
kind: info.kind,
flags: info.flags,
rect,
rotation: pdfrum_doc::ap::widget::widget_rotation(&dict, r),
dict: dict.clone(),
valued,
});
}
form.focusables.push((subtype, Focusable { id, rect }));
form.dicts.insert(id.index, dict);
}
form
}
fn page_height<R: Resolve>(page_dict: &Dict, r: &R) -> f32 {
let inherited = |key: &Name| -> Option<pdfrum_object::Object> {
let mut node = page_dict.clone();
for _ in 0..64 {
if let Some(value) = node.get(key, r) {
return Some(value.get().clone());
}
node = node.dict(obj_names::PARENT, r)?;
}
None
};
let mut diags = Diagnostics::default();
let (_, height) = pdfrum_page::display_size_from_dict(page_dict, inherited, r, &mut diags);
#[expect(
clippy::cast_possible_truncation,
reason = "a page taller than f32 has already lost meaning; the value \
only decides which side a dropdown opens on"
)]
let height = height as f32;
height
}
struct WidgetRead {
kind: Option<FieldKind>,
flags: FieldFlags,
}
fn read_widget<R: Resolve>(dict: &Dict, r: &R) -> (WidgetHit, WidgetRead) {
let flags = FieldFlags::from_bits(inherited_int(dict, obj_names::FF, r).unwrap_or(0));
let field_type = inherited_name(dict, obj_names::FT, r).unwrap_or_default();
let kind = FieldKind::classify(&field_type, flags);
let annot_flags = pdfrum_doc::AnnotFlags::from_bits(dict.int(obj_names::F, r).unwrap_or(0));
let hit = WidgetHit {
signature: kind == Some(FieldKind::Signature),
hidden: annot_flags.is_hidden()
|| annot_flags.no_view()
|| annot_flags.contains(pdfrum_doc::AnnotFlags::INVISIBLE),
read_only: flags.is_read_only(),
push_button: kind == Some(FieldKind::Button),
};
(hit, WidgetRead { kind, flags })
}
fn band_of(subtype: Subtype) -> LayoutBand {
match subtype {
Subtype::Popup => LayoutBand::Popup,
Subtype::Widget => LayoutBand::Widget,
_ => LayoutBand::Other,
}
}
fn field_id_of(names: &mut Vec<String>, name: &str, index: usize) -> FieldId {
if name.is_empty() {
return FieldId(u32::MAX - u32::try_from(index).unwrap_or(0));
}
if let Some(at) = names.iter().position(|known| known == name) {
return FieldId(u32::try_from(at).unwrap_or(0));
}
names.push(name.to_string());
FieldId(u32::try_from(names.len() - 1).unwrap_or(0))
}
fn value_dict_of<R: Resolve>(dict: &Dict, catalog: &Dict, r: &R) -> Option<Dict> {
if dict.contains_key(obj_names::PARENT) {
return None;
}
let name = dict.byte_string(obj_names::T, r)?;
let form = catalog.dict(obj_names::ACRO_FORM, r)?;
let fields = form.array(FIELDS, r)?;
let first = (0..fields.len())
.filter_map(|index| fields.dict_at(index, r))
.find(|entry| entry.byte_string(obj_names::T, r).as_deref() == Some(name.as_slice()))?;
(first != *dict).then_some(first)
}
fn document_field_names<R: Resolve>(catalog: &Dict, r: &R) -> Vec<String> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
pdfrum_doc::form::Form::load(catalog, r, &limits, &mut diags)
.map(|form| form.fields.iter().map(|field| field.name.clone()).collect())
.unwrap_or_default()
}
fn position_in_form(fields: &[String], name: &str) -> Option<u32> {
if name.is_empty() {
return None;
}
fields
.iter()
.position(|field| field == name)
.and_then(|index| u32::try_from(index).ok())
}
fn inherited_int<R: Resolve>(dict: &Dict, key: &pdfrum_object::Name, r: &R) -> Option<i64> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
pdfrum_doc::form::field_attr(dict, key, r, &limits, &mut diags)?.as_int()
}
fn inherited_name<R: Resolve>(dict: &Dict, key: &pdfrum_object::Name, r: &R) -> Option<Vec<u8>> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
Some(pdfrum_doc::form::field_attr(dict, key, r, &limits, &mut diags)?.to_byte_string())
}
pub(crate) fn to_rect(rect: kurbo::Rect) -> Rect {
#[expect(
clippy::cast_possible_truncation,
reason = "page coordinates beyond f32 have already lost meaning, and every \
geometric query in this crate is f32"
)]
Rect::new(
rect.x0 as f32,
rect.y0 as f32,
rect.x1 as f32,
rect.y1 as f32,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unnamed_widget_is_its_own_field() {
let mut names = Vec::new();
let first = field_id_of(&mut names, "", 0);
let second = field_id_of(&mut names, "", 1);
assert_ne!(first, second, "two unnamed widgets are two fields");
assert!(names.is_empty(), "and neither claims a named id");
}
#[test]
fn two_widgets_with_one_name_are_one_field() {
let mut names = Vec::new();
let first = field_id_of(&mut names, "Group", 0);
let second = field_id_of(&mut names, "Group", 3);
assert_eq!(first, second);
assert_eq!(names.len(), 1);
}
#[test]
fn distinct_names_take_distinct_ids_in_first_seen_order() {
let mut names = Vec::new();
assert_eq!(field_id_of(&mut names, "A", 0), FieldId(0));
assert_eq!(field_id_of(&mut names, "B", 1), FieldId(1));
assert_eq!(field_id_of(&mut names, "A", 2), FieldId(0));
assert_eq!(field_id_of(&mut names, "C", 3), FieldId(2));
}
#[test]
fn the_unnamed_id_space_does_not_meet_the_named_one() {
let mut names = Vec::new();
let loose = field_id_of(&mut names, "", 0);
for index in 0..64 {
let titled = field_id_of(&mut names, &format!("field{index}"), index);
assert_ne!(titled, loose);
}
}
#[test]
fn subtypes_sort_into_the_three_bands() {
assert_eq!(band_of(Subtype::Popup), LayoutBand::Popup);
assert_eq!(band_of(Subtype::Widget), LayoutBand::Widget);
assert_eq!(band_of(Subtype::Link), LayoutBand::Other);
assert_eq!(band_of(Subtype::Highlight), LayoutBand::Other);
}
#[test]
fn a_page_with_no_annots_reads_as_empty() {
let page = Dict::new();
let catalog = Dict::new();
let form = read(0, &page, &catalog, &pdfrum_object::NoResolve);
assert!(form.candidates.is_empty());
assert!(form.widgets.is_empty());
assert!(form.focusables.is_empty());
}
}