Skip to main content

pdfrum_edit/
form_field.rs

1//! Creating interactive form fields (ISO 32000-1 §12.7.4).
2//!
3//! The rest of the write side fills fields that a file already declares. This
4//! module makes the fields: it writes the widget annotation a reader draws,
5//! the field dictionary that gives it a name and a type, the `/AcroForm`
6//! entry that puts it in the document's field tree, and the `/DR` resources
7//! the appearance generator needs to lay a value out.
8//!
9//! A field and its widget are **one object** whenever the field has a single
10//! control, which is what ISO 32000-1 §12.7.3.1 permits and what nearly every
11//! producer writes; a radio group is the exception, and gets a parent field
12//! with one widget kid per button.
13//!
14//! What this does **not** do is draw. The appearance follows from the keys
15//! written here — `/MK` for the chrome, `/DA` and `/DR` for the body — and is
16//! generated by the same code a fill goes through, so a created field looks
17//! the same as one that was filled.
18
19use kurbo::Rect;
20use pdfrum_common::PageIndex;
21use pdfrum_object::{Array, Dict, Name, ObjRef, Object, PdfString, Resolve, encode_text, names};
22
23use crate::{WidgetAppearance, doc::EditDoc, error::Error, font::embed::EmbeddedFont, form};
24
25/// A field write either applies or names why it could not.
26type Result<T> = core::result::Result<T, Error>;
27
28/// The default `/DA` a created field takes when the caller names none:
29/// black Helvetica, auto-sized.
30///
31/// The `0` size is the auto-size sentinel of ISO 32000-1 §12.7.3.3 — the
32/// generator picks a size that fits the widget's rectangle rather than
33/// clipping a fixed one.
34pub const DEFAULT_FIELD_DA: &str = "0 g /Helv 0 Tf";
35
36/// The resource name a created field's `/DR` gives its default face.
37///
38/// `Helv` is the spelling Acrobat writes and every reader recognises; the
39/// `/DA` above names it, so the two have to agree.
40const DEFAULT_FONT_RESOURCE: &str = "Helv";
41
42/// Which kind of control a created field is.
43///
44/// Scoped to the three kinds a document author reaches for first. A combo
45/// box, list box or signature field is a larger surface — `/Opt` ordering and
46/// selection semantics for the first two, a byte-range placeholder and a
47/// certificate for the third — and is deliberately not offered here rather
48/// than offered half-built.
49#[derive(Debug, Clone, PartialEq)]
50#[non_exhaustive]
51pub enum FieldKindSpec {
52    /// A single-line text field (`/Tx`).
53    Text,
54    /// A check box (`/Btn` with neither the push-button nor the radio bit).
55    ///
56    /// The string is the name of the **on** state — what `/V` and `/AS` hold
57    /// when the box is ticked, and the key the on appearance is filed under.
58    /// `Yes` is the conventional choice; the off state is always `Off`.
59    Check {
60        /// The on-state name.
61        on: String,
62    },
63    /// A radio group (`/Btn` with bit 16 set).
64    ///
65    /// One button per entry: each is a widget on its own rectangle, and the
66    /// string is that button's export value — the name `/V` takes when that
67    /// button is the one chosen.
68    Radio {
69        /// Each button's rectangle and export value, in order.
70        buttons: Vec<(Rect, String)>,
71    },
72}
73
74impl FieldKindSpec {
75    /// A check box whose on-state is the conventional `Yes`.
76    #[must_use]
77    pub fn check() -> Self {
78        Self::Check {
79            on: "Yes".to_owned(),
80        }
81    }
82
83    /// The `/FT` this kind writes.
84    fn field_type(&self) -> &'static Name {
85        match self {
86            Self::Text => crate::names::TX,
87            Self::Check { .. } | Self::Radio { .. } => crate::names::BTN,
88        }
89    }
90
91    /// The `/Ff` bits this kind implies, before the caller's own.
92    fn implied_flags(&self) -> i64 {
93        match self {
94            Self::Text | Self::Check { .. } => 0,
95            // Bit 16 (`Radio`), and bit 15 (`NoToggleToOff`) so that clicking
96            // the chosen button does not clear the group — the behaviour every
97            // reader expects of a radio group, and what Acrobat writes.
98            Self::Radio { .. } => (1 << 15) | (1 << 14),
99        }
100    }
101}
102
103/// A form field to create.
104///
105/// The name is the field's identity: ISO 32000-1 §12.7.3.2 makes the fully
106/// qualified name what an action, an export or a script addresses a field by,
107/// and [`Form::field`](pdfrum_doc::form::Form::field) is the only lookup the
108/// reader offers. So a name that is already taken is refused rather than
109/// silently merged into the existing field — see [`Error::DuplicateFieldName`].
110///
111/// ```
112/// use kurbo::Rect;
113/// use pdfrum_edit::{FieldKindSpec, FieldSpec};
114///
115/// let spec = FieldSpec::new("email", Rect::new(72.0, 700.0, 300.0, 720.0), FieldKindSpec::Text)
116///     .max_len(64)
117///     .tooltip("Your email address")
118///     .required(true);
119/// assert_eq!(spec.name(), "email");
120/// ```
121#[derive(Debug, Clone, PartialEq)]
122pub struct FieldSpec {
123    name: String,
124    rect: Rect,
125    kind: FieldKindSpec,
126    value: Option<String>,
127    default_value: Option<String>,
128    tooltip: Option<String>,
129    da: Option<String>,
130    max_len: Option<i64>,
131    extra_flags: i64,
132    appearance: Option<WidgetAppearance>,
133}
134
135impl FieldSpec {
136    /// A field of `kind` named `name`, drawn in `rect` in default user space.
137    ///
138    /// For a [`FieldKindSpec::Radio`] group `rect` is the rectangle the *field*
139    /// reports; each button carries its own, so pass the group's bounding box
140    /// or any rectangle — the buttons are what a reader draws.
141    #[must_use]
142    pub fn new(name: impl Into<String>, rect: Rect, kind: FieldKindSpec) -> Self {
143        Self {
144            name: name.into(),
145            rect,
146            kind,
147            value: None,
148            default_value: None,
149            tooltip: None,
150            da: None,
151            max_len: None,
152            extra_flags: 0,
153            appearance: None,
154        }
155    }
156
157    /// The name this field will be addressed by.
158    #[must_use]
159    pub fn name(&self) -> &str {
160        &self.name
161    }
162
163    /// `/V`: the field's initial value.
164    ///
165    /// For a text field this is the text. For a check box or a radio group it
166    /// is a **state name** — the on-state of the box, or the export value of
167    /// the button to select — and anything else leaves the control clear,
168    /// exactly as `Off` would.
169    #[must_use]
170    pub fn value(mut self, value: impl Into<String>) -> Self {
171        self.value = Some(value.into());
172        self
173    }
174
175    /// `/DV`: the value a reset action restores.
176    ///
177    /// Defaults to [`Self::value`] when that is set, since a field whose
178    /// initial value resets to empty is rarely what an author means.
179    #[must_use]
180    pub fn default_value(mut self, value: impl Into<String>) -> Self {
181        self.default_value = Some(value.into());
182        self
183    }
184
185    /// `/TU`: the tooltip a reader shows, and the accessible name a screen
186    /// reader announces.
187    #[must_use]
188    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
189        self.tooltip = Some(text.into());
190        self
191    }
192
193    /// `/DA`: the appearance string the value is laid out with.
194    ///
195    /// Defaults to [`DEFAULT_FIELD_DA`]. A `/DA` naming a face that is not in
196    /// the form's `/DR` falls back to a substituted one at draw time rather
197    /// than failing, so a caller who embeds a face should name it here and
198    /// register it with [`add_form_font`].
199    #[must_use]
200    pub fn da(mut self, da: impl Into<String>) -> Self {
201        self.da = Some(da.into());
202        self
203    }
204
205    /// `/MaxLen`: the longest value the field accepts, in characters.
206    ///
207    /// Text fields only; ignored on a button. Setting it is also what turns
208    /// the comb flag (bit 25) into evenly spaced cells, which is why the two
209    /// are usually written together.
210    #[must_use]
211    pub fn max_len(mut self, max: i64) -> Self {
212        self.max_len = Some(max);
213        self
214    }
215
216    /// `/Ff` bit 1: the field may not be changed.
217    #[must_use]
218    pub fn read_only(self, yes: bool) -> Self {
219        self.flag(1 << 0, yes)
220    }
221
222    /// `/Ff` bit 2: the field must have a value when the form is submitted.
223    #[must_use]
224    pub fn required(self, yes: bool) -> Self {
225        self.flag(1 << 1, yes)
226    }
227
228    /// `/Ff` bit 13: the text field accepts more than one line.
229    ///
230    /// Text fields only.
231    #[must_use]
232    pub fn multiline(self, yes: bool) -> Self {
233        self.flag(1 << 12, yes)
234    }
235
236    /// `/Ff` bit 14: the text field masks what is typed.
237    ///
238    /// Text fields only.
239    #[must_use]
240    pub fn password(self, yes: bool) -> Self {
241        self.flag(1 << 13, yes)
242    }
243
244    /// `/Ff` bit 25: the text field is divided into [`Self::max_len`] evenly
245    /// spaced cells.
246    ///
247    /// The comb flag has no meaning without a `/MaxLen`, and the reader's
248    /// layout ignores it when there is none.
249    #[must_use]
250    pub fn comb(self, yes: bool) -> Self {
251        self.flag(1 << 24, yes)
252    }
253
254    /// Any further `/Ff` bits, as
255    /// [`FieldFlags::bits`](pdfrum_doc::form::FieldFlags::bits) reports them.
256    ///
257    /// The named setters above cover the bits a caller reaches for; this is
258    /// the escape hatch for the rest, and for round-tripping a flag word read
259    /// off an existing field.
260    #[must_use]
261    pub fn flags(mut self, bits: i64) -> Self {
262        self.extra_flags |= bits;
263        self
264    }
265
266    /// `/MK`: the background, border and caption the widget is drawn with.
267    ///
268    /// A field with no `/MK` has no background and no visible border — the
269    /// chrome generator paints only what `/MK` names — so a field meant to
270    /// look like a box wants at least a [`WidgetAppearance::border`].
271    #[must_use]
272    pub fn appearance(mut self, appearance: WidgetAppearance) -> Self {
273        self.appearance = Some(appearance);
274        self
275    }
276
277    fn flag(mut self, bit: i64, yes: bool) -> Self {
278        if yes {
279            self.extra_flags |= bit;
280        } else {
281            self.extra_flags &= !bit;
282        }
283        self
284    }
285
286    /// The whole `/Ff` word.
287    fn flag_word(&self) -> i64 {
288        self.kind.implied_flags() | self.extra_flags
289    }
290}
291
292/// Creates a form field and its widget, and returns the field's reference.
293///
294/// The field is added to the catalog's `/AcroForm /Fields` and its widget (or,
295/// for a radio group, each of them) to `page`'s `/Annots`, so it is both
296/// addressable and drawn. The document gains an `/AcroForm` if it had none,
297/// and that form gains a `/DR` naming Helvetica if it had none, because a
298/// field whose `/DA` names a face the form does not carry lays its value out
299/// in a substituted one.
300///
301/// The appearance is generated here, from the same generators a fill uses, so
302/// the field renders without the reader having to rebuild it.
303///
304/// # Errors
305///
306/// - [`Error::NoDestinationCatalog`] when the document has no catalog.
307/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page.
308/// - [`Error::DuplicateFieldName`] when the document already has a field of
309///   that name.
310/// - [`Error::EmptyFieldName`] when the name is empty — the reader drops such
311///   a field, so writing one would silently lose it.
312/// - [`Error::EmptyRadioGroup`] for a radio group with no buttons.
313///
314/// ```
315/// use std::sync::Arc;
316/// use kurbo::Rect;
317/// use pdfrum_edit::{
318///     EditDoc, FieldKindSpec, FieldSpec, SaveOptions, add_form_field, save,
319/// };
320/// use pdfrum_parser::{LoadOptions, load};
321///
322/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
323/// let doc = load(bytes, &LoadOptions::default())?;
324/// let mut edit = EditDoc::new(&doc);
325///
326/// add_form_field(
327///     &mut edit,
328///     0u32,
329///     &FieldSpec::new("name", Rect::new(72.0, 700.0, 300.0, 720.0), FieldKindSpec::Text)
330///         .value("Ada"),
331/// )?;
332///
333/// let mut out = Vec::new();
334/// save(&edit, &SaveOptions::default(), &mut out)?;
335/// # Ok::<(), Box<dyn std::error::Error>>(())
336/// ```
337pub fn add_form_field(
338    dest: &mut EditDoc<'_>,
339    page: impl Into<PageIndex>,
340    spec: &FieldSpec,
341) -> Result<ObjRef> {
342    if spec.name.is_empty() {
343        return Err(Error::EmptyFieldName);
344    }
345    if let FieldKindSpec::Radio { buttons } = &spec.kind
346        && buttons.is_empty()
347    {
348        return Err(Error::EmptyRadioGroup);
349    }
350
351    let page = page.into();
352    let Some((page_ref, mut page_dict, _)) = dest.page_state(page)? else {
353        return Err(Error::InlinePage(page));
354    };
355
356    if field_names(dest).iter().any(|name| name == &spec.name) {
357        return Err(Error::DuplicateFieldName(spec.name.clone()));
358    }
359
360    // The form has to exist, and carry a face, *before* the field is written:
361    // the appearance generator reads `/DR /Font` off the catalog, so a field
362    // laid out against a form that does not yet name Helvetica would fall back
363    // to a substituted face and measure differently from the same field
364    // written second.
365    ensure_default_resources(dest)?;
366
367    let field_ref = match &spec.kind {
368        FieldKindSpec::Radio { buttons } => {
369            write_radio_group(dest, page_ref, &mut page_dict, spec, buttons)
370        }
371        _ => write_single_widget_field(dest, page_ref, &mut page_dict, spec),
372    };
373
374    register_field(dest, field_ref)?;
375    Ok(field_ref)
376}
377
378/// Registers a face in the form's `/DR /Font` under `name`, so a `/DA` may
379/// name it.
380///
381/// The face itself comes from [`EditDoc::embed_font`](crate::EditDoc) or
382/// [`EditDoc::standard_font`](crate::EditDoc); this only files it where the
383/// form's layout looks. A name already in use is replaced, which is how a
384/// caller swaps the face every field's default `/DA` resolves to.
385///
386/// # Errors
387///
388/// [`Error::NoDestinationCatalog`] when the document has no catalog.
389///
390/// ```
391/// use std::sync::Arc;
392/// use pdfrum_edit::{EditDoc, StandardFont, add_form_font};
393/// use pdfrum_parser::{LoadOptions, load};
394///
395/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
396/// let doc = load(bytes, &LoadOptions::default())?;
397/// let mut edit = EditDoc::new(&doc);
398///
399/// let face = edit.standard_font(StandardFont::TimesBold)?;
400/// add_form_font(&mut edit, "TiBo", &face)?;
401/// # Ok::<(), Box<dyn std::error::Error>>(())
402/// ```
403pub fn add_form_font(
404    dest: &mut EditDoc<'_>,
405    name: impl AsRef<str>,
406    font: &EmbeddedFont,
407) -> Result<()> {
408    let resource = Name::from(name.as_ref().as_bytes());
409    let font_ref = font.object();
410    edit_form(dest, |dest, form| {
411        let mut resources = sub_dict(dest, form, crate::names::DR);
412        let mut fonts = sub_dict(dest, &resources, names::FONT);
413        fonts.insert(resource, Object::Ref(font_ref));
414        resources.insert(names::FONT.clone(), Object::Dict(fonts));
415        form.insert(crate::names::DR.clone(), Object::Dict(resources));
416    })
417}
418
419/// Every field name the document already uses, fully qualified.
420///
421/// Read through the edits, so a field added earlier in this session counts.
422fn field_names(dest: &EditDoc<'_>) -> Vec<String> {
423    let Some(catalog) = edited_catalog(dest) else {
424        return Vec::new();
425    };
426    let (limits, mut diags) = (
427        pdfrum_common::Limits::default(),
428        pdfrum_common::Diagnostics::default(),
429    );
430    pdfrum_doc::form::Form::load(&catalog, dest, &limits, &mut diags)
431        .map(|form| form.fields.into_iter().map(|field| field.name).collect())
432        .unwrap_or_default()
433}
434
435/// The catalog **as the edits leave it**.
436///
437/// [`EditDoc::base`] would answer with the file's own, which is wrong here for
438/// the same reason it is wrong for a page: a form created by an earlier call
439/// in this session lives only in the overlay, and a duplicate-name check or a
440/// `/DR` lookup that missed it would write a second `/AcroForm` over the
441/// first.
442pub(crate) fn edited_catalog(dest: &EditDoc<'_>) -> Option<Dict> {
443    let root = dest.base().trailer().reference(names::ROOT)?;
444    dest.fetch(root)
445        .ok()
446        .and_then(|object| object.as_dict().cloned())
447}
448
449/// A sub-dictionary as the edits leave it, or an empty one.
450fn sub_dict(dest: &EditDoc<'_>, parent: &Dict, key: &Name) -> Dict {
451    match parent.raw(key) {
452        Some(Object::Ref(reference)) => dest
453            .fetch(*reference)
454            .ok()
455            .and_then(|object| object.as_dict().cloned())
456            .unwrap_or_default(),
457        Some(Object::Dict(dict)) => dict.clone(),
458        _ => Dict::new(),
459    }
460}
461
462/// Gives the form a `/DR /Font /Helv` when it has no default face.
463///
464/// Only when it has none: a form that already names faces keeps them, and a
465/// `Helv` already there is whatever the file chose it to mean.
466fn ensure_default_resources(dest: &mut EditDoc<'_>) -> Result<()> {
467    let has_helv = edited_catalog(dest)
468        .and_then(|catalog| catalog.dict(names::ACRO_FORM, dest))
469        .and_then(|form| form.dict(crate::names::DR, dest))
470        .and_then(|resources| resources.dict(names::FONT, dest))
471        .is_some_and(|fonts| fonts.contains_key(&Name::from(DEFAULT_FONT_RESOURCE)));
472    if has_helv {
473        return Ok(());
474    }
475    let face = dest.standard_font(crate::StandardFont::Helvetica)?;
476    add_form_font(dest, DEFAULT_FONT_RESOURCE, &face)
477}
478
479/// Writes a field whose dictionary **is** its widget.
480///
481/// One object for both is what ISO 32000-1 §12.7.3.1 allows for a field with a
482/// single control, and it is what the reader's `widgets_of` expects to find:
483/// merging them keeps the value edit and the appearance edit on the same
484/// object, which is the case `form::apply` is already written around.
485fn write_single_widget_field(
486    dest: &mut EditDoc<'_>,
487    page_ref: ObjRef,
488    page_dict: &mut Dict,
489    spec: &FieldSpec,
490) -> ObjRef {
491    let mut dict = field_dict(spec);
492    widget_keys(&mut dict, spec.rect, page_ref, spec.appearance.as_ref());
493
494    if let FieldKindSpec::Check { on } = &spec.kind {
495        let state = state_name(spec.value.as_deref(), on);
496        dict.insert(names::AS.clone(), Object::Name(state.clone()));
497        dict.insert(names::V.clone(), Object::Name(state));
498    }
499
500    let field_ref = dest.add(Object::Dict(Dict::new()));
501    let dict = with_appearance(dest, dict, &appearance_states(&spec.kind));
502    dest.replace(field_ref, Object::Dict(dict));
503    attach_to_page(dest, page_ref, page_dict, field_ref);
504    field_ref
505}
506
507/// Writes a radio group: a parent field carrying the name and the value, and
508/// one widget kid per button.
509///
510/// The parent has no `/Rect` and no `/Subtype` — it is naming structure, not a
511/// control — which is exactly the shape the reader's `visit` treats as
512/// terminal-with-widget-kids rather than as a branch of the field tree.
513fn write_radio_group(
514    dest: &mut EditDoc<'_>,
515    page_ref: ObjRef,
516    page_dict: &mut Dict,
517    spec: &FieldSpec,
518    buttons: &[(Rect, String)],
519) -> ObjRef {
520    let parent_ref = dest.add(Object::Dict(Dict::new()));
521    let mut parent = field_dict(spec);
522
523    let chosen = spec.value.as_deref();
524    let mut kids = Array::default();
525    for (rect, export) in buttons {
526        let mut kid = Dict::new();
527        widget_keys(&mut kid, *rect, page_ref, spec.appearance.as_ref());
528        kid.insert(names::PARENT.clone(), Object::Ref(parent_ref));
529        // Each button shows its own export value when it is the chosen one and
530        // `Off` otherwise; `/V` on the parent says which, and `/AS` here has to
531        // agree or the reader draws a group with every button lit.
532        let state = if chosen == Some(export.as_str()) {
533            Name::from(export.as_bytes())
534        } else {
535            crate::names::OFF.clone()
536        };
537        kid.insert(names::AS.clone(), Object::Name(state));
538
539        let kid_ref = dest.add(Object::Dict(Dict::new()));
540        let states = [crate::names::OFF.clone(), Name::from(export.as_bytes())];
541        let kid = with_appearance(dest, kid, &states);
542        dest.replace(kid_ref, Object::Dict(kid));
543        attach_to_page(dest, page_ref, page_dict, kid_ref);
544        kids.push(Object::Ref(kid_ref));
545    }
546
547    parent.insert(names::KIDS.clone(), Object::Array(kids));
548    let value = chosen
549        .filter(|chosen| buttons.iter().any(|(_, export)| export == chosen))
550        .map_or_else(|| crate::names::OFF.clone(), |v| Name::from(v.as_bytes()));
551    parent.insert(names::V.clone(), Object::Name(value));
552
553    dest.replace(parent_ref, Object::Dict(parent));
554    parent_ref
555}
556
557/// The keys every form field carries, whatever draws it.
558fn field_dict(spec: &FieldSpec) -> Dict {
559    let mut dict = Dict::new();
560    dict.insert(
561        names::FT.clone(),
562        Object::Name(spec.kind.field_type().clone()),
563    );
564    dict.insert(names::T.clone(), Object::Str(text_string(&spec.name)));
565    dict.insert(
566        names::DA.clone(),
567        Object::Str(PdfString::literal(
568            spec.da.as_deref().unwrap_or(DEFAULT_FIELD_DA).as_bytes(),
569        )),
570    );
571
572    let flags = spec.flag_word();
573    if flags != 0 {
574        dict.insert(names::FF.clone(), Object::Int(flags));
575    }
576    if let Some(tooltip) = &spec.tooltip {
577        dict.insert(names::TU.clone(), Object::Str(text_string(tooltip)));
578    }
579    if matches!(spec.kind, FieldKindSpec::Text) {
580        if let Some(max) = spec.max_len {
581            dict.insert(crate::names::MAX_LEN.clone(), Object::Int(max));
582        }
583        if let Some(value) = &spec.value {
584            dict.insert(names::V.clone(), Object::Str(text_string(value)));
585        }
586        let default = spec.default_value.as_ref().or(spec.value.as_ref());
587        if let Some(default) = default {
588            dict.insert(crate::names::DV.clone(), Object::Str(text_string(default)));
589        }
590    } else if let Some(default) = &spec.default_value {
591        dict.insert(
592            crate::names::DV.clone(),
593            Object::Name(Name::from(default.as_bytes())),
594        );
595    }
596    dict
597}
598
599/// The keys that make a dictionary a widget annotation on `page_ref`.
600fn widget_keys(
601    dict: &mut Dict,
602    rect: Rect,
603    page_ref: ObjRef,
604    appearance: Option<&WidgetAppearance>,
605) {
606    dict.insert(
607        names::TYPE.clone(),
608        Object::Name(crate::names::ANNOT.clone()),
609    );
610    dict.insert(
611        names::SUBTYPE.clone(),
612        Object::Name(crate::names::WIDGET.clone()),
613    );
614    dict.insert(names::RECT.clone(), rect_object(rect));
615    dict.insert(names::P.clone(), Object::Ref(page_ref));
616    // Bit 3, `Print`: a field that does not print is a field that vanishes
617    // from every paper copy of the form, which is never what an author means.
618    dict.insert(names::F.clone(), Object::Int(1 << 2));
619    if let Some(appearance) = appearance {
620        dict.insert(form::mk_key(), Object::Dict(appearance.to_dict()));
621    }
622}
623
624/// The state names a kind's `/AP /N` needs a stream under.
625///
626/// A toggle has one appearance per state and a reader picks between them with
627/// `/AS`. Everything else takes the empty slice, which asks for the single
628/// unnamed stream: a text field has one face, and a radio group's parent draws
629/// nothing at all — its kids each carry their own two states.
630fn appearance_states(kind: &FieldKindSpec) -> Vec<Name> {
631    match kind {
632        FieldKindSpec::Check { on } => {
633            vec![crate::names::OFF.clone(), Name::from(on.as_bytes())]
634        }
635        FieldKindSpec::Text | FieldKindSpec::Radio { .. } => Vec::new(),
636    }
637}
638
639/// Which state name a check box starts in.
640fn state_name(value: Option<&str>, on: &str) -> Name {
641    match value {
642        Some(value) if value == on => Name::from(on.as_bytes()),
643        _ => crate::names::OFF.clone(),
644    }
645}
646
647/// Generates `/AP /N` for a widget and returns the dictionary carrying it.
648///
649/// `states` names the sub-dictionary keys a toggle needs; an empty slice means
650/// the single unnamed stream a text field takes. Each state is generated with
651/// `/AS` set to it, because the chrome generator draws a glyph only for the on
652/// state — generating once and filing the same stream under both keys would
653/// give a checkbox a tick it could not clear.
654fn with_appearance(dest: &mut EditDoc<'_>, dict: Dict, states: &[Name]) -> Dict {
655    if states.is_empty() {
656        let mut dict = dict;
657        if let Some(stream) = generate_stream(dest, &dict) {
658            let mut ap = Dict::new();
659            ap.insert(names::N.clone(), Object::Ref(stream));
660            dict.insert(names::AP.clone(), Object::Dict(ap));
661        }
662        return dict;
663    }
664
665    let mut normal = Dict::new();
666    for state in states {
667        let mut probe = dict.clone();
668        probe.insert(names::AS.clone(), Object::Name(state.clone()));
669        if let Some(stream) = generate_stream(dest, &probe) {
670            normal.insert(state.clone(), Object::Ref(stream));
671        }
672    }
673    let mut dict = dict;
674    if !normal.is_empty() {
675        let mut ap = Dict::new();
676        ap.insert(names::N.clone(), Object::Dict(normal));
677        dict.insert(names::AP.clone(), Object::Dict(ap));
678    }
679    dict
680}
681
682/// Runs the widget generators over one dictionary and adds the stream it drew.
683///
684/// The generators walk a page's `/Annots`, so the dictionary travels as a
685/// one-annotation synthetic page — the same trick `add_annotation` uses, and
686/// for the same reason: there is one dispatch from a subtype to its generator
687/// and it lives behind that walk.
688fn generate_stream(dest: &mut EditDoc<'_>, dict: &Dict) -> Option<ObjRef> {
689    let catalog = edited_catalog(dest).unwrap_or_default();
690    let mut build = pdfrum_page::BuildContext::new();
691    let fonts = pdfrum_doc::ap::FormFonts::load(&catalog, dest, &mut build);
692    let mut diags = pdfrum_common::Diagnostics::default();
693    let page = Dict::from_pairs([(
694        names::ANNOTS.clone(),
695        Object::Array(Array::of([Object::Dict(dict.clone())])),
696    )]);
697    let overlay = pdfrum_doc::ap::generate_appearances_with_text(
698        &page,
699        &catalog,
700        Some(&fonts),
701        dest,
702        &mut diags,
703    );
704    let generated = overlay.get(0)?;
705    if generated.stream.is_empty() {
706        return None;
707    }
708    let stream = pdfrum_object::Stream::new(
709        pdfrum_doc::ap::stream_dict(generated),
710        pdfrum_object::ByteSpan::from(generated.stream.clone()),
711    );
712    Some(dest.add(Object::Stream(Box::new(stream))))
713}
714
715/// Adds `field_ref` to the catalog's `/AcroForm /Fields`.
716fn register_field(dest: &mut EditDoc<'_>, field_ref: ObjRef) -> Result<()> {
717    edit_form(dest, |dest, form| {
718        let mut fields = match form.raw(crate::names::FIELDS) {
719            Some(Object::Ref(reference)) => dest
720                .fetch(*reference)
721                .ok()
722                .as_deref()
723                .and_then(Object::as_array)
724                .cloned()
725                .unwrap_or_default(),
726            Some(Object::Array(array)) => array.clone(),
727            _ => Array::default(),
728        };
729        fields.push(Object::Ref(field_ref));
730        form.insert(crate::names::FIELDS.clone(), Object::Array(fields));
731    })
732}
733
734/// Fetches the `/AcroForm` as the edits leave it, changes it, writes it back.
735///
736/// An indirect form is edited **in place** so that every reference to it — the
737/// field tree's most of all — still names the dictionary that now carries the
738/// change.
739fn edit_form(
740    dest: &mut EditDoc<'_>,
741    change: impl FnOnce(&mut EditDoc<'_>, &mut Dict),
742) -> Result<()> {
743    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
744        return Err(Error::NoDestinationCatalog);
745    };
746    let Some(mut catalog) = edited_catalog(dest) else {
747        return Err(Error::NoDestinationCatalog);
748    };
749
750    match catalog.raw(names::ACRO_FORM).cloned() {
751        Some(Object::Ref(form_ref)) => {
752            let mut form = dest
753                .fetch(form_ref)
754                .ok()
755                .and_then(|object| object.as_dict().cloned())
756                .unwrap_or_default();
757            change(dest, &mut form);
758            dest.replace(form_ref, Object::Dict(form));
759        }
760        existing => {
761            let mut form = match existing {
762                Some(Object::Dict(form)) => form,
763                _ => Dict::new(),
764            };
765            change(dest, &mut form);
766            catalog.insert(names::ACRO_FORM.clone(), Object::Dict(form));
767            dest.replace(root, Object::Dict(catalog));
768        }
769    }
770    Ok(())
771}
772
773/// Appends a widget to a page's `/Annots`.
774fn attach_to_page(
775    dest: &mut EditDoc<'_>,
776    page_ref: ObjRef,
777    page_dict: &mut Dict,
778    annot_ref: ObjRef,
779) {
780    match page_dict.raw(names::ANNOTS).cloned() {
781        Some(Object::Ref(array_ref)) => {
782            let mut array = dest
783                .fetch(array_ref)
784                .ok()
785                .as_deref()
786                .and_then(Object::as_array)
787                .cloned()
788                .unwrap_or_default();
789            array.push(Object::Ref(annot_ref));
790            dest.replace(array_ref, Object::Array(array));
791        }
792        Some(Object::Array(mut array)) => {
793            array.push(Object::Ref(annot_ref));
794            page_dict.insert(names::ANNOTS.clone(), Object::Array(array));
795            dest.replace(page_ref, Object::Dict(page_dict.clone()));
796        }
797        _ => {
798            page_dict.insert(
799                names::ANNOTS.clone(),
800                Object::Array(Array::of([Object::Ref(annot_ref)])),
801            );
802            dest.replace(page_ref, Object::Dict(page_dict.clone()));
803        }
804    }
805}
806
807fn text_string(text: &str) -> PdfString {
808    PdfString::literal(encode_text(text))
809}
810
811#[expect(
812    clippy::cast_possible_truncation,
813    reason = "PDF reals are f32; page-space points fit"
814)]
815fn rect_object(rect: Rect) -> Object {
816    let rect = rect.abs();
817    Object::Array(Array::of([
818        Object::Real(rect.x0 as f32),
819        Object::Real(rect.y0 as f32),
820        Object::Real(rect.x1 as f32),
821        Object::Real(rect.y1 as f32),
822    ]))
823}