use kurbo::Rect;
use pdfrum_common::PageIndex;
use pdfrum_object::{Array, Dict, Name, ObjRef, Object, PdfString, Resolve, encode_text, names};
use crate::{WidgetAppearance, doc::EditDoc, error::Error, font::embed::EmbeddedFont, form};
type Result<T> = core::result::Result<T, Error>;
pub const DEFAULT_FIELD_DA: &str = "0 g /Helv 0 Tf";
const DEFAULT_FONT_RESOURCE: &str = "Helv";
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum FieldKindSpec {
Text,
Check {
on: String,
},
Radio {
buttons: Vec<(Rect, String)>,
},
}
impl FieldKindSpec {
#[must_use]
pub fn check() -> Self {
Self::Check {
on: "Yes".to_owned(),
}
}
fn field_type(&self) -> &'static Name {
match self {
Self::Text => crate::names::TX,
Self::Check { .. } | Self::Radio { .. } => crate::names::BTN,
}
}
fn implied_flags(&self) -> i64 {
match self {
Self::Text | Self::Check { .. } => 0,
Self::Radio { .. } => (1 << 15) | (1 << 14),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct FieldSpec {
name: String,
rect: Rect,
kind: FieldKindSpec,
value: Option<String>,
default_value: Option<String>,
tooltip: Option<String>,
da: Option<String>,
max_len: Option<i64>,
extra_flags: i64,
appearance: Option<WidgetAppearance>,
}
impl FieldSpec {
#[must_use]
pub fn new(name: impl Into<String>, rect: Rect, kind: FieldKindSpec) -> Self {
Self {
name: name.into(),
rect,
kind,
value: None,
default_value: None,
tooltip: None,
da: None,
max_len: None,
extra_flags: 0,
appearance: None,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn value(mut self, value: impl Into<String>) -> Self {
self.value = Some(value.into());
self
}
#[must_use]
pub fn default_value(mut self, value: impl Into<String>) -> Self {
self.default_value = Some(value.into());
self
}
#[must_use]
pub fn tooltip(mut self, text: impl Into<String>) -> Self {
self.tooltip = Some(text.into());
self
}
#[must_use]
pub fn da(mut self, da: impl Into<String>) -> Self {
self.da = Some(da.into());
self
}
#[must_use]
pub fn max_len(mut self, max: i64) -> Self {
self.max_len = Some(max);
self
}
#[must_use]
pub fn read_only(self, yes: bool) -> Self {
self.flag(1 << 0, yes)
}
#[must_use]
pub fn required(self, yes: bool) -> Self {
self.flag(1 << 1, yes)
}
#[must_use]
pub fn multiline(self, yes: bool) -> Self {
self.flag(1 << 12, yes)
}
#[must_use]
pub fn password(self, yes: bool) -> Self {
self.flag(1 << 13, yes)
}
#[must_use]
pub fn comb(self, yes: bool) -> Self {
self.flag(1 << 24, yes)
}
#[must_use]
pub fn flags(mut self, bits: i64) -> Self {
self.extra_flags |= bits;
self
}
#[must_use]
pub fn appearance(mut self, appearance: WidgetAppearance) -> Self {
self.appearance = Some(appearance);
self
}
fn flag(mut self, bit: i64, yes: bool) -> Self {
if yes {
self.extra_flags |= bit;
} else {
self.extra_flags &= !bit;
}
self
}
fn flag_word(&self) -> i64 {
self.kind.implied_flags() | self.extra_flags
}
}
pub fn add_form_field(
dest: &mut EditDoc<'_>,
page: impl Into<PageIndex>,
spec: &FieldSpec,
) -> Result<ObjRef> {
if spec.name.is_empty() {
return Err(Error::EmptyFieldName);
}
if let FieldKindSpec::Radio { buttons } = &spec.kind
&& buttons.is_empty()
{
return Err(Error::EmptyRadioGroup);
}
let page = page.into();
let Some((page_ref, mut page_dict, _)) = dest.page_state(page)? else {
return Err(Error::InlinePage(page));
};
if field_names(dest).iter().any(|name| name == &spec.name) {
return Err(Error::DuplicateFieldName(spec.name.clone()));
}
ensure_default_resources(dest)?;
let field_ref = match &spec.kind {
FieldKindSpec::Radio { buttons } => {
write_radio_group(dest, page_ref, &mut page_dict, spec, buttons)
}
_ => write_single_widget_field(dest, page_ref, &mut page_dict, spec),
};
register_field(dest, field_ref)?;
Ok(field_ref)
}
pub fn add_form_font(
dest: &mut EditDoc<'_>,
name: impl AsRef<str>,
font: &EmbeddedFont,
) -> Result<()> {
let resource = Name::from(name.as_ref().as_bytes());
let font_ref = font.object();
edit_form(dest, |dest, form| {
let mut resources = sub_dict(dest, form, crate::names::DR);
let mut fonts = sub_dict(dest, &resources, names::FONT);
fonts.insert(resource, Object::Ref(font_ref));
resources.insert(names::FONT.clone(), Object::Dict(fonts));
form.insert(crate::names::DR.clone(), Object::Dict(resources));
})
}
fn field_names(dest: &EditDoc<'_>) -> Vec<String> {
let Some(catalog) = edited_catalog(dest) else {
return Vec::new();
};
let (limits, mut diags) = (
pdfrum_common::Limits::default(),
pdfrum_common::Diagnostics::default(),
);
pdfrum_doc::form::Form::load(&catalog, dest, &limits, &mut diags)
.map(|form| form.fields.into_iter().map(|field| field.name).collect())
.unwrap_or_default()
}
pub(crate) fn edited_catalog(dest: &EditDoc<'_>) -> Option<Dict> {
let root = dest.base().trailer().reference(names::ROOT)?;
dest.fetch(root)
.ok()
.and_then(|object| object.as_dict().cloned())
}
fn sub_dict(dest: &EditDoc<'_>, parent: &Dict, key: &Name) -> Dict {
match parent.raw(key) {
Some(Object::Ref(reference)) => dest
.fetch(*reference)
.ok()
.and_then(|object| object.as_dict().cloned())
.unwrap_or_default(),
Some(Object::Dict(dict)) => dict.clone(),
_ => Dict::new(),
}
}
fn ensure_default_resources(dest: &mut EditDoc<'_>) -> Result<()> {
let has_helv = edited_catalog(dest)
.and_then(|catalog| catalog.dict(names::ACRO_FORM, dest))
.and_then(|form| form.dict(crate::names::DR, dest))
.and_then(|resources| resources.dict(names::FONT, dest))
.is_some_and(|fonts| fonts.contains_key(&Name::from(DEFAULT_FONT_RESOURCE)));
if has_helv {
return Ok(());
}
let face = dest.standard_font(crate::StandardFont::Helvetica)?;
add_form_font(dest, DEFAULT_FONT_RESOURCE, &face)
}
fn write_single_widget_field(
dest: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
spec: &FieldSpec,
) -> ObjRef {
let mut dict = field_dict(spec);
widget_keys(&mut dict, spec.rect, page_ref, spec.appearance.as_ref());
if let FieldKindSpec::Check { on } = &spec.kind {
let state = state_name(spec.value.as_deref(), on);
dict.insert(names::AS.clone(), Object::Name(state.clone()));
dict.insert(names::V.clone(), Object::Name(state));
}
let field_ref = dest.add(Object::Dict(Dict::new()));
let dict = with_appearance(dest, dict, &appearance_states(&spec.kind));
dest.replace(field_ref, Object::Dict(dict));
attach_to_page(dest, page_ref, page_dict, field_ref);
field_ref
}
fn write_radio_group(
dest: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
spec: &FieldSpec,
buttons: &[(Rect, String)],
) -> ObjRef {
let parent_ref = dest.add(Object::Dict(Dict::new()));
let mut parent = field_dict(spec);
let chosen = spec.value.as_deref();
let mut kids = Array::default();
for (rect, export) in buttons {
let mut kid = Dict::new();
widget_keys(&mut kid, *rect, page_ref, spec.appearance.as_ref());
kid.insert(names::PARENT.clone(), Object::Ref(parent_ref));
let state = if chosen == Some(export.as_str()) {
Name::from(export.as_bytes())
} else {
crate::names::OFF.clone()
};
kid.insert(names::AS.clone(), Object::Name(state));
let kid_ref = dest.add(Object::Dict(Dict::new()));
let states = [crate::names::OFF.clone(), Name::from(export.as_bytes())];
let kid = with_appearance(dest, kid, &states);
dest.replace(kid_ref, Object::Dict(kid));
attach_to_page(dest, page_ref, page_dict, kid_ref);
kids.push(Object::Ref(kid_ref));
}
parent.insert(names::KIDS.clone(), Object::Array(kids));
let value = chosen
.filter(|chosen| buttons.iter().any(|(_, export)| export == chosen))
.map_or_else(|| crate::names::OFF.clone(), |v| Name::from(v.as_bytes()));
parent.insert(names::V.clone(), Object::Name(value));
dest.replace(parent_ref, Object::Dict(parent));
parent_ref
}
fn field_dict(spec: &FieldSpec) -> Dict {
let mut dict = Dict::new();
dict.insert(
names::FT.clone(),
Object::Name(spec.kind.field_type().clone()),
);
dict.insert(names::T.clone(), Object::Str(text_string(&spec.name)));
dict.insert(
names::DA.clone(),
Object::Str(PdfString::literal(
spec.da.as_deref().unwrap_or(DEFAULT_FIELD_DA).as_bytes(),
)),
);
let flags = spec.flag_word();
if flags != 0 {
dict.insert(names::FF.clone(), Object::Int(flags));
}
if let Some(tooltip) = &spec.tooltip {
dict.insert(names::TU.clone(), Object::Str(text_string(tooltip)));
}
if matches!(spec.kind, FieldKindSpec::Text) {
if let Some(max) = spec.max_len {
dict.insert(crate::names::MAX_LEN.clone(), Object::Int(max));
}
if let Some(value) = &spec.value {
dict.insert(names::V.clone(), Object::Str(text_string(value)));
}
let default = spec.default_value.as_ref().or(spec.value.as_ref());
if let Some(default) = default {
dict.insert(crate::names::DV.clone(), Object::Str(text_string(default)));
}
} else if let Some(default) = &spec.default_value {
dict.insert(
crate::names::DV.clone(),
Object::Name(Name::from(default.as_bytes())),
);
}
dict
}
fn widget_keys(
dict: &mut Dict,
rect: Rect,
page_ref: ObjRef,
appearance: Option<&WidgetAppearance>,
) {
dict.insert(
names::TYPE.clone(),
Object::Name(crate::names::ANNOT.clone()),
);
dict.insert(
names::SUBTYPE.clone(),
Object::Name(crate::names::WIDGET.clone()),
);
dict.insert(names::RECT.clone(), rect_object(rect));
dict.insert(names::P.clone(), Object::Ref(page_ref));
dict.insert(names::F.clone(), Object::Int(1 << 2));
if let Some(appearance) = appearance {
dict.insert(form::mk_key(), Object::Dict(appearance.to_dict()));
}
}
fn appearance_states(kind: &FieldKindSpec) -> Vec<Name> {
match kind {
FieldKindSpec::Check { on } => {
vec![crate::names::OFF.clone(), Name::from(on.as_bytes())]
}
FieldKindSpec::Text | FieldKindSpec::Radio { .. } => Vec::new(),
}
}
fn state_name(value: Option<&str>, on: &str) -> Name {
match value {
Some(value) if value == on => Name::from(on.as_bytes()),
_ => crate::names::OFF.clone(),
}
}
fn with_appearance(dest: &mut EditDoc<'_>, dict: Dict, states: &[Name]) -> Dict {
if states.is_empty() {
let mut dict = dict;
if let Some(stream) = generate_stream(dest, &dict) {
let mut ap = Dict::new();
ap.insert(names::N.clone(), Object::Ref(stream));
dict.insert(names::AP.clone(), Object::Dict(ap));
}
return dict;
}
let mut normal = Dict::new();
for state in states {
let mut probe = dict.clone();
probe.insert(names::AS.clone(), Object::Name(state.clone()));
if let Some(stream) = generate_stream(dest, &probe) {
normal.insert(state.clone(), Object::Ref(stream));
}
}
let mut dict = dict;
if !normal.is_empty() {
let mut ap = Dict::new();
ap.insert(names::N.clone(), Object::Dict(normal));
dict.insert(names::AP.clone(), Object::Dict(ap));
}
dict
}
fn generate_stream(dest: &mut EditDoc<'_>, dict: &Dict) -> Option<ObjRef> {
let catalog = edited_catalog(dest).unwrap_or_default();
let mut build = pdfrum_page::BuildContext::new();
let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, dest, &mut build);
let mut diags = pdfrum_common::Diagnostics::default();
let page = Dict::from_pairs([(
names::ANNOTS.clone(),
Object::Array(Array::of([Object::Dict(dict.clone())])),
)]);
let overlay = pdfrum_doc::ap::generate_appearances_with_text(
&page,
&catalog,
Some(&fonts),
dest,
&mut diags,
);
let generated = overlay.get(0)?;
if generated.stream.is_empty() {
return None;
}
let stream = pdfrum_object::Stream::new(
pdfrum_doc::ap::stream_dict(generated),
pdfrum_object::ByteSpan::from(generated.stream.clone()),
);
Some(dest.add(Object::Stream(Box::new(stream))))
}
fn register_field(dest: &mut EditDoc<'_>, field_ref: ObjRef) -> Result<()> {
edit_form(dest, |dest, form| {
let mut fields = match form.raw(crate::names::FIELDS) {
Some(Object::Ref(reference)) => dest
.fetch(*reference)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned()
.unwrap_or_default(),
Some(Object::Array(array)) => array.clone(),
_ => Array::default(),
};
fields.push(Object::Ref(field_ref));
form.insert(crate::names::FIELDS.clone(), Object::Array(fields));
})
}
fn edit_form(
dest: &mut EditDoc<'_>,
change: impl FnOnce(&mut EditDoc<'_>, &mut Dict),
) -> Result<()> {
let Some(root) = dest.base().trailer().reference(names::ROOT) else {
return Err(Error::NoDestinationCatalog);
};
let Some(mut catalog) = edited_catalog(dest) else {
return Err(Error::NoDestinationCatalog);
};
match catalog.raw(names::ACRO_FORM).cloned() {
Some(Object::Ref(form_ref)) => {
let mut form = dest
.fetch(form_ref)
.ok()
.and_then(|object| object.as_dict().cloned())
.unwrap_or_default();
change(dest, &mut form);
dest.replace(form_ref, Object::Dict(form));
}
existing => {
let mut form = match existing {
Some(Object::Dict(form)) => form,
_ => Dict::new(),
};
change(dest, &mut form);
catalog.insert(names::ACRO_FORM.clone(), Object::Dict(form));
dest.replace(root, Object::Dict(catalog));
}
}
Ok(())
}
fn attach_to_page(
dest: &mut EditDoc<'_>,
page_ref: ObjRef,
page_dict: &mut Dict,
annot_ref: ObjRef,
) {
match page_dict.raw(names::ANNOTS).cloned() {
Some(Object::Ref(array_ref)) => {
let mut array = dest
.fetch(array_ref)
.ok()
.as_deref()
.and_then(Object::as_array)
.cloned()
.unwrap_or_default();
array.push(Object::Ref(annot_ref));
dest.replace(array_ref, Object::Array(array));
}
Some(Object::Array(mut array)) => {
array.push(Object::Ref(annot_ref));
page_dict.insert(names::ANNOTS.clone(), Object::Array(array));
dest.replace(page_ref, Object::Dict(page_dict.clone()));
}
_ => {
page_dict.insert(
names::ANNOTS.clone(),
Object::Array(Array::of([Object::Ref(annot_ref)])),
);
dest.replace(page_ref, Object::Dict(page_dict.clone()));
}
}
}
fn text_string(text: &str) -> PdfString {
PdfString::literal(encode_text(text))
}
#[expect(
clippy::cast_possible_truncation,
reason = "PDF reals are f32; page-space points fit"
)]
fn rect_object(rect: Rect) -> Object {
let rect = rect.abs();
Object::Array(Array::of([
Object::Real(rect.x0 as f32),
Object::Real(rect.y0 as f32),
Object::Real(rect.x1 as f32),
Object::Real(rect.y1 as f32),
]))
}