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