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}