Skip to main content

pdfluent_forms/
writeback.rs

1//! Single reliable AcroForm value writeback (D-chain).
2//!
3//! [`apply_field_value`] is the one SDK-level operation for setting a form
4//! value on a `lopdf::Document`. It owns the complete chain the PDF spec and
5//! the reference implementations (pdfium, mupdf, pdf.js) require:
6//!
7//! 1. **`/V`** — text values via the ASCII-literal-else-UTF-16BE+BOM policy
8//!    (ISO 32000-1 §7.9.2.2); button values as byte-exact `/Name` objects
9//!    (§12.7.4.2.3).
10//! 2. **`/AS`** — kept consistent with `/V` on *every* widget of a button
11//!    field: a widget's `/AS` becomes the on-state iff that name is a key of
12//!    the widget's own `/AP /N` sub-dictionary, else `/Off` (the mupdf
13//!    `set_check_grp` rule).
14//! 3. **`/AP`** — text and choice widgets get a regenerated `/AP /N` Form
15//!    XObject (`/Tx BMC … EMC`, WinAnsi-encoded show text, AFM-measured
16//!    positioning, comb/multiline/quadding support) so the fill is visible in
17//!    every viewer without `/NeedAppearances` processing.
18//! 4. **`/NeedAppearances`** — set `true` only as a fallback when appearance
19//!    generation is not trustworthy (value not WinAnsi-representable); the
20//!    stale `/AP /N` is removed in that case so no viewer shows the old
21//!    value. PDF 2.0 deprecates `NeedAppearances`, so the primary path never
22//!    sets it.
23//!
24//! Field lookup accepts fully-qualified names (`parent.kid`) and recurses
25//! through `/Kids`, unlike the historical top-level-only paths. Read-only
26//! fields (`/Ff` bit 1, inherited) are rejected — note this is stricter than
27//! pdfium/mupdf, which only enforce read-only in their UI layers; an SDK has
28//! no UI layer, so set-time enforcement is the only place the contract can
29//! live.
30
31// Copyright (c) 2026 Innovation Trigger B.V.
32//
33// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
34// the PDFluent Commercial Licence. See the LICENSE file in this repository --
35// that file travels with the copy you received, which a URL does not.
36
37use crate::appearance::{parse_da, DefaultAppearance};
38use crate::encoding::{encode_winansi, escape_string_bytes};
39use crate::metrics::StandardFace;
40use lopdf::{dictionary, Dictionary, Document, Object, ObjectId, Stream};
41use std::io::Write as _;
42
43/// Maximum `/Kids` / `/Parent` traversal depth (mirrors `parse::MAX_FIELD_DEPTH`).
44const MAX_DEPTH: usize = 100;
45
46/// Inset between the widget border and the text, in points. The reference
47/// implementations converge on ~2pt at the default 1pt border width.
48const TEXT_INSET: f32 = 2.0;
49
50/// Errors surfaced by [`apply_field_value`].
51#[derive(Debug, thiserror::Error)]
52pub enum WritebackError {
53    /// No terminal field or button group matches the fully-qualified name.
54    #[error("form field '{0}' not found")]
55    FieldNotFound(String),
56    /// The field (or an ancestor) carries the ReadOnly flag (`/Ff` bit 1).
57    #[error("form field '{0}' is read-only")]
58    ReadOnly(String),
59    /// The value kind does not match the field's `/FT`.
60    #[error("form field '{name}' is a {actual} field; cannot apply a {requested} value")]
61    WrongType {
62        /// Fully-qualified field name.
63        name: String,
64        /// The field's actual type as found in the PDF.
65        actual: &'static str,
66        /// The value kind the caller tried to apply.
67        requested: &'static str,
68    },
69    /// A radio export value that no kid widget declares, or a choice value
70    /// not present in a non-editable field's `/Opt` array.
71    #[error("value '{value}' is not a valid option for field '{name}'")]
72    InvalidOption {
73        /// Fully-qualified field name.
74        name: String,
75        /// The rejected value.
76        value: String,
77    },
78    /// Structural problem in the document (missing AcroForm, shape changes).
79    #[error("malformed form structure: {0}")]
80    Malformed(String),
81}
82
83/// The value to write, by field family.
84#[derive(Debug, Clone, Copy)]
85pub enum WriteValue<'a> {
86    /// Text field (`/FT /Tx`) value.
87    Text(&'a str),
88    /// Checkbox on/off. The on-state name is resolved from the widget's own
89    /// `/AP /N` keys (first non-`Off` key), defaulting to `Yes`.
90    Checkbox(bool),
91    /// Radio group selection by export (on-state) name. Matched byte-exactly
92    /// against each kid widget's `/AP /N` keys, with WinAnsi re-encoding of
93    /// the lookup string as a fallback so Latin-1 state names (`coöperatie…`)
94    /// match from a Unicode caller.
95    Radio(&'a str),
96    /// Choice field (`/FT /Ch`) selection.
97    Choice(&'a str),
98}
99
100impl WriteValue<'_> {
101    fn kind(&self) -> &'static str {
102        match self {
103            WriteValue::Text(_) => "text",
104            WriteValue::Checkbox(_) => "checkbox",
105            WriteValue::Radio(_) => "radio",
106            WriteValue::Choice(_) => "choice",
107        }
108    }
109}
110
111/// What [`apply_field_value`] did, for callers that report or log.
112#[derive(Debug, Default, Clone)]
113pub struct WriteOutcome {
114    /// `/AP` streams (re)generated on widgets of this field.
115    pub appearances_generated: usize,
116    /// Widgets whose `/AS` was updated.
117    pub appearance_states_set: usize,
118    /// `true` when `/NeedAppearances` was set as the encoding fallback.
119    pub need_appearances_fallback: bool,
120}
121
122// ---------------------------------------------------------------------------
123// Field location
124// ---------------------------------------------------------------------------
125
126/// A located field: the object id of its dictionary plus its FQN.
127#[derive(Debug, Clone)]
128struct Located {
129    id: ObjectId,
130    fqn: String,
131    /// Direct kids that are themselves located (for widget enumeration).
132    kids: Vec<ObjectId>,
133}
134
135fn acroform_id(doc: &Document) -> Result<ObjectId, WritebackError> {
136    let catalog = doc
137        .catalog()
138        .map_err(|_| WritebackError::Malformed("document has no catalog".into()))?;
139    match catalog.get(b"AcroForm") {
140        Ok(Object::Reference(id)) => Ok(*id),
141        Ok(Object::Dictionary(_)) => Err(WritebackError::Malformed(
142            "inline /AcroForm dictionary; promote it first via ensure_indirect_acroform".into(),
143        )),
144        _ => Err(WritebackError::Malformed(
145            "document has no /AcroForm dictionary".into(),
146        )),
147    }
148}
149
150/// Promote an inline `/AcroForm` dictionary to an indirect object.
151///
152/// Real-world corpora are full of documents whose `/AcroForm` lives inline in
153/// the catalog (LiveCycle static shells in particular — 92% of our hybrid
154/// gate corpus). Mutation paths address the AcroForm by object id, so this
155/// one-time normalization runs at every writeback entry point. Acrobat
156/// itself always writes indirect AcroForms, so the promoted shape is the
157/// canonical one. No-op when `/AcroForm` is already indirect or absent.
158fn ensure_indirect_acroform(doc: &mut Document) {
159    let inline = match doc.catalog() {
160        Ok(catalog) => match catalog.get(b"AcroForm") {
161            Ok(Object::Dictionary(d)) => Some(d.clone()),
162            _ => None,
163        },
164        Err(_) => None,
165    };
166    if let Some(dict) = inline {
167        let id = doc.add_object(Object::Dictionary(dict));
168        if let Ok(catalog) = doc.catalog_mut() {
169            catalog.set("AcroForm", Object::Reference(id));
170        }
171    }
172}
173
174/// Walk `/AcroForm /Fields` (+ `/Kids`, depth- and cycle-guarded) and collect
175/// every indirect field dictionary with its fully-qualified name.
176fn collect_fields(doc: &Document) -> Result<Vec<Located>, WritebackError> {
177    let af_id = acroform_id(doc)?;
178    let af = doc
179        .get_object(af_id)
180        .and_then(|o| o.as_dict())
181        .map_err(|_| WritebackError::Malformed("/AcroForm is not a dictionary".into()))?;
182    let fields = match af.get(b"Fields") {
183        Ok(Object::Array(arr)) => arr.clone(),
184        Ok(Object::Reference(id)) => match doc.get_object(*id) {
185            Ok(Object::Array(arr)) => arr.clone(),
186            _ => return Err(WritebackError::Malformed("/Fields is not an array".into())),
187        },
188        _ => return Err(WritebackError::Malformed("/AcroForm has no /Fields".into())),
189    };
190
191    let mut out = Vec::new();
192    let mut visited = std::collections::BTreeSet::new();
193    for entry in &fields {
194        if let Object::Reference(id) = entry {
195            walk_field(doc, *id, String::new(), 0, &mut visited, &mut out);
196        }
197    }
198    Ok(out)
199}
200
201fn walk_field(
202    doc: &Document,
203    id: ObjectId,
204    prefix: String,
205    depth: usize,
206    visited: &mut std::collections::BTreeSet<ObjectId>,
207    out: &mut Vec<Located>,
208) {
209    if depth >= MAX_DEPTH || !visited.insert(id) {
210        return;
211    }
212    let Ok(dict) = doc.get_object(id).and_then(|o| o.as_dict()) else {
213        return;
214    };
215    let partial = dict
216        .get(b"T")
217        .ok()
218        .and_then(|o| lopdf::decode_text_string(o).ok())
219        .unwrap_or_default();
220    let fqn = match (prefix.is_empty(), partial.is_empty()) {
221        (true, _) => partial.clone(),
222        (false, true) => prefix.clone(),
223        (false, false) => format!("{prefix}.{partial}"),
224    };
225
226    let mut kid_ids = Vec::new();
227    if let Ok(Object::Array(kids)) = dict.get(b"Kids") {
228        for kid in kids {
229            if let Object::Reference(kid_id) = kid {
230                kid_ids.push(*kid_id);
231                // Only kids that carry /T are nested FIELDS and get their own
232                // Located entry; unnamed kids are widget annotations of THIS
233                // field (§12.7.3.1 — a field node is identified by /T) and
234                // must not shadow the field under the same FQN.
235                let is_nested_field = doc
236                    .get_object(*kid_id)
237                    .and_then(|o| o.as_dict())
238                    .map(|d| d.get(b"T").is_ok())
239                    .unwrap_or(false);
240                if is_nested_field {
241                    walk_field(doc, *kid_id, fqn.clone(), depth + 1, visited, out);
242                }
243            }
244        }
245    }
246    out.push(Located {
247        id,
248        fqn,
249        kids: kid_ids,
250    });
251}
252
253/// Resolve an inheritable key by walking the `/Parent` chain.
254fn inherited<'a>(doc: &'a Document, dict: &'a Dictionary, key: &[u8]) -> Option<Object> {
255    let mut current: Option<&Dictionary> = Some(dict);
256    for _ in 0..MAX_DEPTH {
257        let d = current?;
258        if let Ok(v) = d.get(key) {
259            return Some(v.clone());
260        }
261        current = match d.get(b"Parent") {
262            Ok(Object::Reference(pid)) => doc.get_object(*pid).and_then(|o| o.as_dict()).ok(),
263            _ => None,
264        };
265    }
266    None
267}
268
269fn effective_flags(doc: &Document, dict: &Dictionary) -> i64 {
270    match inherited(doc, dict, b"Ff") {
271        Some(Object::Integer(i)) => i,
272        _ => 0,
273    }
274}
275
276fn effective_field_type(doc: &Document, dict: &Dictionary) -> Option<Vec<u8>> {
277    match inherited(doc, dict, b"FT") {
278        Some(Object::Name(n)) => Some(n),
279        _ => None,
280    }
281}
282
283/// The widgets of a field: kid widget annotations, or the field dict itself
284/// when field and widget are merged (it carries a `/Rect`).
285fn widget_ids(doc: &Document, located: &Located) -> Vec<ObjectId> {
286    let mut out = Vec::new();
287    for &kid in &located.kids {
288        if let Ok(d) = doc.get_object(kid).and_then(|o| o.as_dict()) {
289            // A kid with /T is a nested field, not a widget of this field.
290            // /Rect is NOT required: spec-degenerate widgets without one
291            // still carry /AP//AS state; geometry-dependent steps guard via
292            // widget_rect() themselves.
293            if d.get(b"T").is_err() {
294                out.push(kid);
295            }
296        }
297    }
298    if out.is_empty() {
299        out.push(located.id);
300    }
301    out
302}
303
304/// First non-`Off` key of a widget's `/AP /N` sub-dictionary, raw bytes.
305fn on_state_of_widget(doc: &Document, widget: ObjectId) -> Option<Vec<u8>> {
306    let dict = doc.get_object(widget).and_then(|o| o.as_dict()).ok()?;
307    let n = appearance_normal_dict(doc, dict)?;
308    n.iter()
309        .map(|(k, _)| k.clone())
310        .find(|k| k.as_slice() != b"Off")
311}
312
313/// Resolve `/AP /N` to a sub-state dictionary (following references).
314fn appearance_normal_dict<'a>(doc: &'a Document, dict: &'a Dictionary) -> Option<&'a Dictionary> {
315    let ap = match dict.get(b"AP").ok()? {
316        Object::Reference(id) => doc.get_object(*id).and_then(|o| o.as_dict()).ok()?,
317        Object::Dictionary(d) => d,
318        _ => return None,
319    };
320    match ap.get(b"N").ok()? {
321        Object::Reference(id) => doc.get_object(*id).and_then(|o| o.as_dict()).ok(),
322        Object::Dictionary(d) => Some(d),
323        _ => None,
324    }
325}
326
327/// Whether a widget's `/AP /N` declares `state` as a key, byte-exact.
328fn widget_has_state(doc: &Document, widget: ObjectId, state: &[u8]) -> bool {
329    let Ok(dict) = doc.get_object(widget).and_then(|o| o.as_dict()) else {
330        return false;
331    };
332    match appearance_normal_dict(doc, dict) {
333        Some(n) => n.iter().any(|(k, _)| k.as_slice() == state),
334        None => false,
335    }
336}
337
338/// Set `/AS` on a widget dictionary.
339fn set_widget_as(doc: &mut Document, widget: ObjectId, state: &[u8]) -> bool {
340    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(widget) {
341        d.set("AS", Object::Name(state.to_vec()));
342        true
343    } else {
344        false
345    }
346}
347
348// ---------------------------------------------------------------------------
349// Public entry points
350// ---------------------------------------------------------------------------
351
352/// Set a form field value, updating `/V`, `/AS`, `/AP` and (only as an
353/// encoding fallback) `/NeedAppearances`. See the module docs for the chain.
354///
355/// `name` is the fully-qualified field name (`parent.kid` notation);
356/// hierarchical forms are fully addressable.
357pub fn apply_field_value(
358    doc: &mut Document,
359    name: &str,
360    value: WriteValue<'_>,
361) -> Result<WriteOutcome, WritebackError> {
362    ensure_indirect_acroform(doc);
363    let fields = collect_fields(doc)?;
364    let located = fields
365        .iter()
366        .find(|l| l.fqn == name)
367        .cloned()
368        .ok_or_else(|| WritebackError::FieldNotFound(name.to_string()))?;
369
370    let dict = doc
371        .get_object(located.id)
372        .and_then(|o| o.as_dict())
373        .map_err(|_| WritebackError::Malformed("field object is not a dictionary".into()))?;
374
375    // ReadOnly (Ff bit 1) — inherited, set-time enforced (see module docs).
376    if effective_flags(doc, dict) & 0x1 != 0 {
377        return Err(WritebackError::ReadOnly(name.to_string()));
378    }
379
380    let ft = effective_field_type(doc, dict).unwrap_or_else(|| b"Tx".to_vec());
381    let flags = effective_flags(doc, dict);
382
383    match (ft.as_slice(), value) {
384        (b"Tx", WriteValue::Text(text)) => apply_text(doc, &located, text),
385        (b"Btn", WriteValue::Checkbox(on)) if flags & 0x18000 == 0 => {
386            apply_checkbox(doc, &located, on)
387        }
388        (b"Btn", WriteValue::Radio(export)) if flags & 0x8000 != 0 => {
389            apply_radio(doc, &located, name, export)
390        }
391        (b"Ch", WriteValue::Choice(text)) => apply_choice(doc, &located, name, text, flags),
392        (b"Sig", _) => Err(WritebackError::WrongType {
393            name: name.to_string(),
394            actual: "signature",
395            requested: value.kind(),
396        }),
397        (actual_ft, v) => Err(WritebackError::WrongType {
398            name: name.to_string(),
399            actual: match actual_ft {
400                b"Tx" => "text",
401                b"Btn" if flags & 0x10000 != 0 => "push-button",
402                b"Btn" if flags & 0x8000 != 0 => "radio",
403                b"Btn" => "checkbox",
404                b"Ch" => "choice",
405                _ => "unknown",
406            },
407            requested: v.kind(),
408        }),
409    }
410}
411
412/// Set multiple selected values on a multi-select list box (`/Ff` bit 22).
413///
414/// Writes `/V` as an array of text strings and rebuilds `/I` as the sorted,
415/// de-duplicated zero-based indices of the selected values within `/Opt`
416/// (PDF 32000-1 §12.7.4.4 — Acrobat writes both, and a consistent `/I`
417/// drives correct visual selection in viewers that honour it, disambiguating
418/// duplicate display strings). Per-option highlight appearance is viewer-native,
419/// so `/NeedAppearances` is set rather than synthesising an `/AP`.
420///
421/// Rejects read-only fields, non-choice fields, single-select list boxes, and
422/// (for non-editable fields) values absent from `/Opt`. Passing an empty
423/// `values` slice clears the selection (`/V` becomes an empty array and `/I`
424/// is removed).
425pub fn apply_choice_multi(
426    doc: &mut Document,
427    name: &str,
428    values: &[String],
429) -> Result<WriteOutcome, WritebackError> {
430    ensure_indirect_acroform(doc);
431    let fields = collect_fields(doc)?;
432    let located = fields
433        .iter()
434        .find(|l| l.fqn == name)
435        .cloned()
436        .ok_or_else(|| WritebackError::FieldNotFound(name.to_string()))?;
437
438    let dict = doc
439        .get_object(located.id)
440        .and_then(|o| o.as_dict())
441        .map_err(|_| WritebackError::Malformed("field object is not a dictionary".into()))?;
442
443    if effective_flags(doc, dict) & 0x1 != 0 {
444        return Err(WritebackError::ReadOnly(name.to_string()));
445    }
446
447    let ft = effective_field_type(doc, dict).unwrap_or_else(|| b"Tx".to_vec());
448    let flags = effective_flags(doc, dict);
449    // Bit 22 (0-indexed) = multi-select flag.
450    if ft.as_slice() != b"Ch" || flags & 0x200000 == 0 {
451        return Err(WritebackError::WrongType {
452            name: name.to_string(),
453            actual: match ft.as_slice() {
454                b"Ch" => "single-select choice",
455                b"Tx" => "text",
456                b"Btn" => "button",
457                _ => "unknown",
458            },
459            requested: "multi-select choice",
460        });
461    }
462
463    // Resolve /Opt once: it validates non-editable selections and rebuilds /I.
464    let options = {
465        let d = field_dict(doc, &located)?;
466        choice_options(doc, d)
467    };
468    let editable = flags & 0x40000 != 0;
469    if !editable {
470        for v in values {
471            let known = options
472                .iter()
473                .any(|(export, display)| export == v || display == v);
474            if !known {
475                return Err(WritebackError::InvalidOption {
476                    name: name.to_string(),
477                    value: v.clone(),
478                });
479            }
480        }
481    }
482
483    // Write /V as an array of text strings.
484    let v_obj = Object::Array(values.iter().map(|s| lopdf::text_string(s)).collect());
485    set_field_v(doc, located.id, v_obj)?;
486
487    // Rebuild /I as the sorted, de-duplicated zero-based indices into /Opt of
488    // the selected values. Values not present in /Opt (free-text entries on an
489    // editable list box) contribute no index and live in /V only.
490    let mut indices: Vec<i64> = values
491        .iter()
492        .filter_map(|v| {
493            options
494                .iter()
495                .position(|(export, display)| export == v || display == v)
496                .map(|i| i as i64)
497        })
498        .collect();
499    indices.sort_unstable();
500    indices.dedup();
501    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(located.id) {
502        if indices.is_empty() {
503            // No resolvable indices — drop the stale cache so it can't
504            // contradict /V.
505            d.remove(b"I");
506        } else {
507            d.set(
508                b"I".to_vec(),
509                Object::Array(indices.into_iter().map(Object::Integer).collect()),
510            );
511        }
512    }
513    // Per-option highlight appearance is viewer-native; delegate via NeedAppearances.
514    set_need_appearances(doc, true)?;
515
516    Ok(WriteOutcome {
517        appearances_generated: 0,
518        appearance_states_set: 0,
519        need_appearances_fallback: true,
520    })
521}
522
523/// Regenerate `/AP /N` for every filled text/choice field in the document.
524///
525/// For documents filled by tools that only wrote `/V` (+`/NeedAppearances`),
526/// this materialises trustworthy appearances so the document renders
527/// correctly in viewers — including this SDK's own renderer — that do not
528/// honour `/NeedAppearances`. Fields whose value is not
529/// WinAnsi-representable are left for the viewer (counted in the outcome's
530/// `need_appearances_fallback`).
531pub fn regenerate_appearances(doc: &mut Document) -> Result<WriteOutcome, WritebackError> {
532    ensure_indirect_acroform(doc);
533    let fields = collect_fields(doc)?;
534    let mut outcome = WriteOutcome::default();
535    for located in &fields {
536        let Ok(dict) = doc.get_object(located.id).and_then(|o| o.as_dict()) else {
537            continue;
538        };
539        // Terminal fields only: a node with field-kids is a group.
540        let has_field_kids = located.kids.iter().any(|&k| {
541            doc.get_object(k)
542                .and_then(|o| o.as_dict())
543                .map(|d| d.get(b"T").is_ok())
544                .unwrap_or(false)
545        });
546        if has_field_kids {
547            continue;
548        }
549        let ft = effective_field_type(doc, dict).unwrap_or_default();
550        if ft != b"Tx" && ft != b"Ch" {
551            continue;
552        }
553        let value = match inherited(doc, dict, b"V") {
554            Some(v @ Object::String(..)) => lopdf::decode_text_string(&v).unwrap_or_default(),
555            _ => continue,
556        };
557        if value.is_empty() {
558            continue;
559        }
560        match generate_text_widget_appearances(doc, located, &value) {
561            Ok(n) => outcome.appearances_generated += n,
562            Err(_) => outcome.need_appearances_fallback = true,
563        }
564    }
565    Ok(outcome)
566}
567
568// ---------------------------------------------------------------------------
569// Per-type application
570// ---------------------------------------------------------------------------
571
572fn apply_text(
573    doc: &mut Document,
574    located: &Located,
575    text: &str,
576) -> Result<WriteOutcome, WritebackError> {
577    // Enforce /MaxLen (inherited) by truncation, matching viewer behavior.
578    let max_len = {
579        let dict = field_dict(doc, located)?;
580        match inherited(doc, dict, b"MaxLen") {
581            Some(Object::Integer(n)) if n >= 0 => Some(n as usize),
582            _ => None,
583        }
584    };
585    let text: String = match max_len {
586        Some(n) => text.chars().take(n).collect(),
587        None => text.to_string(),
588    };
589
590    set_field_v(doc, located.id, lopdf::text_string(&text))?;
591
592    let mut outcome = WriteOutcome::default();
593    match generate_text_widget_appearances(doc, located, &text) {
594        Ok(n) => outcome.appearances_generated = n,
595        Err(NotWinAnsi) => {
596            // Encoding fallback: never leave a stale appearance showing the
597            // old value; defer drawing to the viewer.
598            remove_widget_appearances(doc, located);
599            set_need_appearances(doc, true)?;
600            outcome.need_appearances_fallback = true;
601        }
602    }
603    Ok(outcome)
604}
605
606fn apply_checkbox(
607    doc: &mut Document,
608    located: &Located,
609    on: bool,
610) -> Result<WriteOutcome, WritebackError> {
611    let widgets = widget_ids(doc, located);
612    let on_state = widgets
613        .iter()
614        .find_map(|&w| on_state_of_widget(doc, w))
615        .unwrap_or_else(|| b"Yes".to_vec());
616    let state: &[u8] = if on { &on_state } else { b"Off" };
617
618    set_field_v(doc, located.id, Object::Name(state.to_vec()))?;
619
620    let mut outcome = WriteOutcome::default();
621    for &w in &widgets {
622        // With a substate dict, /AS may only name an existing key; without
623        // one (malformed checkbox with a bare stream /N), keep /AS consistent
624        // with /V anyway — viewers ignore /AS for stream-/N appearances, and
625        // appearance-regenerating processors read it (pdfium CheckControl
626        // behaves the same).
627        let has_substates = {
628            let dict = doc.get_object(w).and_then(|o| o.as_dict()).ok();
629            dict.and_then(|d| appearance_normal_dict(doc, d)).is_some()
630        };
631        let on_here = on && (!has_substates || widget_has_state(doc, w, state));
632        let widget_state: &[u8] = if on_here { state } else { b"Off" };
633        if set_widget_as(doc, w, widget_state) {
634            outcome.appearance_states_set += 1;
635        }
636    }
637    Ok(outcome)
638}
639
640fn apply_radio(
641    doc: &mut Document,
642    located: &Located,
643    name: &str,
644    export: &str,
645) -> Result<WriteOutcome, WritebackError> {
646    let widgets = widget_ids(doc, located);
647
648    // Resolve the byte-exact on-state: try the caller's string as raw UTF-8
649    // bytes first, then WinAnsi-encoded (Latin-1 state names like
650    // `coöperatie…` are stored as single 0xF6 bytes in real forms).
651    let candidates: Vec<Vec<u8>> = {
652        let mut c = vec![export.as_bytes().to_vec()];
653        if let Some(w) = encode_winansi(export) {
654            if w != export.as_bytes() {
655                c.push(w);
656            }
657        }
658        c
659    };
660    // A group where no widget declares a substate dictionary is degenerate
661    // (no /AP at all — fixture-grade or broken forms). There is nothing to
662    // validate against or to /AS-select; accept the export verbatim so /V
663    // round-trips, matching the reference implementations' programmatic
664    // paths.
665    let any_substates = widgets.iter().any(|&w| {
666        doc.get_object(w)
667            .and_then(|o| o.as_dict())
668            .ok()
669            .and_then(|d| appearance_normal_dict(doc, d))
670            .is_some()
671    });
672    let chosen = if any_substates {
673        candidates
674            .into_iter()
675            .find(|cand| widgets.iter().any(|&w| widget_has_state(doc, w, cand)))
676            .ok_or_else(|| WritebackError::InvalidOption {
677                name: name.to_string(),
678                value: export.to_string(),
679            })?
680    } else {
681        export.as_bytes().to_vec()
682    };
683
684    // /V (a Name) on the group head; /AS per kid widget (§12.7.4.2.3).
685    set_field_v(doc, located.id, Object::Name(chosen.clone()))?;
686
687    let mut outcome = WriteOutcome::default();
688    for &w in &widgets {
689        let state: &[u8] = if widget_has_state(doc, w, &chosen) {
690            &chosen
691        } else {
692            b"Off"
693        };
694        if set_widget_as(doc, w, state) {
695            outcome.appearance_states_set += 1;
696        }
697    }
698    Ok(outcome)
699}
700
701fn apply_choice(
702    doc: &mut Document,
703    located: &Located,
704    name: &str,
705    text: &str,
706    flags: i64,
707) -> Result<WriteOutcome, WritebackError> {
708    // Validate against /Opt unless the combo is editable (Edit flag bit 19).
709    let editable = flags & 0x40000 != 0;
710    let options = {
711        let dict = field_dict(doc, located)?;
712        choice_options(doc, dict)
713    };
714    if !editable && !options.is_empty() {
715        let known = options
716            .iter()
717            .any(|(export, display)| export == text || display == text);
718        if !known {
719            return Err(WritebackError::InvalidOption {
720                name: name.to_string(),
721                value: text.to_string(),
722            });
723        }
724    }
725
726    set_field_v(doc, located.id, lopdf::text_string(text))?;
727    // Stale selected-index cache is only valid against the old /V.
728    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(located.id) {
729        d.remove(b"I");
730    }
731
732    let mut outcome = WriteOutcome::default();
733    // Display text: prefer the display string of a matching export value.
734    let display = options
735        .iter()
736        .find(|(export, _)| export == text)
737        .map(|(_, d)| d.clone())
738        .unwrap_or_else(|| text.to_string());
739    match generate_text_widget_appearances(doc, located, &display) {
740        Ok(n) => outcome.appearances_generated = n,
741        Err(NotWinAnsi) => {
742            remove_widget_appearances(doc, located);
743            set_need_appearances(doc, true)?;
744            outcome.need_appearances_fallback = true;
745        }
746    }
747    Ok(outcome)
748}
749
750// ---------------------------------------------------------------------------
751// Shared mutation helpers
752// ---------------------------------------------------------------------------
753
754fn field_dict<'a>(doc: &'a Document, located: &Located) -> Result<&'a Dictionary, WritebackError> {
755    doc.get_object(located.id)
756        .and_then(|o| o.as_dict())
757        .map_err(|_| WritebackError::Malformed("field object vanished".into()))
758}
759
760fn set_field_v(doc: &mut Document, id: ObjectId, value: Object) -> Result<(), WritebackError> {
761    match doc.get_object_mut(id) {
762        Ok(Object::Dictionary(d)) => {
763            d.set("V", value);
764            Ok(())
765        }
766        _ => Err(WritebackError::Malformed(
767            "field object is not mutable".into(),
768        )),
769    }
770}
771
772fn set_need_appearances(doc: &mut Document, value: bool) -> Result<(), WritebackError> {
773    let af_id = acroform_id(doc)?;
774    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(af_id) {
775        d.set("NeedAppearances", Object::Boolean(value));
776    }
777    Ok(())
778}
779
780fn remove_widget_appearances(doc: &mut Document, located: &Located) {
781    for w in widget_ids(doc, located) {
782        if let Ok(Object::Dictionary(d)) = doc.get_object_mut(w) {
783            d.remove(b"AP");
784        }
785    }
786}
787
788/// `(export, display)` pairs from `/Opt` (inherited).
789fn choice_options(doc: &Document, dict: &Dictionary) -> Vec<(String, String)> {
790    let Some(Object::Array(arr)) = inherited(doc, dict, b"Opt") else {
791        return Vec::new();
792    };
793    arr.iter()
794        .filter_map(|o| {
795            let resolved = match o {
796                Object::Reference(id) => doc.get_object(*id).ok()?,
797                other => other,
798            };
799            match resolved {
800                Object::String(..) => {
801                    let s = lopdf::decode_text_string(resolved).ok()?;
802                    Some((s.clone(), s))
803                }
804                Object::Array(pair) if pair.len() >= 2 => {
805                    let export = lopdf::decode_text_string(&pair[0]).ok()?;
806                    let display = lopdf::decode_text_string(&pair[1]).ok()?;
807                    Some((export, display))
808                }
809                _ => None,
810            }
811        })
812        .collect()
813}
814
815// ---------------------------------------------------------------------------
816// Appearance generation (text + choice widgets)
817// ---------------------------------------------------------------------------
818
819/// Marker error: the value is not WinAnsi-representable, generation skipped.
820struct NotWinAnsi;
821
822/// Generate and install `/AP /N` on every widget of a text/choice field.
823/// Returns the number of widgets updated.
824fn generate_text_widget_appearances(
825    doc: &mut Document,
826    located: &Located,
827    text: &str,
828) -> Result<usize, NotWinAnsi> {
829    // Multiline values are encoded per line; the encoder rejects what the
830    // Standard-14 WinAnsi fonts cannot show.
831    let encoded_lines: Vec<Vec<u8>> = {
832        let mut lines = Vec::new();
833        for line in text.split('\n') {
834            let line = line.strip_suffix('\r').unwrap_or(line);
835            lines.push(encode_winansi(line).ok_or(NotWinAnsi)?);
836        }
837        lines
838    };
839
840    let Ok(dict) = doc.get_object(located.id).and_then(|o| o.as_dict()) else {
841        return Ok(0);
842    };
843    let flags = effective_flags(doc, dict);
844    let da_string = match inherited(doc, dict, b"DA") {
845        Some(v @ Object::String(..)) => lopdf::decode_text_string(&v).unwrap_or_default(),
846        _ => acroform_da(doc).unwrap_or_default(),
847    };
848    let da = parse_da(if da_string.is_empty() {
849        "/Helv 0 Tf 0 g"
850    } else {
851        &da_string
852    });
853    let quadding = match inherited(doc, dict, b"Q") {
854        Some(Object::Integer(1)) => 1u8,
855        Some(Object::Integer(2)) => 2u8,
856        _ => 0u8,
857    };
858    let max_len = match inherited(doc, dict, b"MaxLen") {
859        Some(Object::Integer(n)) if n > 0 => Some(n as u32),
860        _ => None,
861    };
862    let multiline = flags & 0x1000 != 0;
863    let comb = flags & 0x100_0000 != 0 && max_len.is_some() && !multiline;
864    let password = flags & 0x2000 != 0;
865
866    let widgets = widget_ids(doc, located);
867    let mut updated = 0;
868    for &w in &widgets {
869        let rect = match widget_rect(doc, w) {
870            Some(r) => r,
871            None => continue,
872        };
873        let content = build_text_appearance_content(
874            &encoded_lines,
875            rect,
876            &da,
877            quadding,
878            comb,
879            max_len,
880            multiline,
881            password,
882        );
883        let font_alias = da.font_name.clone().unwrap_or_else(|| "Helv".to_string());
884        install_widget_appearance(doc, w, rect, content, &font_alias);
885        updated += 1;
886    }
887    Ok(updated)
888}
889
890fn acroform_da(doc: &Document) -> Option<String> {
891    let af_id = acroform_id(doc).ok()?;
892    let af = doc.get_object(af_id).and_then(|o| o.as_dict()).ok()?;
893    let v = af.get(b"DA").ok()?;
894    lopdf::decode_text_string(v).ok()
895}
896
897fn widget_rect(doc: &Document, widget: ObjectId) -> Option<[f32; 4]> {
898    let d = doc.get_object(widget).and_then(|o| o.as_dict()).ok()?;
899    let Ok(Object::Array(arr)) = d.get(b"Rect") else {
900        return None;
901    };
902    if arr.len() != 4 {
903        return None;
904    }
905    let mut r = [0f32; 4];
906    for (i, o) in arr.iter().enumerate() {
907        r[i] = match o {
908            Object::Integer(n) => *n as f32,
909            Object::Real(f) => *f,
910            _ => return None,
911        };
912    }
913    // Normalize: PDF rects may have swapped corners.
914    Some([
915        r[0].min(r[2]),
916        r[1].min(r[3]),
917        r[0].max(r[2]),
918        r[1].max(r[3]),
919    ])
920}
921
922/// Build the `/Tx BMC … EMC` content stream for a text-ish widget.
923#[allow(clippy::too_many_arguments)]
924fn build_text_appearance_content(
925    lines: &[Vec<u8>],
926    rect: [f32; 4],
927    da: &DefaultAppearance,
928    quadding: u8,
929    comb: bool,
930    max_len: Option<u32>,
931    multiline: bool,
932    password: bool,
933) -> Vec<u8> {
934    let w = rect[2] - rect[0];
935    let h = rect[3] - rect[1];
936    let face = StandardFace::from_font_name(da.font_name.as_deref().unwrap_or("Helv"));
937    let inset = TEXT_INSET;
938    let inner_w = (w - 2.0 * inset).max(1.0);
939    let inner_h = (h - 2.0 * inset).max(1.0);
940
941    // Password fields render bullets/asterisks; mirror Acrobat's asterisks.
942    let display_lines: Vec<Vec<u8>> = if password {
943        lines
944            .iter()
945            .map(|l| vec![b'*'; l.iter().filter(|&&b| b != b'\r').count()])
946            .collect()
947    } else {
948        lines.to_vec()
949    };
950
951    // Font size: explicit from DA, else auto-size (0 Tf).
952    let font_size = if da.font_size > 0.0 {
953        da.font_size
954    } else if multiline {
955        // mupdf uses a fixed 12pt for auto-sized multiline.
956        12.0_f32.min(inner_h)
957    } else {
958        // pdf.js: min(h/LINE_FACTOR, w/textWidth), LINE_FACTOR = 1.35.
959        let longest_units: u32 = display_lines
960            .iter()
961            .map(|l| l.iter().map(|&b| face.glyph_width(b) as u32).sum())
962            .max()
963            .unwrap_or(0);
964        let by_height = inner_h / 1.35;
965        let by_width = if longest_units > 0 {
966            inner_w * 1000.0 / longest_units as f32
967        } else {
968            by_height
969        };
970        by_height.min(by_width).clamp(2.0, 144.0)
971    };
972
973    let mut buf = Vec::with_capacity(256);
974    let _ = writeln!(buf, "/Tx BMC");
975    buf.extend_from_slice(b"q\n");
976    // Clip to the inner box so overlong values cannot paint outside the
977    // widget (ISO 32000-1 §12.7.3.3: text shall be clipped to the BBox).
978    let _ = writeln!(buf, "{inset} {inset} {inner_w} {inner_h} re W n");
979    buf.extend_from_slice(b"BT\n");
980    write_da_color(&mut buf, da);
981    let font_alias = da.font_name.as_deref().unwrap_or("Helv");
982    let _ = writeln!(buf, "/{font_alias} {font_size} Tf");
983
984    if comb {
985        // Comb cells span the FULL widget width (Acrobat behavior), one
986        // glyph centered per cell.
987        let cells = max_len.unwrap_or(1).max(1);
988        let cell_w = w / cells as f32;
989        let baseline = (h - font_size) / 2.0 + font_size * 0.22;
990        let line = display_lines.first().cloned().unwrap_or_default();
991        let mut prev_x = 0.0_f32;
992        let mut prev_y = 0.0_f32;
993        for (i, &byte) in line.iter().take(cells as usize).enumerate() {
994            let glyph_w = face.glyph_width(byte) as f32 * font_size / 1000.0;
995            let x = cell_w * i as f32 + (cell_w - glyph_w) / 2.0;
996            let _ = writeln!(buf, "{} {} Td", x - prev_x, baseline - prev_y);
997            prev_x = x;
998            prev_y = baseline;
999            let esc = escape_string_bytes(&[byte]);
1000            buf.extend_from_slice(b"(");
1001            buf.extend_from_slice(&esc);
1002            buf.extend_from_slice(b") Tj\n");
1003        }
1004    } else if multiline {
1005        let leading = font_size * 1.2;
1006        let _ = writeln!(buf, "{leading} TL");
1007        // Word-wrap each logical line to the inner width.
1008        let wrapped = wrap_lines(&display_lines, face, font_size, inner_w);
1009        let first_baseline = h - inset - font_size;
1010        let _ = writeln!(buf, "{inset} {first_baseline} Td");
1011        for (i, line) in wrapped.iter().enumerate() {
1012            if i > 0 {
1013                buf.extend_from_slice(b"T*\n");
1014            }
1015            let esc = escape_string_bytes(line);
1016            buf.extend_from_slice(b"(");
1017            buf.extend_from_slice(&esc);
1018            buf.extend_from_slice(b") Tj\n");
1019        }
1020    } else {
1021        let line = display_lines.first().cloned().unwrap_or_default();
1022        let text_w = face.text_width(&line, font_size);
1023        let x = match quadding {
1024            1 => inset + (inner_w - text_w) / 2.0,
1025            2 => inset + inner_w - text_w,
1026            _ => inset,
1027        }
1028        .max(inset);
1029        let baseline = (h - font_size) / 2.0 + font_size * 0.22;
1030        let _ = writeln!(buf, "{x} {baseline} Td");
1031        let esc = escape_string_bytes(&line);
1032        buf.extend_from_slice(b"(");
1033        buf.extend_from_slice(&esc);
1034        buf.extend_from_slice(b") Tj\n");
1035    }
1036
1037    buf.extend_from_slice(b"ET\nQ\nEMC\n");
1038    buf
1039}
1040
1041/// Greedy word-wrap of encoded lines to `width` points at `font_size`.
1042fn wrap_lines(lines: &[Vec<u8>], face: StandardFace, font_size: f32, width: f32) -> Vec<Vec<u8>> {
1043    let mut out = Vec::new();
1044    for line in lines {
1045        if line.is_empty() {
1046            out.push(Vec::new());
1047            continue;
1048        }
1049        let mut current: Vec<u8> = Vec::new();
1050        for word in line.split(|&b| b == b' ') {
1051            let candidate_len = if current.is_empty() {
1052                face.text_width(word, font_size)
1053            } else {
1054                face.text_width(&current, font_size)
1055                    + face.glyph_width(b' ') as f32 * font_size / 1000.0
1056                    + face.text_width(word, font_size)
1057            };
1058            if !current.is_empty() && candidate_len > width {
1059                out.push(std::mem::take(&mut current));
1060            }
1061            if !current.is_empty() {
1062                current.push(b' ');
1063            }
1064            current.extend_from_slice(word);
1065        }
1066        out.push(current);
1067    }
1068    out
1069}
1070
1071fn write_da_color(buf: &mut Vec<u8>, da: &DefaultAppearance) {
1072    let op = match (da.color.len(), da.color_op.as_deref()) {
1073        (1, Some("g")) => "g",
1074        (3, Some("rg")) => "rg",
1075        (4, Some("k")) => "k",
1076        _ => {
1077            buf.extend_from_slice(b"0 g\n");
1078            return;
1079        }
1080    };
1081    for c in &da.color {
1082        let _ = write!(buf, "{c} ");
1083    }
1084    let _ = writeln!(buf, "{op}");
1085}
1086
1087/// Wrap content in a Form XObject with proper `/Resources /Font` and install
1088/// it as the widget's `/AP /N`.
1089fn install_widget_appearance(
1090    doc: &mut Document,
1091    widget: ObjectId,
1092    rect: [f32; 4],
1093    content: Vec<u8>,
1094    font_alias: &str,
1095) {
1096    let w = rect[2] - rect[0];
1097    let h = rect[3] - rect[1];
1098
1099    // Resources: ALWAYS bind the DA alias to our own self-contained
1100    // Standard-14 font dict with explicit /Encoding /WinAnsiEncoding,
1101    // shadowing the form-level /DR entry. The show-text bytes in the content
1102    // are WinAnsi-encoded, and real-world /DR fonts routinely carry other
1103    // encodings (PDFDocEncoding-style /Differences are common in
1104    // government forms) — referencing them would silently remap our bytes
1105    // (€ 0x80 → bullet). pdfium's GenerateFallbackFontDict makes the same
1106    // choice. The DA's face is preserved through the BaseFont mapping; DA
1107    // fonts outside the Standard-14 render as their nearest standard face.
1108    let face = StandardFace::from_font_name(font_alias);
1109    let font_obj = Object::Dictionary(dictionary! {
1110        "Type" => Object::Name(b"Font".to_vec()),
1111        "Subtype" => Object::Name(b"Type1".to_vec()),
1112        "BaseFont" => Object::Name(face.base_font_name().as_bytes().to_vec()),
1113        "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec()),
1114    });
1115    let mut fonts = Dictionary::new();
1116    fonts.set(font_alias.as_bytes().to_vec(), font_obj);
1117    let resources = dictionary! {
1118        "Font" => Object::Dictionary(fonts),
1119    };
1120    let xobj = Stream::new(
1121        dictionary! {
1122            "Type" => Object::Name(b"XObject".to_vec()),
1123            "Subtype" => Object::Name(b"Form".to_vec()),
1124            "BBox" => Object::Array(vec![
1125                Object::Real(0.0), Object::Real(0.0), Object::Real(w), Object::Real(h),
1126            ]),
1127            "Resources" => Object::Dictionary(resources),
1128        },
1129        content,
1130    );
1131    let ap_ref = doc.add_object(Object::Stream(xobj));
1132    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(widget) {
1133        let mut ap = Dictionary::new();
1134        ap.set("N", Object::Reference(ap_ref));
1135        d.set("AP", Object::Dictionary(ap));
1136    }
1137}