pdfrum-edit 0.5.0

PDF serializer: full/incremental save, page import, subsetting
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
//! Creating interactive form fields (ISO 32000-1 §12.7.4).
//!
//! The rest of the write side fills fields that a file already declares. This
//! module makes the fields: it writes the widget annotation a reader draws,
//! the field dictionary that gives it a name and a type, the `/AcroForm`
//! entry that puts it in the document's field tree, and the `/DR` resources
//! the appearance generator needs to lay a value out.
//!
//! A field and its widget are **one object** whenever the field has a single
//! control, which is what ISO 32000-1 §12.7.3.1 permits and what nearly every
//! producer writes; a radio group is the exception, and gets a parent field
//! with one widget kid per button.
//!
//! What this does **not** do is draw. The appearance follows from the keys
//! written here — `/MK` for the chrome, `/DA` and `/DR` for the body — and is
//! generated by the same code a fill goes through, so a created field looks
//! the same as one that was filled.

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};

/// A field write either applies or names why it could not.
type Result<T> = core::result::Result<T, Error>;

/// The default `/DA` a created field takes when the caller names none:
/// black Helvetica, auto-sized.
///
/// The `0` size is the auto-size sentinel of ISO 32000-1 §12.7.3.3 — the
/// generator picks a size that fits the widget's rectangle rather than
/// clipping a fixed one.
pub const DEFAULT_FIELD_DA: &str = "0 g /Helv 0 Tf";

/// The resource name a created field's `/DR` gives its default face.
///
/// `Helv` is the spelling Acrobat writes and every reader recognises; the
/// `/DA` above names it, so the two have to agree.
const DEFAULT_FONT_RESOURCE: &str = "Helv";

/// Which kind of control a created field is.
///
/// Scoped to the three kinds a document author reaches for first. A combo
/// box, list box or signature field is a larger surface — `/Opt` ordering and
/// selection semantics for the first two, a byte-range placeholder and a
/// certificate for the third — and is deliberately not offered here rather
/// than offered half-built.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum FieldKindSpec {
    /// A single-line text field (`/Tx`).
    Text,
    /// A check box (`/Btn` with neither the push-button nor the radio bit).
    ///
    /// The string is the name of the **on** state — what `/V` and `/AS` hold
    /// when the box is ticked, and the key the on appearance is filed under.
    /// `Yes` is the conventional choice; the off state is always `Off`.
    Check {
        /// The on-state name.
        on: String,
    },
    /// A radio group (`/Btn` with bit 16 set).
    ///
    /// One button per entry: each is a widget on its own rectangle, and the
    /// string is that button's export value — the name `/V` takes when that
    /// button is the one chosen.
    Radio {
        /// Each button's rectangle and export value, in order.
        buttons: Vec<(Rect, String)>,
    },
}

impl FieldKindSpec {
    /// A check box whose on-state is the conventional `Yes`.
    #[must_use]
    pub fn check() -> Self {
        Self::Check {
            on: "Yes".to_owned(),
        }
    }

    /// The `/FT` this kind writes.
    fn field_type(&self) -> &'static Name {
        match self {
            Self::Text => crate::names::TX,
            Self::Check { .. } | Self::Radio { .. } => crate::names::BTN,
        }
    }

    /// The `/Ff` bits this kind implies, before the caller's own.
    fn implied_flags(&self) -> i64 {
        match self {
            Self::Text | Self::Check { .. } => 0,
            // Bit 16 (`Radio`), and bit 15 (`NoToggleToOff`) so that clicking
            // the chosen button does not clear the group — the behaviour every
            // reader expects of a radio group, and what Acrobat writes.
            Self::Radio { .. } => (1 << 15) | (1 << 14),
        }
    }
}

/// A form field to create.
///
/// The name is the field's identity: ISO 32000-1 §12.7.3.2 makes the fully
/// qualified name what an action, an export or a script addresses a field by,
/// and [`Form::field`](pdfrum_doc::form::Form::field) is the only lookup the
/// reader offers. So a name that is already taken is refused rather than
/// silently merged into the existing field — see [`Error::DuplicateFieldName`].
///
/// ```
/// use kurbo::Rect;
/// use pdfrum_edit::{FieldKindSpec, FieldSpec};
///
/// let spec = FieldSpec::new("email", Rect::new(72.0, 700.0, 300.0, 720.0), FieldKindSpec::Text)
///     .max_len(64)
///     .tooltip("Your email address")
///     .required(true);
/// assert_eq!(spec.name(), "email");
/// ```
#[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 {
    /// A field of `kind` named `name`, drawn in `rect` in default user space.
    ///
    /// For a [`FieldKindSpec::Radio`] group `rect` is the rectangle the *field*
    /// reports; each button carries its own, so pass the group's bounding box
    /// or any rectangle — the buttons are what a reader draws.
    #[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,
        }
    }

    /// The name this field will be addressed by.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// `/V`: the field's initial value.
    ///
    /// For a text field this is the text. For a check box or a radio group it
    /// is a **state name** — the on-state of the box, or the export value of
    /// the button to select — and anything else leaves the control clear,
    /// exactly as `Off` would.
    #[must_use]
    pub fn value(mut self, value: impl Into<String>) -> Self {
        self.value = Some(value.into());
        self
    }

    /// `/DV`: the value a reset action restores.
    ///
    /// Defaults to [`Self::value`] when that is set, since a field whose
    /// initial value resets to empty is rarely what an author means.
    #[must_use]
    pub fn default_value(mut self, value: impl Into<String>) -> Self {
        self.default_value = Some(value.into());
        self
    }

    /// `/TU`: the tooltip a reader shows, and the accessible name a screen
    /// reader announces.
    #[must_use]
    pub fn tooltip(mut self, text: impl Into<String>) -> Self {
        self.tooltip = Some(text.into());
        self
    }

    /// `/DA`: the appearance string the value is laid out with.
    ///
    /// Defaults to [`DEFAULT_FIELD_DA`]. A `/DA` naming a face that is not in
    /// the form's `/DR` falls back to a substituted one at draw time rather
    /// than failing, so a caller who embeds a face should name it here and
    /// register it with [`add_form_font`].
    #[must_use]
    pub fn da(mut self, da: impl Into<String>) -> Self {
        self.da = Some(da.into());
        self
    }

    /// `/MaxLen`: the longest value the field accepts, in characters.
    ///
    /// Text fields only; ignored on a button. Setting it is also what turns
    /// the comb flag (bit 25) into evenly spaced cells, which is why the two
    /// are usually written together.
    #[must_use]
    pub fn max_len(mut self, max: i64) -> Self {
        self.max_len = Some(max);
        self
    }

    /// `/Ff` bit 1: the field may not be changed.
    #[must_use]
    pub fn read_only(self, yes: bool) -> Self {
        self.flag(1 << 0, yes)
    }

    /// `/Ff` bit 2: the field must have a value when the form is submitted.
    #[must_use]
    pub fn required(self, yes: bool) -> Self {
        self.flag(1 << 1, yes)
    }

    /// `/Ff` bit 13: the text field accepts more than one line.
    ///
    /// Text fields only.
    #[must_use]
    pub fn multiline(self, yes: bool) -> Self {
        self.flag(1 << 12, yes)
    }

    /// `/Ff` bit 14: the text field masks what is typed.
    ///
    /// Text fields only.
    #[must_use]
    pub fn password(self, yes: bool) -> Self {
        self.flag(1 << 13, yes)
    }

    /// `/Ff` bit 25: the text field is divided into [`Self::max_len`] evenly
    /// spaced cells.
    ///
    /// The comb flag has no meaning without a `/MaxLen`, and the reader's
    /// layout ignores it when there is none.
    #[must_use]
    pub fn comb(self, yes: bool) -> Self {
        self.flag(1 << 24, yes)
    }

    /// Any further `/Ff` bits, as
    /// [`FieldFlags::bits`](pdfrum_doc::form::FieldFlags::bits) reports them.
    ///
    /// The named setters above cover the bits a caller reaches for; this is
    /// the escape hatch for the rest, and for round-tripping a flag word read
    /// off an existing field.
    #[must_use]
    pub fn flags(mut self, bits: i64) -> Self {
        self.extra_flags |= bits;
        self
    }

    /// `/MK`: the background, border and caption the widget is drawn with.
    ///
    /// A field with no `/MK` has no background and no visible border — the
    /// chrome generator paints only what `/MK` names — so a field meant to
    /// look like a box wants at least a [`WidgetAppearance::border`].
    #[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
    }

    /// The whole `/Ff` word.
    fn flag_word(&self) -> i64 {
        self.kind.implied_flags() | self.extra_flags
    }
}

/// Creates a form field and its widget, and returns the field's reference.
///
/// The field is added to the catalog's `/AcroForm /Fields` and its widget (or,
/// for a radio group, each of them) to `page`'s `/Annots`, so it is both
/// addressable and drawn. The document gains an `/AcroForm` if it had none,
/// and that form gains a `/DR` naming Helvetica if it had none, because a
/// field whose `/DA` names a face the form does not carry lays its value out
/// in a substituted one.
///
/// The appearance is generated here, from the same generators a fill uses, so
/// the field renders without the reader having to rebuild it.
///
/// # Errors
///
/// - [`Error::NoDestinationCatalog`] when the document has no catalog.
/// - [`Error::PageIndexOutOfRange`] / [`Error::InlinePage`] for a bad page.
/// - [`Error::DuplicateFieldName`] when the document already has a field of
///   that name.
/// - [`Error::EmptyFieldName`] when the name is empty — the reader drops such
///   a field, so writing one would silently lose it.
/// - [`Error::EmptyRadioGroup`] for a radio group with no buttons.
///
/// ```
/// use std::sync::Arc;
/// use kurbo::Rect;
/// use pdfrum_edit::{
///     EditDoc, FieldKindSpec, FieldSpec, SaveOptions, add_form_field, save,
/// };
/// use pdfrum_parser::{LoadOptions, load};
///
/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
/// let doc = load(bytes, &LoadOptions::default())?;
/// let mut edit = EditDoc::new(&doc);
///
/// add_form_field(
///     &mut edit,
///     0u32,
///     &FieldSpec::new("name", Rect::new(72.0, 700.0, 300.0, 720.0), FieldKindSpec::Text)
///         .value("Ada"),
/// )?;
///
/// let mut out = Vec::new();
/// save(&edit, &SaveOptions::default(), &mut out)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
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()));
    }

    // The form has to exist, and carry a face, *before* the field is written:
    // the appearance generator reads `/DR /Font` off the catalog, so a field
    // laid out against a form that does not yet name Helvetica would fall back
    // to a substituted face and measure differently from the same field
    // written second.
    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)
}

/// Registers a face in the form's `/DR /Font` under `name`, so a `/DA` may
/// name it.
///
/// The face itself comes from [`EditDoc::embed_font`](crate::EditDoc) or
/// [`EditDoc::standard_font`](crate::EditDoc); this only files it where the
/// form's layout looks. A name already in use is replaced, which is how a
/// caller swaps the face every field's default `/DA` resolves to.
///
/// # Errors
///
/// [`Error::NoDestinationCatalog`] when the document has no catalog.
///
/// ```
/// use std::sync::Arc;
/// use pdfrum_edit::{EditDoc, StandardFont, add_form_font};
/// use pdfrum_parser::{LoadOptions, load};
///
/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
/// let doc = load(bytes, &LoadOptions::default())?;
/// let mut edit = EditDoc::new(&doc);
///
/// let face = edit.standard_font(StandardFont::TimesBold)?;
/// add_form_font(&mut edit, "TiBo", &face)?;
/// # Ok::<(), Box<dyn std::error::Error>>(())
/// ```
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));
    })
}

/// Every field name the document already uses, fully qualified.
///
/// Read through the edits, so a field added earlier in this session counts.
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()
}

/// The catalog **as the edits leave it**.
///
/// [`EditDoc::base`] would answer with the file's own, which is wrong here for
/// the same reason it is wrong for a page: a form created by an earlier call
/// in this session lives only in the overlay, and a duplicate-name check or a
/// `/DR` lookup that missed it would write a second `/AcroForm` over the
/// first.
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())
}

/// A sub-dictionary as the edits leave it, or an empty one.
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(),
    }
}

/// Gives the form a `/DR /Font /Helv` when it has no default face.
///
/// Only when it has none: a form that already names faces keeps them, and a
/// `Helv` already there is whatever the file chose it to mean.
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)
}

/// Writes a field whose dictionary **is** its widget.
///
/// One object for both is what ISO 32000-1 §12.7.3.1 allows for a field with a
/// single control, and it is what the reader's `widgets_of` expects to find:
/// merging them keeps the value edit and the appearance edit on the same
/// object, which is the case `form::apply` is already written around.
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
}

/// Writes a radio group: a parent field carrying the name and the value, and
/// one widget kid per button.
///
/// The parent has no `/Rect` and no `/Subtype` — it is naming structure, not a
/// control — which is exactly the shape the reader's `visit` treats as
/// terminal-with-widget-kids rather than as a branch of the field tree.
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));
        // Each button shows its own export value when it is the chosen one and
        // `Off` otherwise; `/V` on the parent says which, and `/AS` here has to
        // agree or the reader draws a group with every button lit.
        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
}

/// The keys every form field carries, whatever draws it.
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
}

/// The keys that make a dictionary a widget annotation on `page_ref`.
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));
    // Bit 3, `Print`: a field that does not print is a field that vanishes
    // from every paper copy of the form, which is never what an author means.
    dict.insert(names::F.clone(), Object::Int(1 << 2));
    if let Some(appearance) = appearance {
        dict.insert(form::mk_key(), Object::Dict(appearance.to_dict()));
    }
}

/// The state names a kind's `/AP /N` needs a stream under.
///
/// A toggle has one appearance per state and a reader picks between them with
/// `/AS`. Everything else takes the empty slice, which asks for the single
/// unnamed stream: a text field has one face, and a radio group's parent draws
/// nothing at all — its kids each carry their own two states.
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(),
    }
}

/// Which state name a check box starts in.
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(),
    }
}

/// Generates `/AP /N` for a widget and returns the dictionary carrying it.
///
/// `states` names the sub-dictionary keys a toggle needs; an empty slice means
/// the single unnamed stream a text field takes. Each state is generated with
/// `/AS` set to it, because the chrome generator draws a glyph only for the on
/// state — generating once and filing the same stream under both keys would
/// give a checkbox a tick it could not clear.
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
}

/// Runs the widget generators over one dictionary and adds the stream it drew.
///
/// The generators walk a page's `/Annots`, so the dictionary travels as a
/// one-annotation synthetic page — the same trick `add_annotation` uses, and
/// for the same reason: there is one dispatch from a subtype to its generator
/// and it lives behind that walk.
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))))
}

/// Adds `field_ref` to the catalog's `/AcroForm /Fields`.
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));
    })
}

/// Fetches the `/AcroForm` as the edits leave it, changes it, writes it back.
///
/// An indirect form is edited **in place** so that every reference to it — the
/// field tree's most of all — still names the dictionary that now carries the
/// change.
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(())
}

/// Appends a widget to a page's `/Annots`.
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),
    ]))
}