Skip to main content

oxideav_pdf/
acroform.rs

1//! Round-31 — AcroForm interactive-widget writer (ISO 32000-1 §12.7).
2//!
3//! Symmetric writer side of the round-26 reader's [`crate::reader::AnnotationKind::Widget`]
4//! decoder. Given a [`oxideav_scene::Scene`] in pages mode + a slice of
5//! [`FormField`] specs, emits a PDF whose Catalog carries `/AcroForm`
6//! and whose first page carries a `/Annots` array of `/Subtype /Widget`
7//! annotations bound to the fields.
8//!
9//! Field types implemented (per §12.7.4):
10//!
11//! * **Text field** — `/FT /Tx`. Optional `/V` default value, `/MaxLen`,
12//!   `/Q` justification, `/Ff` multi-line bit (bit 12 — bit index 13).
13//! * **Button** — `/FT /Btn`. Three subtypes via `/Ff`:
14//!   * Pushbutton (`/Ff` bit 16 — bit-index 17 in §12.7.4.2.1 Table 226)
15//!   * Checkbox — neither pushbutton nor radio bit
16//!   * Radio (`/Ff` bit 15 — bit-index 16 in Table 226). The whole
17//!     [`FormFieldRadioGroup`] becomes one terminal field with `/Kids`,
18//!     one widget annotation per option (the appearance state name
19//!     selects the active option).
20//! * **Choice** — `/FT /Ch`. Combo (`/Ff` bit 17 — bit-index 18) vs.
21//!   list box. `/Opt` is an array of option labels.
22//! * **Signature** — `/FT /Sig` wrapping a [`crate::sig::Signer`]. Re-uses
23//!   the round-30 `/Contents` placeholder pattern: a placeholder is
24//!   reserved at writer time, then patched after the surrounding bytes
25//!   are stable. Only one signature field per call (the byte-range
26//!   placeholder pattern assumes a single signed range).
27//!
28//! Provenance: ISO 32000-1 §12.7.2 (AcroForm dict), §12.7.3 (field
29//! dictionaries), §12.7.4.2 (button), §12.7.4.3 (text), §12.7.4.4
30//! (choice), §12.7.4.5 (signature), §12.5.6.19 Table 188 (Widget
31//! annotation). No third-party PDF source consulted.
32
33use oxideav_scene::Scene;
34
35use crate::error::PdfError;
36use crate::info::{build_info_dict, has_metadata};
37use crate::objects::{Dict, Document, Object, ObjectId};
38use crate::page::{build_pages, PageInput};
39use crate::resources::ResourceCollector;
40use crate::sig::Signer;
41use crate::writer::render_frame_for_linearize as render_frame;
42
43// ---------------------------------------------------------------------
44// Public API — field type structs + the FormField enum.
45// ---------------------------------------------------------------------
46
47/// Text justification (`/Q` in §12.7.3.3 Table 222).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum FieldJustification {
50    /// Left-justified (default).
51    #[default]
52    Left,
53    /// Centred.
54    Center,
55    /// Right-justified.
56    Right,
57}
58
59impl FieldJustification {
60    fn as_int(self) -> i64 {
61        match self {
62            Self::Left => 0,
63            Self::Center => 1,
64            Self::Right => 2,
65        }
66    }
67}
68
69/// `/FT /Tx` — single-line or multi-line text field (§12.7.4.3).
70#[derive(Debug, Clone)]
71pub struct FormFieldText {
72    /// `/T` partial field name (must be unique within the form).
73    pub name: String,
74    /// `/Rect` bounding box on the page (PDF coordinates).
75    pub rect: [f32; 4],
76    /// 0-based page index the widget lives on. Defaults to 0 (the
77    /// first page).
78    pub page_index: usize,
79    /// `/V` default value — the visible text.
80    pub value: Option<String>,
81    /// `/MaxLen` maximum number of characters. `None` = unbounded.
82    pub max_length: Option<u32>,
83    /// `/Ff` bit 12 — multi-line text input (§12.7.4.3 Table 228).
84    pub multi_line: bool,
85    /// `/Q` justification.
86    pub justification: FieldJustification,
87    /// `/DA` default appearance string. `None` ⇒
88    /// inherit AcroForm /DA `"(/Helv 12 Tf 0 g)"`.
89    pub default_appearance: Option<String>,
90}
91
92/// `/FT /Btn` checkbox (§12.7.4.2.3). The checked / unchecked appearance
93/// is keyed by `/Yes` (checked) and `/Off` (unchecked) state names per
94/// Table 228 — round-31 emits both as the rendered glyph 'X' / blank.
95#[derive(Debug, Clone)]
96pub struct FormFieldCheckbox {
97    /// `/T` partial field name.
98    pub name: String,
99    /// `/Rect` widget bounds.
100    pub rect: [f32; 4],
101    /// 0-based page index.
102    pub page_index: usize,
103    /// Initial checked state.
104    pub checked: bool,
105    /// `/DA` default appearance.
106    pub default_appearance: Option<String>,
107}
108
109/// One option in a [`FormFieldRadioGroup`] — a single physical widget
110/// annotation that participates in the group's mutual-exclusion via
111/// its `/AS` appearance state.
112#[derive(Debug, Clone)]
113pub struct RadioOption {
114    /// Distinct `/AS` appearance state name (the "on" state). When the
115    /// radio group's value equals this name, this option's `/AS` is set
116    /// to the matching name; otherwise it's `/Off`.
117    pub export_value: String,
118    /// `/Rect` widget bounds.
119    pub rect: [f32; 4],
120    /// 0-based page index.
121    pub page_index: usize,
122}
123
124/// `/FT /Btn` with `NoToggleToOff` + `Radio` flags (§12.7.4.2.2). One
125/// terminal field with `/Kids` listing every option's widget.
126#[derive(Debug, Clone)]
127pub struct FormFieldRadioGroup {
128    /// `/T` partial field name.
129    pub name: String,
130    /// The physical options.
131    pub options: Vec<RadioOption>,
132    /// `/V` currently-selected export value. `None` ⇒ no option active.
133    pub value: Option<String>,
134}
135
136/// `/FT /Ch` choice field (§12.7.4.4). Combo-box (`/Ff` bit 17) when
137/// `combo_box` is true; list box otherwise.
138#[derive(Debug, Clone)]
139pub struct FormFieldChoice {
140    /// `/T` partial field name.
141    pub name: String,
142    /// `/Rect` widget bounds.
143    pub rect: [f32; 4],
144    /// 0-based page index.
145    pub page_index: usize,
146    /// `/Opt` array — each entry is one option label.
147    pub options: Vec<String>,
148    /// `/V` currently-selected value (must appear in `options` to
149    /// round-trip cleanly).
150    pub value: Option<String>,
151    /// True ⇒ combo box; false ⇒ list box.
152    pub combo_box: bool,
153    /// `/DA` default appearance.
154    pub default_appearance: Option<String>,
155}
156
157/// `/FT /Sig` signature field — round-30 [`crate::sig::Signer`] wired
158/// into the AcroForm. Only one signature field per
159/// [`write_pdf_with_form`] call.
160pub struct FormFieldSignature {
161    /// `/T` partial field name (e.g. `"Signature1"`).
162    pub name: String,
163    /// `/Rect` widget bounds.
164    pub rect: [f32; 4],
165    /// 0-based page index.
166    pub page_index: usize,
167    /// The cryptographic signer + identity used to populate the CMS
168    /// SignedData blob.
169    pub signer: Box<dyn Signer>,
170    /// Signer identity (cert chain + IAS).
171    pub identity: crate::sig::SignerIdentity,
172}
173
174impl std::fmt::Debug for FormFieldSignature {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        f.debug_struct("FormFieldSignature")
177            .field("name", &self.name)
178            .field("rect", &self.rect)
179            .field("page_index", &self.page_index)
180            .field("signer", &"<dyn Signer>")
181            .finish()
182    }
183}
184
185/// One of the four interactive form field types defined by
186/// §12.7.4. The writer collapses these into the `/AcroForm /Fields`
187/// array + the per-page `/Annots /Subtype /Widget` annotations.
188#[allow(missing_docs)]
189pub enum FormField {
190    Text(FormFieldText),
191    Checkbox(FormFieldCheckbox),
192    RadioGroup(FormFieldRadioGroup),
193    Choice(FormFieldChoice),
194    Signature(FormFieldSignature),
195}
196
197impl std::fmt::Debug for FormField {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            Self::Text(t) => f.debug_tuple("Text").field(t).finish(),
201            Self::Checkbox(c) => f.debug_tuple("Checkbox").field(c).finish(),
202            Self::RadioGroup(r) => f.debug_tuple("RadioGroup").field(r).finish(),
203            Self::Choice(c) => f.debug_tuple("Choice").field(c).finish(),
204            Self::Signature(s) => f.debug_tuple("Signature").field(s).finish(),
205        }
206    }
207}
208
209/// Default AcroForm `/DA` per §12.7.3.3 — Helvetica 12pt black.
210const DEFAULT_DA: &str = "/Helv 12 Tf 0 g";
211
212// Same /Contents budget as the round-30 sig writer.
213const CONTENTS_HEX_LEN: usize = 8192;
214
215/// Width-stable maximum value for each `/ByteRange` slot. 8 digits =
216/// 99,999,999 — any PDF under ~100 MB fits. All four slots use this
217/// same value at placeholder time so the serialised `/ByteRange
218/// [N N N N]` array has a known byte width (4 * 8 + 3 spaces = 35
219/// bytes between the `[` and `]`).
220const BYTE_RANGE_SLOT_MAX: i64 = 99_999_999;
221const BYTE_RANGE_SLOT_WIDTH: usize = 8;
222
223/// Render a [`Scene`] with the supplied AcroForm fields attached
224/// (ISO 32000-1 §12.7). When the slice contains a
225/// [`FormField::Signature`], the round-30 byte-range placeholder
226/// pattern is applied so the resulting bytes are a valid signed PDF.
227///
228/// Constraints:
229///
230/// * `scene` must be in pages mode (same contract as
231///   [`crate::write_pdf_from_scene`]).
232/// * At most one [`FormField::Signature`] per call. The byte-range
233///   placeholder pattern of §12.8.1.1 has a single excluded range
234///   `[b, c)`, so a multi-signature scheme would require multiple
235///   incremental-update revisions — out of scope for round 31.
236/// * Each field's `page_index` must be `< scene.pages.len()`.
237pub fn write_pdf_with_form(scene: &Scene, form_fields: &[FormField]) -> Result<Vec<u8>, PdfError> {
238    let pages = scene
239        .pages
240        .as_ref()
241        .filter(|p| !p.is_empty())
242        .ok_or_else(|| {
243            PdfError::other(
244                "write_pdf_with_form: scene is not in pages mode (scene.pages is None or empty)",
245            )
246        })?;
247    let n_pages = pages.len();
248
249    let signature_count = form_fields
250        .iter()
251        .filter(|f| matches!(f, FormField::Signature(_)))
252        .count();
253    if signature_count > 1 {
254        return Err(PdfError::other(
255            "write_pdf_with_form: only one /FT /Sig field per call is supported (round 31)",
256        ));
257    }
258    validate_pages(form_fields, n_pages)?;
259
260    // Render every page's content stream + resources up front.
261    struct Rendered<'a> {
262        frame: &'a oxideav_core::vector::VectorFrame,
263        width: f32,
264        height: f32,
265        content_bytes: Vec<u8>,
266        resources: ResourceCollector,
267    }
268    let rendered: Vec<Rendered<'_>> = pages
269        .iter()
270        .map(|page| {
271            let (content_bytes, resources) = render_frame(&page.content);
272            Rendered {
273                frame: &page.content,
274                width: page.width,
275                height: page.height,
276                content_bytes,
277                resources,
278            }
279        })
280        .collect();
281
282    let inputs: Vec<PageInput<'_>> = rendered
283        .into_iter()
284        .map(|r| PageInput {
285            width: r.width,
286            height: r.height,
287            content_bytes: r.content_bytes,
288            resources: r.resources,
289            frame: r.frame,
290        })
291        .collect();
292
293    let mut doc = Document::new();
294    let pages_build = build_pages(&mut doc, inputs);
295    if has_metadata(&scene.metadata) {
296        let info_id = doc.add(Object::Dict(build_info_dict(&scene.metadata)));
297        doc.info = Some(info_id);
298    }
299
300    // ---- Allocate ids ----------------------------------------------
301    // Allocate one id per top-level field (terminal field for
302    // single-widget types; aggregate field for radio groups). For
303    // signature fields we also allocate the sig dict id.
304    let mut top_field_ids: Vec<ObjectId> = Vec::with_capacity(form_fields.len());
305    // Per top-field, the widget ids that should land in the matching
306    // page's /Annots. For non-radio fields the field id IS the widget
307    // (merged-field shape per §12.7.3.1).
308    let mut widgets_per_page: Vec<Vec<ObjectId>> = (0..n_pages).map(|_| Vec::new()).collect();
309    // Bookkeeping for the signature placeholder (only one allowed).
310    let mut sig_field_idx: Option<usize> = None;
311    let mut sig_dict_id: Option<ObjectId> = None;
312    // Per-radio-group kid ids so we can wire /Kids after id allocation.
313    let mut radio_kid_ids: Vec<Vec<ObjectId>> = Vec::with_capacity(form_fields.len());
314
315    for (i, field) in form_fields.iter().enumerate() {
316        let id = doc.allocate_id();
317        top_field_ids.push(id);
318        match field {
319            FormField::Text(t) => {
320                widgets_per_page[t.page_index].push(id);
321                radio_kid_ids.push(Vec::new());
322            }
323            FormField::Checkbox(c) => {
324                widgets_per_page[c.page_index].push(id);
325                radio_kid_ids.push(Vec::new());
326            }
327            FormField::RadioGroup(r) => {
328                let mut kids = Vec::with_capacity(r.options.len());
329                for opt in &r.options {
330                    let kid_id = doc.allocate_id();
331                    kids.push(kid_id);
332                    widgets_per_page[opt.page_index].push(kid_id);
333                }
334                radio_kid_ids.push(kids);
335            }
336            FormField::Choice(c) => {
337                widgets_per_page[c.page_index].push(id);
338                radio_kid_ids.push(Vec::new());
339            }
340            FormField::Signature(s) => {
341                sig_field_idx = Some(i);
342                let sdid = doc.allocate_id();
343                sig_dict_id = Some(sdid);
344                widgets_per_page[s.page_index].push(id);
345                radio_kid_ids.push(Vec::new());
346            }
347        }
348    }
349
350    // ---- Emit each top-level field dict ----------------------------
351    let mut contents_hex_offset_marker: Option<u32> = None;
352    for (i, field) in form_fields.iter().enumerate() {
353        let id = top_field_ids[i];
354        match field {
355            FormField::Text(t) => {
356                let dict = build_text_field_dict(t);
357                doc.add_object(id, Object::Dict(dict));
358            }
359            FormField::Checkbox(c) => {
360                let mut dict = build_checkbox_dict(c);
361                // §12.5.5 + §12.7.4.2.3 — the /AS state (Yes / Off)
362                // selects its stream from the /AP /N subdictionary.
363                let ap = button_appearance_dict(
364                    &mut doc,
365                    c.rect,
366                    "Yes",
367                    checkbox_appearance_content(c.rect, true),
368                    checkbox_appearance_content(c.rect, false),
369                );
370                dict.set("AP", ap);
371                doc.add_object(id, Object::Dict(dict));
372            }
373            FormField::RadioGroup(r) => {
374                let kid_ids = &radio_kid_ids[i];
375                let aggregate = build_radio_aggregate_dict(r, id, kid_ids);
376                doc.add_object(id, Object::Dict(aggregate));
377                for (opt, kid_id) in r.options.iter().zip(kid_ids.iter()) {
378                    let active = matches!(&r.value, Some(v) if v == &opt.export_value);
379                    let mut kid = build_radio_kid_dict(opt, id, active);
380                    // Each kid's /AP /N maps its export-value state to
381                    // the "on" (dotted) appearance plus the shared
382                    // /Off state (§12.7.4.2.3 Table 239).
383                    let ap = button_appearance_dict(
384                        &mut doc,
385                        opt.rect,
386                        &opt.export_value,
387                        radio_appearance_content(opt.rect, true),
388                        radio_appearance_content(opt.rect, false),
389                    );
390                    kid.set("AP", ap);
391                    doc.add_object(*kid_id, Object::Dict(kid));
392                }
393            }
394            FormField::Choice(c) => {
395                let dict = build_choice_field_dict(c);
396                doc.add_object(id, Object::Dict(dict));
397            }
398            FormField::Signature(s) => {
399                // The signature widget field. /V points at the sig dict.
400                let dict = Dict::new()
401                    .with("Type", Object::Name("Annot".into()))
402                    .with("Subtype", Object::Name("Widget".into()))
403                    .with("FT", Object::Name("Sig".into()))
404                    .with("T", text_string(&s.name))
405                    .with("Rect", rect_array(s.rect))
406                    .with("F", Object::Integer(4))
407                    .with(
408                        "V",
409                        Object::Reference(
410                            sig_dict_id.expect("sig_dict_id allocated for signature field"),
411                        ),
412                    )
413                    .with("P", Object::Reference(pages_build.page_ids[s.page_index]));
414                doc.add_object(id, Object::Dict(dict));
415
416                // Emit the sig dict with size-stable placeholders so the
417                // serialiser yields the EXACT final byte layout: the
418                // /Contents value is a HexString of CONTENTS_HEX_LEN/2
419                // bytes (= CONTENTS_HEX_LEN hex chars + the `< >`
420                // brackets), and /ByteRange is a literal-string
421                // matching BYTE_RANGE_PLACEHOLDER. Both are
422                // length-preserving overwrites after offsets are known.
423                let contents_placeholder = vec![0u8; CONTENTS_HEX_LEN / 2];
424                let signer_cert_hex = {
425                    let cert_bytes = s
426                        .identity
427                        .cert_chain
428                        .first()
429                        .map(|v| v.as_slice())
430                        .unwrap_or(&[]);
431                    cert_bytes.to_vec()
432                };
433                // We embed /ByteRange as a LiteralString carrying the
434                // placeholder text. This lets the serialiser emit the
435                // exact `(/ByteRange [...])` byte sequence with stable
436                // length so we can locate + patch it in place.
437                let sig_dict = Dict::new()
438                    .with("Type", Object::Name("Sig".into()))
439                    .with("Filter", Object::Name("Adobe.PPKLite".into()))
440                    .with("SubFilter", Object::Name("adbe.pkcs7.detached".into()))
441                    .with(
442                        "ByteRange",
443                        Object::Array(vec![
444                            Object::Integer(BYTE_RANGE_SLOT_MAX),
445                            Object::Integer(BYTE_RANGE_SLOT_MAX),
446                            Object::Integer(BYTE_RANGE_SLOT_MAX),
447                            Object::Integer(BYTE_RANGE_SLOT_MAX),
448                        ]),
449                    )
450                    .with("Contents", Object::HexString(contents_placeholder))
451                    .with("Cert", Object::HexString(signer_cert_hex));
452                doc.add_object(sig_dict_id.unwrap(), Object::Dict(sig_dict));
453                contents_hex_offset_marker = Some(sig_dict_id.unwrap().number);
454            }
455        }
456    }
457
458    // ---- AcroForm dict + Catalog patch -----------------------------
459    let acroform_id = doc.allocate_id();
460    let mut acroform_dict = Dict::new()
461        .with(
462            "Fields",
463            Object::Array(
464                top_field_ids
465                    .iter()
466                    .map(|id| Object::Reference(*id))
467                    .collect(),
468            ),
469        )
470        .with("DA", Object::LiteralString(DEFAULT_DA.as_bytes().to_vec()));
471    // SigFlags 3 = SignaturesExist | AppendOnly when a signature is present.
472    if sig_field_idx.is_some() {
473        acroform_dict.set("SigFlags", Object::Integer(3));
474    }
475    // /NeedAppearances true makes most viewers regenerate /AP at open
476    // time — keeps the writer from having to draw glyph-perfect
477    // appearance streams for every text-field value.
478    acroform_dict.set("NeedAppearances", Object::Bool(true));
479    doc.add_object(acroform_id, Object::Dict(acroform_dict));
480
481    // Patch catalog to point at /AcroForm.
482    let catalog = doc
483        .object_mut(pages_build.catalog_id)
484        .ok_or_else(|| PdfError::other("write_pdf_with_form: catalog id missing"))?;
485    if let Object::Dict(d) = catalog {
486        d.set("AcroForm", Object::Reference(acroform_id));
487    }
488
489    // ---- Per-page /Annots arrays -----------------------------------
490    for (page_idx, widgets) in widgets_per_page.iter().enumerate() {
491        if widgets.is_empty() {
492            continue;
493        }
494        let page_id = pages_build.page_ids[page_idx];
495        let page_obj = doc
496            .object_mut(page_id)
497            .ok_or_else(|| PdfError::other("write_pdf_with_form: page id missing"))?;
498        if let Object::Dict(d) = page_obj {
499            d.set(
500                "Annots",
501                Object::Array(widgets.iter().map(|w| Object::Reference(*w)).collect()),
502            );
503        }
504    }
505
506    // ---- Serialise & (maybe) sign ----------------------------------
507    if let Some(sig_idx) = sig_field_idx {
508        // For the signature path, we serialise with a marker stream so
509        // we can locate the sig dict's bytes, then overwrite that
510        // region in-place with the hand-laid version carrying the
511        // /ByteRange + /Contents placeholders. The /ByteRange is
512        // computed once the surrounding bytes are stable.
513        sign_path(
514            &mut doc,
515            form_fields,
516            sig_idx,
517            sig_dict_id.expect("sig dict id"),
518            contents_hex_offset_marker,
519        )
520    } else {
521        let mut out = Vec::with_capacity(4096);
522        doc.write_to(&mut out)?;
523        Ok(out)
524    }
525}
526
527// ---------------------------------------------------------------------
528// Dict builders.
529// ---------------------------------------------------------------------
530
531fn rect_array(rect: [f32; 4]) -> Object {
532    Object::Array(rect.iter().map(|v| Object::Real(*v as f64)).collect())
533}
534
535// ---------------------------------------------------------------------
536// §12.5.5 widget appearance streams.
537//
538// A check-box / radio-button widget carries an /AS appearance state
539// (§12.7.4.2.3) that selects a stream from the /AP /N subdictionary;
540// without /AP the /AS name has nothing to select and the widget's
541// rendering is left to /NeedAppearances regeneration (deprecated in
542// PDF 2.0). The writer therefore emits self-contained vector
543// appearances: no font program is referenced (the classical
544// ZapfDingbats check would need a font resource), so the streams
545// render under any conforming reader.
546// ---------------------------------------------------------------------
547
548/// Emit one `/Type /XObject /Subtype /Form` appearance stream whose
549/// `/BBox` is the widget `/Rect` — the §12.5.5 placement algorithm
550/// then maps it onto the rectangle by identity, so `content` paints
551/// directly in default user space.
552fn emit_widget_appearance(doc: &mut Document, rect: [f32; 4], content: String) -> ObjectId {
553    let dict = Dict::new()
554        .with("Type", Object::Name("XObject".into()))
555        .with("Subtype", Object::Name("Form".into()))
556        .with("BBox", rect_array(rect));
557    doc.add(Object::Stream(crate::objects::Stream::new(
558        dict,
559        content.into_bytes(),
560    )))
561}
562
563/// Check-box appearance content: a 1-pt black border box, plus (when
564/// `checked`) a three-point check-mark polyline stroked with round
565/// caps at 12 % of the box's smaller dimension.
566fn checkbox_appearance_content(rect: [f32; 4], checked: bool) -> String {
567    use crate::operators::format_real;
568    let fr = |v: f32| format_real(f64::from(v));
569    let (x0, y0) = (rect[0] + 0.5, rect[1] + 0.5);
570    let (x1, y1) = (rect[2] - 0.5, rect[3] - 0.5);
571    let (w, h) = (x1 - x0, y1 - y0);
572    let mut ops = format!("0 G 1 w\n{} {} {} {} re\nS\n", fr(x0), fr(y0), fr(w), fr(h));
573    if checked && w > 0.0 && h > 0.0 {
574        let lw = (w.min(h) * 0.12).max(0.4);
575        ops.push_str(&format!(
576            "{} w\n1 J 1 j\n{} {} m\n{} {} l\n{} {} l\nS\n",
577            fr(lw),
578            fr(x0 + 0.20 * w),
579            fr(y0 + 0.50 * h),
580            fr(x0 + 0.45 * w),
581            fr(y0 + 0.25 * h),
582            fr(x0 + 0.80 * w),
583            fr(y0 + 0.75 * h),
584        ));
585    }
586    ops
587}
588
589/// Radio-button appearance content: a 1-pt black ellipse border
590/// inscribed in the widget rect, plus (when `on`) a filled inner dot
591/// at half the border's radii.
592fn radio_appearance_content(rect: [f32; 4], on: bool) -> String {
593    use crate::operators::format_real;
594    let fr = |v: f32| format_real(f64::from(v));
595    let (x0, y0) = (rect[0] + 0.5, rect[1] + 0.5);
596    let (x1, y1) = (rect[2] - 0.5, rect[3] - 0.5);
597    let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
598    let (rx, ry) = (((x1 - x0) / 2.0).max(0.0), ((y1 - y0) / 2.0).max(0.0));
599    let ellipse = |ops: &mut String, rx: f32, ry: f32| {
600        let k = crate::annotations::ARC_KAPPA;
601        let (kx, ky) = (rx * k, ry * k);
602        ops.push_str(&format!("{} {} m\n", fr(cx + rx), fr(cy)));
603        for (c1, c2, end) in [
604            ((cx + rx, cy + ky), (cx + kx, cy + ry), (cx, cy + ry)),
605            ((cx - kx, cy + ry), (cx - rx, cy + ky), (cx - rx, cy)),
606            ((cx - rx, cy - ky), (cx - kx, cy - ry), (cx, cy - ry)),
607            ((cx + kx, cy - ry), (cx + rx, cy - ky), (cx + rx, cy)),
608        ] {
609            ops.push_str(&format!(
610                "{} {} {} {} {} {} c\n",
611                fr(c1.0),
612                fr(c1.1),
613                fr(c2.0),
614                fr(c2.1),
615                fr(end.0),
616                fr(end.1)
617            ));
618        }
619        ops.push_str("h\n");
620    };
621    let mut ops = String::from("0 G 1 w\n");
622    ellipse(&mut ops, rx, ry);
623    ops.push_str("S\n");
624    if on {
625        ops.push_str("0 g\n");
626        ellipse(&mut ops, rx * 0.5, ry * 0.5);
627        ops.push_str("f\n");
628    }
629    ops
630}
631
632/// Build the two-state `/AP << /N << /<on> … /Off … >> >>` appearance
633/// dictionary for a button widget (§12.5.5 + §12.7.4.2.3), emitting
634/// both state streams.
635fn button_appearance_dict(
636    doc: &mut Document,
637    rect: [f32; 4],
638    on_state: &str,
639    on_content: String,
640    off_content: String,
641) -> Object {
642    let on_id = emit_widget_appearance(doc, rect, on_content);
643    let off_id = emit_widget_appearance(doc, rect, off_content);
644    let states = Dict::new()
645        .with(on_state, Object::Reference(on_id))
646        .with("Off", Object::Reference(off_id));
647    Object::Dict(Dict::new().with("N", Object::Dict(states)))
648}
649
650fn text_string(s: &str) -> Object {
651    // PDF "text string" form per §7.9.2.2.1 — ASCII passes through as a
652    // literal string; non-ASCII becomes UTF-16BE-with-BOM in a hex
653    // string. Same logic as `crate::writer::outline_text_string`.
654    if s.bytes().all(|b| b.is_ascii() && b != 0) {
655        Object::LiteralString(s.as_bytes().to_vec())
656    } else {
657        let mut bytes = vec![0xFE, 0xFF];
658        for cp in s.encode_utf16() {
659            bytes.push((cp >> 8) as u8);
660            bytes.push((cp & 0xFF) as u8);
661        }
662        Object::HexString(bytes)
663    }
664}
665
666fn build_text_field_dict(t: &FormFieldText) -> Dict {
667    let mut d = Dict::new()
668        .with("Type", Object::Name("Annot".into()))
669        .with("Subtype", Object::Name("Widget".into()))
670        .with("FT", Object::Name("Tx".into()))
671        .with("T", text_string(&t.name))
672        .with("Rect", rect_array(t.rect))
673        .with("F", Object::Integer(4)); // /F bit 3 = Print
674    if let Some(v) = &t.value {
675        d.set("V", text_string(v));
676        d.set("DV", text_string(v));
677    }
678    if let Some(m) = t.max_length {
679        d.set("MaxLen", Object::Integer(m as i64));
680    }
681    // Field flags: bit 13 (value 0x1000) = Multiline per Table 228.
682    if t.multi_line {
683        d.set("Ff", Object::Integer(0x1000));
684    }
685    d.set("Q", Object::Integer(t.justification.as_int()));
686    let da = t.default_appearance.as_deref().unwrap_or(DEFAULT_DA);
687    d.set("DA", Object::LiteralString(da.as_bytes().to_vec()));
688    d
689}
690
691fn build_checkbox_dict(c: &FormFieldCheckbox) -> Dict {
692    let mut d = Dict::new()
693        .with("Type", Object::Name("Annot".into()))
694        .with("Subtype", Object::Name("Widget".into()))
695        .with("FT", Object::Name("Btn".into()))
696        .with("T", text_string(&c.name))
697        .with("Rect", rect_array(c.rect))
698        .with("F", Object::Integer(4));
699    if c.checked {
700        d.set("V", Object::Name("Yes".into()));
701        d.set("AS", Object::Name("Yes".into()));
702        d.set("DV", Object::Name("Yes".into()));
703    } else {
704        d.set("V", Object::Name("Off".into()));
705        d.set("AS", Object::Name("Off".into()));
706        d.set("DV", Object::Name("Off".into()));
707    }
708    let da = c.default_appearance.as_deref().unwrap_or(DEFAULT_DA);
709    d.set("DA", Object::LiteralString(da.as_bytes().to_vec()));
710    // No /Ff bits set ⇒ checkbox (neither Pushbutton bit 17 nor Radio
711    // bit 16 of Table 228).
712    d
713}
714
715fn build_radio_aggregate_dict(
716    r: &FormFieldRadioGroup,
717    _self_id: ObjectId,
718    kid_ids: &[ObjectId],
719) -> Dict {
720    // /Ff bits 16 (Radio = 0x8000) + 15 (NoToggleToOff = 0x4000) per
721    // Table 228. We don't set bit 17 (Pushbutton) — that would conflict
722    // with Radio.
723    let ff: i64 = 0x8000 | 0x4000;
724    let mut d = Dict::new()
725        .with("FT", Object::Name("Btn".into()))
726        .with("T", text_string(&r.name))
727        .with("Ff", Object::Integer(ff))
728        .with(
729            "Kids",
730            Object::Array(kid_ids.iter().map(|id| Object::Reference(*id)).collect()),
731        );
732    if let Some(v) = &r.value {
733        d.set("V", Object::Name(v.clone()));
734        d.set("DV", Object::Name(v.clone()));
735    } else {
736        d.set("V", Object::Name("Off".into()));
737        d.set("DV", Object::Name("Off".into()));
738    }
739    d
740}
741
742fn build_radio_kid_dict(opt: &RadioOption, parent_id: ObjectId, active: bool) -> Dict {
743    let mut d = Dict::new()
744        .with("Type", Object::Name("Annot".into()))
745        .with("Subtype", Object::Name("Widget".into()))
746        .with("Parent", Object::Reference(parent_id))
747        .with("Rect", rect_array(opt.rect))
748        .with("F", Object::Integer(4));
749    // Per §12.7.4.2.3 + Table 239, a radio kid's /AS is either /Off
750    // or the export_value Name to indicate which option is "on".
751    let as_name = if active {
752        Object::Name(opt.export_value.clone())
753    } else {
754        Object::Name("Off".into())
755    };
756    d.set("AS", as_name);
757    d
758}
759
760fn build_choice_field_dict(c: &FormFieldChoice) -> Dict {
761    let mut d = Dict::new()
762        .with("Type", Object::Name("Annot".into()))
763        .with("Subtype", Object::Name("Widget".into()))
764        .with("FT", Object::Name("Ch".into()))
765        .with("T", text_string(&c.name))
766        .with("Rect", rect_array(c.rect))
767        .with("F", Object::Integer(4));
768    // Option array — each entry is a single string per §12.7.4.4 Table 231.
769    let opt_array: Vec<Object> = c.options.iter().map(|s| text_string(s)).collect();
770    d.set("Opt", Object::Array(opt_array));
771    if let Some(v) = &c.value {
772        d.set("V", text_string(v));
773        d.set("DV", text_string(v));
774    }
775    // /Ff bit 18 = Combo. List boxes are the default (no bit).
776    if c.combo_box {
777        d.set("Ff", Object::Integer(0x20000));
778    }
779    let da = c.default_appearance.as_deref().unwrap_or(DEFAULT_DA);
780    d.set("DA", Object::LiteralString(da.as_bytes().to_vec()));
781    d
782}
783
784fn validate_pages(form_fields: &[FormField], n_pages: usize) -> Result<(), PdfError> {
785    for field in form_fields {
786        match field {
787            FormField::Text(t) => check_page(t.page_index, n_pages)?,
788            FormField::Checkbox(c) => check_page(c.page_index, n_pages)?,
789            FormField::Choice(c) => check_page(c.page_index, n_pages)?,
790            FormField::Signature(s) => check_page(s.page_index, n_pages)?,
791            FormField::RadioGroup(r) => {
792                if r.options.is_empty() {
793                    return Err(PdfError::other(
794                        "write_pdf_with_form: radio group has no options",
795                    ));
796                }
797                for opt in &r.options {
798                    check_page(opt.page_index, n_pages)?;
799                }
800            }
801        }
802    }
803    Ok(())
804}
805
806fn check_page(page_index: usize, n_pages: usize) -> Result<(), PdfError> {
807    if page_index >= n_pages {
808        Err(PdfError::other(format!(
809            "write_pdf_with_form: form field page_index {page_index} \
810             out of range (scene has {n_pages} page(s))",
811        )))
812    } else {
813        Ok(())
814    }
815}
816
817// ---------------------------------------------------------------------
818// Signature path — re-uses the round-30 byterange-placeholder pattern.
819// ---------------------------------------------------------------------
820
821fn sign_path(
822    doc: &mut Document,
823    form_fields: &[FormField],
824    sig_idx: usize,
825    sig_dict_id: ObjectId,
826    _contents_hex_offset_marker: Option<u32>,
827) -> Result<Vec<u8>, PdfError> {
828    // Strategy: the sig dict has been emitted with size-stable
829    // placeholders (HexString of CONTENTS_HEX_LEN/2 bytes → `<{HEX}>`
830    // of CONTENTS_HEX_LEN+2 bytes; /ByteRange as
831    // `[MAX MAX MAX MAX]` where MAX is BYTE_RANGE_SLOT_MAX padded to
832    // BYTE_RANGE_SLOT_WIDTH digits). So we can serialise once, locate
833    // both placeholders, and patch in place — no offset shifting.
834
835    let mut out = Vec::with_capacity(4096);
836    doc.write_to(&mut out)?;
837
838    // Find the sig dict's serialised body.
839    let id_prefix = format!("{} 0 obj\n", sig_dict_id.number);
840    let obj_start = out
841        .windows(id_prefix.len())
842        .position(|w| w == id_prefix.as_bytes())
843        .ok_or_else(|| PdfError::other("sign_path: sig dict missing in serialised PDF"))?;
844    let body_start = obj_start + id_prefix.len();
845    let endobj_off = find_subslice(&out[body_start..], b"\nendobj\n")
846        .ok_or_else(|| PdfError::other("sign_path: endobj missing after sig dict"))?;
847    let body_end = body_start + endobj_off;
848    let body = &out[body_start..body_end];
849
850    // Locate the `/Contents <…>` hex placeholder inside the sig dict.
851    let contents_marker = b"/Contents <";
852    let contents_in_body = find_subslice(body, contents_marker)
853        .ok_or_else(|| PdfError::other("sign_path: /Contents <…> marker missing"))?;
854    let contents_hex_start = body_start + contents_in_body + contents_marker.len();
855
856    // Locate the `/ByteRange [...]` array inside the sig dict.
857    let br_marker = b"/ByteRange [";
858    let br_in_body = find_subslice(body, br_marker)
859        .ok_or_else(|| PdfError::other("sign_path: /ByteRange marker missing"))?;
860    let br_array_start = body_start + br_in_body + br_marker.len();
861    // The array body is `<8>D <8>D <8>D <8>D` separated by single
862    // spaces, terminated by `]`. The serialiser emits no leading
863    // padding (Integer is `{}`); since all four start at
864    // BYTE_RANGE_SLOT_MAX they are exactly BYTE_RANGE_SLOT_WIDTH
865    // digits, so the array body byte length is
866    // 4*W + 3 + 0 = 35 (no surrounding brackets — those are outside
867    // br_array_start).
868    let array_body_len = BYTE_RANGE_SLOT_WIDTH * 4 + 3;
869    let br_array_end = br_array_start + array_body_len;
870    // Sanity check — the byte right after must be `]`.
871    if out.get(br_array_end) != Some(&b']') {
872        return Err(PdfError::other(format!(
873            "sign_path: /ByteRange array width drift (expected `]` at off {br_array_end})",
874        )));
875    }
876
877    // Compute byte-range integers. The signed range is everything
878    // EXCEPT the bytes between `<` and `>` of /Contents (per
879    // §12.8.1.1).
880    let a: i64 = 0;
881    let b: i64 = contents_hex_start as i64;
882    let c: i64 = (contents_hex_start + CONTENTS_HEX_LEN) as i64;
883    let d: i64 = out.len() as i64 - c;
884
885    // Each slot must fit BYTE_RANGE_SLOT_MAX digits.
886    if a > BYTE_RANGE_SLOT_MAX
887        || b > BYTE_RANGE_SLOT_MAX
888        || c > BYTE_RANGE_SLOT_MAX
889        || d > BYTE_RANGE_SLOT_MAX
890    {
891        return Err(PdfError::other(format!(
892            "sign_path: PDF too large for /ByteRange slot width {BYTE_RANGE_SLOT_WIDTH} \
893             (max value {BYTE_RANGE_SLOT_MAX})",
894        )));
895    }
896
897    // Patch the four slots — each is exactly BYTE_RANGE_SLOT_WIDTH
898    // digits, zero-padded.
899    let formatted = format!(
900        "{a:0w$} {b:0w$} {c:0w$} {d:0w$}",
901        a = a,
902        b = b,
903        c = c,
904        d = d,
905        w = BYTE_RANGE_SLOT_WIDTH
906    );
907    if formatted.len() != array_body_len {
908        return Err(PdfError::other(
909            "sign_path: byte-range formatter width drift",
910        ));
911    }
912    out[br_array_start..br_array_end].copy_from_slice(formatted.as_bytes());
913
914    // Hash + sign.
915    let (signer_ref, identity) = match &form_fields[sig_idx] {
916        FormField::Signature(s) => (s.signer.as_ref(), &s.identity),
917        _ => unreachable!(),
918    };
919    let signed_bytes = concat_byte_ranges(&out, [a, b, c, d])?;
920    let content_hash = signer_ref.algorithm().hash().hash(&signed_bytes);
921    let md_attr = crate::pubsec::verify::build_message_digest_attribute_der(&content_hash);
922    let ct_attr =
923        crate::sig::writer::build_content_type_attribute_der(&crate::pubsec::cms::OID_DATA);
924    let attrs_body = crate::pubsec::verify::pack_signed_attrs_implicit(&[ct_attr, md_attr]);
925    let tbs = crate::pubsec::verify::signed_attrs_to_be_signed(&attrs_body);
926    let tbs_hash = signer_ref.algorithm().hash().hash(&tbs);
927    let signature_bytes = signer_ref.sign(&tbs_hash)?;
928
929    let cms_blob = crate::sig::pkcs7_wrap_signed_data(
930        signer_ref.algorithm(),
931        &identity.issuer_der,
932        &identity.serial,
933        &identity.cert_chain,
934        Some(&attrs_body),
935        &signature_bytes,
936    );
937
938    patch_contents(&mut out, contents_hex_start, &cms_blob)?;
939    Ok(out)
940}
941
942fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
943    hay.windows(needle.len()).position(|w| w == needle)
944}
945
946fn patch_contents(
947    pdf: &mut [u8],
948    contents_hex_offset: usize,
949    contents_der: &[u8],
950) -> Result<(), PdfError> {
951    let hex_len_needed = contents_der.len() * 2;
952    if hex_len_needed > CONTENTS_HEX_LEN {
953        return Err(PdfError::other(format!(
954            "write_pdf_with_form: CMS blob {hex_len_needed} hex chars exceeds /Contents budget {CONTENTS_HEX_LEN}",
955        )));
956    }
957    for (i, b) in contents_der.iter().enumerate() {
958        let hi = (b >> 4) & 0x0F;
959        let lo = b & 0x0F;
960        pdf[contents_hex_offset + 2 * i] = hex_digit(hi);
961        pdf[contents_hex_offset + 2 * i + 1] = hex_digit(lo);
962    }
963    for byte in pdf
964        .iter_mut()
965        .skip(contents_hex_offset + hex_len_needed)
966        .take(CONTENTS_HEX_LEN - hex_len_needed)
967    {
968        *byte = b'0';
969    }
970    Ok(())
971}
972
973fn hex_digit(n: u8) -> u8 {
974    match n {
975        0..=9 => b'0' + n,
976        10..=15 => b'A' + (n - 10),
977        _ => unreachable!(),
978    }
979}
980
981fn concat_byte_ranges(pdf: &[u8], byte_range: [i64; 4]) -> Result<Vec<u8>, PdfError> {
982    let [a, b, c, d] = byte_range;
983    if a < 0 || b < 0 || c < 0 || d < 0 {
984        return Err(PdfError::other("write_pdf_with_form: negative /ByteRange"));
985    }
986    let (a, b, c, d) = (a as usize, b as usize, c as usize, d as usize);
987    if a + b > pdf.len() || c + d > pdf.len() {
988        return Err(PdfError::other(
989            "write_pdf_with_form: /ByteRange extends past file length",
990        ));
991    }
992    let mut out = Vec::with_capacity(b + d);
993    out.extend_from_slice(&pdf[a..a + b]);
994    out.extend_from_slice(&pdf[c..c + d]);
995    Ok(out)
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    #[test]
1003    fn default_da_is_helvetica_12pt_black() {
1004        // §12.7.3.3 default appearance.
1005        assert_eq!(DEFAULT_DA, "/Helv 12 Tf 0 g");
1006    }
1007
1008    #[test]
1009    fn rect_array_emits_four_reals() {
1010        let o = rect_array([1.0, 2.0, 3.0, 4.0]);
1011        match o {
1012            Object::Array(a) => assert_eq!(a.len(), 4),
1013            _ => panic!("expected array"),
1014        }
1015    }
1016
1017    #[test]
1018    fn justification_int_values_match_table_222() {
1019        // Table 222 lists 0=left, 1=centre, 2=right.
1020        assert_eq!(FieldJustification::Left.as_int(), 0);
1021        assert_eq!(FieldJustification::Center.as_int(), 1);
1022        assert_eq!(FieldJustification::Right.as_int(), 2);
1023    }
1024}