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)]
128pub(crate) struct Located {
129    pub(crate) id: ObjectId,
130    pub(crate) fqn: String,
131    /// Direct kids that are themselves located (for widget enumeration).
132    pub(crate) 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.
158pub(crate) fn 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.
176pub(crate) fn 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.
254pub(crate) fn 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
276pub(crate) fn 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). The list is drawn into `/AP /N` (visible rows,
419/// highlighted selection); `/NeedAppearances` is set only when an option cannot
420/// be drawn with the Standard-14 WinAnsi fonts.
421///
422/// Rejects read-only fields, non-choice fields, single-select list boxes, and
423/// (for non-editable fields) values absent from `/Opt`. Passing an empty
424/// `values` slice clears the selection (`/V` becomes an empty array and `/I`
425/// is removed).
426pub fn apply_choice_multi(
427    doc: &mut Document,
428    name: &str,
429    values: &[String],
430) -> Result<WriteOutcome, WritebackError> {
431    ensure_indirect_acroform(doc);
432    let fields = collect_fields(doc)?;
433    let located = fields
434        .iter()
435        .find(|l| l.fqn == name)
436        .cloned()
437        .ok_or_else(|| WritebackError::FieldNotFound(name.to_string()))?;
438
439    let dict = doc
440        .get_object(located.id)
441        .and_then(|o| o.as_dict())
442        .map_err(|_| WritebackError::Malformed("field object is not a dictionary".into()))?;
443
444    if effective_flags(doc, dict) & 0x1 != 0 {
445        return Err(WritebackError::ReadOnly(name.to_string()));
446    }
447
448    let ft = effective_field_type(doc, dict).unwrap_or_else(|| b"Tx".to_vec());
449    let flags = effective_flags(doc, dict);
450    // Bit 22 (0-indexed) = multi-select flag.
451    if ft.as_slice() != b"Ch" || flags & 0x200000 == 0 {
452        return Err(WritebackError::WrongType {
453            name: name.to_string(),
454            actual: match ft.as_slice() {
455                b"Ch" => "single-select choice",
456                b"Tx" => "text",
457                b"Btn" => "button",
458                _ => "unknown",
459            },
460            requested: "multi-select choice",
461        });
462    }
463
464    // Resolve /Opt once: it validates non-editable selections and rebuilds /I.
465    let options = {
466        let d = field_dict(doc, &located)?;
467        choice_options(doc, d)
468    };
469    let editable = flags & 0x40000 != 0;
470    if !editable {
471        for v in values {
472            let known = options
473                .iter()
474                .any(|(export, display)| export == v || display == v);
475            if !known {
476                return Err(WritebackError::InvalidOption {
477                    name: name.to_string(),
478                    value: v.clone(),
479                });
480            }
481        }
482    }
483
484    // Write /V as an array of text strings.
485    let v_obj = Object::Array(values.iter().map(|s| lopdf::text_string(s)).collect());
486    set_field_v(doc, located.id, v_obj)?;
487
488    // Rebuild /I as the sorted, de-duplicated zero-based indices into /Opt of
489    // the selected values. Values not present in /Opt (free-text entries on an
490    // editable list box) contribute no index and live in /V only.
491    let mut indices: Vec<i64> = values
492        .iter()
493        .filter_map(|v| {
494            options
495                .iter()
496                .position(|(export, display)| export == v || display == v)
497                .map(|i| i as i64)
498        })
499        .collect();
500    indices.sort_unstable();
501    indices.dedup();
502    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(located.id) {
503        if indices.is_empty() {
504            // No resolvable indices — drop the stale cache so it can't
505            // contradict /V.
506            d.remove(b"I");
507        } else {
508            d.set(
509                b"I".to_vec(),
510                Object::Array(indices.into_iter().map(Object::Integer).collect()),
511            );
512        }
513    }
514    // Draw the list ourselves so flatten and viewers that ignore
515    // /NeedAppearances show the selection (#707). Display strings the
516    // Standard-14 WinAnsi fonts cannot draw fall back to the viewer.
517    let selected: Vec<usize> = values
518        .iter()
519        .filter_map(|v| {
520            options
521                .iter()
522                .position(|(export, display)| export == v || display == v)
523        })
524        .collect();
525    let mut outcome = WriteOutcome::default();
526    match generate_listbox_widget_appearances(doc, &located, &options, &selected) {
527        Ok(n) => outcome.appearances_generated = n,
528        Err(NotWinAnsi) => {
529            remove_widget_appearances(doc, &located);
530            set_need_appearances(doc, true)?;
531            outcome.need_appearances_fallback = true;
532        }
533    }
534    Ok(outcome)
535}
536
537/// Regenerate `/AP /N` for every filled text/choice field in the document.
538///
539/// For documents filled by tools that only wrote `/V` (+`/NeedAppearances`),
540/// this materialises trustworthy appearances so the document renders
541/// correctly in viewers — including this SDK's own renderer — that do not
542/// honour `/NeedAppearances`. Fields whose value is not
543/// WinAnsi-representable are left for the viewer (counted in the outcome's
544/// `need_appearances_fallback`).
545pub fn regenerate_appearances(doc: &mut Document) -> Result<WriteOutcome, WritebackError> {
546    ensure_indirect_acroform(doc);
547    let fields = collect_fields(doc)?;
548    let mut outcome = WriteOutcome::default();
549    for located in &fields {
550        let Ok(dict) = doc.get_object(located.id).and_then(|o| o.as_dict()) else {
551            continue;
552        };
553        // Terminal fields only: a node with field-kids is a group.
554        let has_field_kids = located.kids.iter().any(|&k| {
555            doc.get_object(k)
556                .and_then(|o| o.as_dict())
557                .map(|d| d.get(b"T").is_ok())
558                .unwrap_or(false)
559        });
560        if has_field_kids {
561            continue;
562        }
563        let ft = effective_field_type(doc, dict).unwrap_or_default();
564        if ft != b"Tx" && ft != b"Ch" {
565            continue;
566        }
567        if ft == b"Ch" && effective_flags(doc, dict) & COMBO_FLAG == 0 {
568            // List boxes draw every option, not just the value.
569            let options = choice_options(doc, dict);
570            let selected = listbox_selection(doc, dict, &options);
571            if options.is_empty() || selected.is_empty() {
572                continue;
573            }
574            match generate_listbox_widget_appearances(doc, located, &options, &selected) {
575                Ok(n) => outcome.appearances_generated += n,
576                Err(_) => outcome.need_appearances_fallback = true,
577            }
578            continue;
579        }
580        let value = match inherited(doc, dict, b"V") {
581            Some(v @ Object::String(..)) => lopdf::decode_text_string(&v).unwrap_or_default(),
582            _ => continue,
583        };
584        if value.is_empty() {
585            continue;
586        }
587        match generate_text_widget_appearances(doc, located, &value) {
588            Ok(n) => outcome.appearances_generated += n,
589            Err(_) => outcome.need_appearances_fallback = true,
590        }
591    }
592    Ok(outcome)
593}
594
595// ---------------------------------------------------------------------------
596// Per-type application
597// ---------------------------------------------------------------------------
598
599fn apply_text(
600    doc: &mut Document,
601    located: &Located,
602    text: &str,
603) -> Result<WriteOutcome, WritebackError> {
604    apply_text_with_display(doc, located, text, None)
605}
606
607/// [`apply_text`] with an optional display string: `/V` takes `text`, the
608/// appearance draws `display` (a formatted rendering of the same value, e.g.
609/// from an `AFNumber_Format` action). `/MaxLen` limits the stored value only.
610pub(crate) fn apply_text_with_display(
611    doc: &mut Document,
612    located: &Located,
613    text: &str,
614    display: Option<&str>,
615) -> Result<WriteOutcome, WritebackError> {
616    // Enforce /MaxLen (inherited) by truncation, matching viewer behavior.
617    let max_len = {
618        let dict = field_dict(doc, located)?;
619        match inherited(doc, dict, b"MaxLen") {
620            Some(Object::Integer(n)) if n >= 0 => Some(n as usize),
621            _ => None,
622        }
623    };
624    let text: String = match max_len {
625        Some(n) => text.chars().take(n).collect(),
626        None => text.to_string(),
627    };
628
629    set_field_v(doc, located.id, lopdf::text_string(&text))?;
630
631    let mut outcome = WriteOutcome::default();
632    match generate_text_widget_appearances(doc, located, display.unwrap_or(&text)) {
633        Ok(n) => outcome.appearances_generated = n,
634        Err(NotWinAnsi) => {
635            // Encoding fallback: never leave a stale appearance showing the
636            // old value; defer drawing to the viewer.
637            remove_widget_appearances(doc, located);
638            set_need_appearances(doc, true)?;
639            outcome.need_appearances_fallback = true;
640        }
641    }
642    Ok(outcome)
643}
644
645fn apply_checkbox(
646    doc: &mut Document,
647    located: &Located,
648    on: bool,
649) -> Result<WriteOutcome, WritebackError> {
650    let widgets = widget_ids(doc, located);
651    let on_state = widgets
652        .iter()
653        .find_map(|&w| on_state_of_widget(doc, w))
654        .unwrap_or_else(|| b"Yes".to_vec());
655    let state: &[u8] = if on { &on_state } else { b"Off" };
656
657    set_field_v(doc, located.id, Object::Name(state.to_vec()))?;
658
659    let mut outcome = WriteOutcome::default();
660    for &w in &widgets {
661        // With a substate dict, /AS may only name an existing key; without
662        // one (malformed checkbox with a bare stream /N), keep /AS consistent
663        // with /V anyway — viewers ignore /AS for stream-/N appearances, and
664        // appearance-regenerating processors read it (pdfium CheckControl
665        // behaves the same).
666        let has_substates = {
667            let dict = doc.get_object(w).and_then(|o| o.as_dict()).ok();
668            dict.and_then(|d| appearance_normal_dict(doc, d)).is_some()
669        };
670        let on_here = on && (!has_substates || widget_has_state(doc, w, state));
671        let widget_state: &[u8] = if on_here { state } else { b"Off" };
672        if set_widget_as(doc, w, widget_state) {
673            outcome.appearance_states_set += 1;
674        }
675    }
676    Ok(outcome)
677}
678
679fn apply_radio(
680    doc: &mut Document,
681    located: &Located,
682    name: &str,
683    export: &str,
684) -> Result<WriteOutcome, WritebackError> {
685    let widgets = widget_ids(doc, located);
686
687    // Resolve the byte-exact on-state: try the caller's string as raw UTF-8
688    // bytes first, then WinAnsi-encoded (Latin-1 state names like
689    // `coöperatie…` are stored as single 0xF6 bytes in real forms).
690    let candidates: Vec<Vec<u8>> = {
691        let mut c = vec![export.as_bytes().to_vec()];
692        if let Some(w) = encode_winansi(export) {
693            if w != export.as_bytes() {
694                c.push(w);
695            }
696        }
697        c
698    };
699    // A group where no widget declares a substate dictionary is degenerate
700    // (no /AP at all — fixture-grade or broken forms). There is nothing to
701    // validate against or to /AS-select; accept the export verbatim so /V
702    // round-trips, matching the reference implementations' programmatic
703    // paths.
704    let any_substates = widgets.iter().any(|&w| {
705        doc.get_object(w)
706            .and_then(|o| o.as_dict())
707            .ok()
708            .and_then(|d| appearance_normal_dict(doc, d))
709            .is_some()
710    });
711    let chosen = if any_substates {
712        candidates
713            .into_iter()
714            .find(|cand| widgets.iter().any(|&w| widget_has_state(doc, w, cand)))
715            .ok_or_else(|| WritebackError::InvalidOption {
716                name: name.to_string(),
717                value: export.to_string(),
718            })?
719    } else {
720        export.as_bytes().to_vec()
721    };
722
723    // /V (a Name) on the group head; /AS per kid widget (§12.7.4.2.3).
724    set_field_v(doc, located.id, Object::Name(chosen.clone()))?;
725
726    let mut outcome = WriteOutcome::default();
727    for &w in &widgets {
728        let state: &[u8] = if widget_has_state(doc, w, &chosen) {
729            &chosen
730        } else {
731            b"Off"
732        };
733        if set_widget_as(doc, w, state) {
734            outcome.appearance_states_set += 1;
735        }
736    }
737    Ok(outcome)
738}
739
740fn apply_choice(
741    doc: &mut Document,
742    located: &Located,
743    name: &str,
744    text: &str,
745    flags: i64,
746) -> Result<WriteOutcome, WritebackError> {
747    // Validate against /Opt unless the combo is editable (Edit flag bit 19).
748    let editable = flags & 0x40000 != 0;
749    let options = {
750        let dict = field_dict(doc, located)?;
751        choice_options(doc, dict)
752    };
753    if !editable && !options.is_empty() {
754        let known = options
755            .iter()
756            .any(|(export, display)| export == text || display == text);
757        if !known {
758            return Err(WritebackError::InvalidOption {
759                name: name.to_string(),
760                value: text.to_string(),
761            });
762        }
763    }
764
765    set_field_v(doc, located.id, lopdf::text_string(text))?;
766    // Stale selected-index cache is only valid against the old /V.
767    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(located.id) {
768        d.remove(b"I");
769    }
770
771    let mut outcome = WriteOutcome::default();
772    if flags & COMBO_FLAG == 0 {
773        // A list box shows its options, with the selected one highlighted.
774        let selected: Vec<usize> = options
775            .iter()
776            .position(|(export, display)| export == text || display == text)
777            .into_iter()
778            .collect();
779        match generate_listbox_widget_appearances(doc, located, &options, &selected) {
780            Ok(n) => outcome.appearances_generated = n,
781            Err(NotWinAnsi) => {
782                remove_widget_appearances(doc, located);
783                set_need_appearances(doc, true)?;
784                outcome.need_appearances_fallback = true;
785            }
786        }
787        return Ok(outcome);
788    }
789    // Display text: prefer the display string of a matching export value.
790    let display = options
791        .iter()
792        .find(|(export, _)| export == text)
793        .map(|(_, d)| d.clone())
794        .unwrap_or_else(|| text.to_string());
795    match generate_text_widget_appearances(doc, located, &display) {
796        Ok(n) => outcome.appearances_generated = n,
797        Err(NotWinAnsi) => {
798            remove_widget_appearances(doc, located);
799            set_need_appearances(doc, true)?;
800            outcome.need_appearances_fallback = true;
801        }
802    }
803    Ok(outcome)
804}
805
806// ---------------------------------------------------------------------------
807// Shared mutation helpers
808// ---------------------------------------------------------------------------
809
810fn field_dict<'a>(doc: &'a Document, located: &Located) -> Result<&'a Dictionary, WritebackError> {
811    doc.get_object(located.id)
812        .and_then(|o| o.as_dict())
813        .map_err(|_| WritebackError::Malformed("field object vanished".into()))
814}
815
816fn set_field_v(doc: &mut Document, id: ObjectId, value: Object) -> Result<(), WritebackError> {
817    match doc.get_object_mut(id) {
818        Ok(Object::Dictionary(d)) => {
819            d.set("V", value);
820            Ok(())
821        }
822        _ => Err(WritebackError::Malformed(
823            "field object is not mutable".into(),
824        )),
825    }
826}
827
828fn set_need_appearances(doc: &mut Document, value: bool) -> Result<(), WritebackError> {
829    let af_id = acroform_id(doc)?;
830    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(af_id) {
831        d.set("NeedAppearances", Object::Boolean(value));
832    }
833    Ok(())
834}
835
836fn remove_widget_appearances(doc: &mut Document, located: &Located) {
837    for w in widget_ids(doc, located) {
838        if let Ok(Object::Dictionary(d)) = doc.get_object_mut(w) {
839            d.remove(b"AP");
840        }
841    }
842}
843
844/// `(export, display)` pairs from `/Opt` (inherited).
845fn choice_options(doc: &Document, dict: &Dictionary) -> Vec<(String, String)> {
846    let Some(Object::Array(arr)) = inherited(doc, dict, b"Opt") else {
847        return Vec::new();
848    };
849    arr.iter()
850        .filter_map(|o| {
851            let resolved = match o {
852                Object::Reference(id) => doc.get_object(*id).ok()?,
853                other => other,
854            };
855            match resolved {
856                Object::String(..) => {
857                    let s = lopdf::decode_text_string(resolved).ok()?;
858                    Some((s.clone(), s))
859                }
860                Object::Array(pair) if pair.len() >= 2 => {
861                    let export = lopdf::decode_text_string(&pair[0]).ok()?;
862                    let display = lopdf::decode_text_string(&pair[1]).ok()?;
863                    Some((export, display))
864                }
865                _ => None,
866            }
867        })
868        .collect()
869}
870
871// ---------------------------------------------------------------------------
872// Appearance generation (text + choice widgets)
873// ---------------------------------------------------------------------------
874
875/// Marker error: the value is not WinAnsi-representable, generation skipped.
876struct NotWinAnsi;
877
878/// Generate and install `/AP /N` on every widget of a text/choice field.
879/// Returns the number of widgets updated.
880fn generate_text_widget_appearances(
881    doc: &mut Document,
882    located: &Located,
883    text: &str,
884) -> Result<usize, NotWinAnsi> {
885    // Multiline values are encoded per line; the encoder rejects what the
886    // Standard-14 WinAnsi fonts cannot show.
887    let encoded_lines: Vec<Vec<u8>> = {
888        let mut lines = Vec::new();
889        for line in text.split('\n') {
890            let line = line.strip_suffix('\r').unwrap_or(line);
891            lines.push(encode_winansi(line).ok_or(NotWinAnsi)?);
892        }
893        lines
894    };
895
896    let Ok(dict) = doc.get_object(located.id).and_then(|o| o.as_dict()) else {
897        return Ok(0);
898    };
899    let flags = effective_flags(doc, dict);
900    let da_string = match inherited(doc, dict, b"DA") {
901        Some(v @ Object::String(..)) => lopdf::decode_text_string(&v).unwrap_or_default(),
902        _ => acroform_da(doc).unwrap_or_default(),
903    };
904    let da = parse_da(if da_string.is_empty() {
905        "/Helv 0 Tf 0 g"
906    } else {
907        &da_string
908    });
909    let quadding = match inherited(doc, dict, b"Q") {
910        Some(Object::Integer(1)) => 1u8,
911        Some(Object::Integer(2)) => 2u8,
912        _ => 0u8,
913    };
914    let max_len = match inherited(doc, dict, b"MaxLen") {
915        Some(Object::Integer(n)) if n > 0 => Some(n as u32),
916        _ => None,
917    };
918    let multiline = flags & 0x1000 != 0;
919    let comb = flags & 0x100_0000 != 0 && max_len.is_some() && !multiline;
920    let password = flags & 0x2000 != 0;
921
922    let widgets = widget_ids(doc, located);
923    let mut updated = 0;
924    for &w in &widgets {
925        let rect = match widget_rect(doc, w) {
926            Some(r) => r,
927            None => continue,
928        };
929        let content = build_text_appearance_content(
930            &encoded_lines,
931            rect,
932            &da,
933            quadding,
934            comb,
935            max_len,
936            multiline,
937            password,
938        );
939        let font_alias = da.font_name.clone().unwrap_or_else(|| "Helv".to_string());
940        install_widget_appearance(doc, w, rect, content, &font_alias);
941        updated += 1;
942    }
943    Ok(updated)
944}
945
946/// `/Ff` Combo bit (bit 18): a choice field without it is a list box.
947const COMBO_FLAG: i64 = 0x20000;
948
949/// Indices into `options` that a list box currently selects: the values in
950/// `/V` (a string or an array), else the `/I` cache.
951fn listbox_selection(
952    doc: &Document,
953    dict: &Dictionary,
954    options: &[(String, String)],
955) -> Vec<usize> {
956    let mut values: Vec<String> = Vec::new();
957    match inherited(doc, dict, b"V") {
958        Some(v @ Object::String(..)) => {
959            values.extend(lopdf::decode_text_string(&v).ok());
960        }
961        Some(Object::Array(a)) => {
962            for o in &a {
963                values.extend(lopdf::decode_text_string(o).ok());
964            }
965        }
966        _ => {}
967    }
968    let mut out: Vec<usize> = values
969        .iter()
970        .filter_map(|v| options.iter().position(|(e, d)| e == v || d == v))
971        .collect();
972    if out.is_empty() {
973        if let Some(Object::Array(a)) = inherited(doc, dict, b"I") {
974            out = a
975                .iter()
976                .filter_map(|o| match o {
977                    Object::Integer(i) if *i >= 0 && (*i as usize) < options.len() => {
978                        Some(*i as usize)
979                    }
980                    _ => None,
981                })
982                .collect();
983        }
984    }
985    out.sort_unstable();
986    out.dedup();
987    out
988}
989
990/// Generate and install `/AP /N` on every widget of a list box: the visible
991/// rows from `/TI`, a highlight behind each selected row, clipped to the
992/// widget. Returns the number of widgets updated.
993fn generate_listbox_widget_appearances(
994    doc: &mut Document,
995    located: &Located,
996    options: &[(String, String)],
997    selected: &[usize],
998) -> Result<usize, NotWinAnsi> {
999    let mut rows: Vec<Vec<u8>> = Vec::with_capacity(options.len());
1000    for (_, display) in options {
1001        rows.push(encode_winansi(display).ok_or(NotWinAnsi)?);
1002    }
1003    let Ok(dict) = doc.get_object(located.id).and_then(|o| o.as_dict()) else {
1004        return Ok(0);
1005    };
1006    let da_string = match inherited(doc, dict, b"DA") {
1007        Some(v @ Object::String(..)) => lopdf::decode_text_string(&v).unwrap_or_default(),
1008        _ => acroform_da(doc).unwrap_or_default(),
1009    };
1010    let da = parse_da(if da_string.is_empty() {
1011        "/Helv 0 Tf 0 g"
1012    } else {
1013        &da_string
1014    });
1015    let top_index = match inherited(doc, dict, b"TI") {
1016        Some(Object::Integer(n)) if n > 0 => (n as usize).min(rows.len()),
1017        _ => 0,
1018    };
1019
1020    let font_alias = da.font_name.clone().unwrap_or_else(|| "Helv".to_string());
1021    let mut updated = 0;
1022    for w in widget_ids(doc, located) {
1023        let Some(rect) = widget_rect(doc, w) else {
1024            continue;
1025        };
1026        let width = rect[2] - rect[0];
1027        let height = rect[3] - rect[1];
1028        let font_size = if da.font_size > 0.0 {
1029            da.font_size
1030        } else {
1031            12.0
1032        };
1033        let leading = font_size * 1.2;
1034        let inner_w = (width - 2.0 * TEXT_INSET).max(1.0);
1035        let inner_h = (height - 2.0 * TEXT_INSET).max(1.0);
1036
1037        let mut buf = Vec::with_capacity(256);
1038        let _ = writeln!(buf, "/Tx BMC");
1039        buf.extend_from_slice(b"q\n");
1040        let _ = writeln!(buf, "{TEXT_INSET} {TEXT_INSET} {inner_w} {inner_h} re W n");
1041        let first_top = height - TEXT_INSET;
1042        for (row, bytes) in rows.iter().enumerate().skip(top_index) {
1043            let slot = (row - top_index) as f32;
1044            let row_top = first_top - slot * leading;
1045            if row_top <= 0.0 {
1046                break;
1047            }
1048            if selected.contains(&row) {
1049                let _ = writeln!(
1050                    buf,
1051                    "0.6 0.75 0.85 rg {TEXT_INSET} {} {inner_w} {leading} re f",
1052                    row_top - leading
1053                );
1054            }
1055            buf.extend_from_slice(b"BT\n");
1056            write_da_color(&mut buf, &da);
1057            let _ = writeln!(buf, "/{font_alias} {font_size} Tf");
1058            let _ = writeln!(
1059                buf,
1060                "{} {} Td",
1061                TEXT_INSET + 1.0,
1062                row_top - leading + (leading - font_size) / 2.0 + font_size * 0.2
1063            );
1064            buf.extend_from_slice(b"(");
1065            buf.extend_from_slice(&escape_string_bytes(bytes));
1066            buf.extend_from_slice(b") Tj\nET\n");
1067        }
1068        buf.extend_from_slice(b"Q\nEMC\n");
1069        install_widget_appearance(doc, w, rect, buf, &font_alias);
1070        updated += 1;
1071    }
1072    Ok(updated)
1073}
1074
1075fn acroform_da(doc: &Document) -> Option<String> {
1076    let af_id = acroform_id(doc).ok()?;
1077    let af = doc.get_object(af_id).and_then(|o| o.as_dict()).ok()?;
1078    let v = af.get(b"DA").ok()?;
1079    lopdf::decode_text_string(v).ok()
1080}
1081
1082fn widget_rect(doc: &Document, widget: ObjectId) -> Option<[f32; 4]> {
1083    let d = doc.get_object(widget).and_then(|o| o.as_dict()).ok()?;
1084    let Ok(Object::Array(arr)) = d.get(b"Rect") else {
1085        return None;
1086    };
1087    if arr.len() != 4 {
1088        return None;
1089    }
1090    let mut r = [0f32; 4];
1091    for (i, o) in arr.iter().enumerate() {
1092        r[i] = match o {
1093            Object::Integer(n) => *n as f32,
1094            Object::Real(f) => *f,
1095            _ => return None,
1096        };
1097    }
1098    // Normalize: PDF rects may have swapped corners.
1099    Some([
1100        r[0].min(r[2]),
1101        r[1].min(r[3]),
1102        r[0].max(r[2]),
1103        r[1].max(r[3]),
1104    ])
1105}
1106
1107/// Build the `/Tx BMC … EMC` content stream for a text-ish widget.
1108#[allow(clippy::too_many_arguments)]
1109fn build_text_appearance_content(
1110    lines: &[Vec<u8>],
1111    rect: [f32; 4],
1112    da: &DefaultAppearance,
1113    quadding: u8,
1114    comb: bool,
1115    max_len: Option<u32>,
1116    multiline: bool,
1117    password: bool,
1118) -> Vec<u8> {
1119    let w = rect[2] - rect[0];
1120    let h = rect[3] - rect[1];
1121    let face = StandardFace::from_font_name(da.font_name.as_deref().unwrap_or("Helv"));
1122    let inset = TEXT_INSET;
1123    let inner_w = (w - 2.0 * inset).max(1.0);
1124    let inner_h = (h - 2.0 * inset).max(1.0);
1125
1126    // Password fields render bullets/asterisks; mirror Acrobat's asterisks.
1127    let display_lines: Vec<Vec<u8>> = if password {
1128        lines
1129            .iter()
1130            .map(|l| vec![b'*'; l.iter().filter(|&&b| b != b'\r').count()])
1131            .collect()
1132    } else {
1133        lines.to_vec()
1134    };
1135
1136    // Font size: explicit from DA, else auto-size (0 Tf).
1137    let font_size = if da.font_size > 0.0 {
1138        da.font_size
1139    } else if multiline {
1140        // mupdf uses a fixed 12pt for auto-sized multiline.
1141        12.0_f32.min(inner_h)
1142    } else {
1143        // pdf.js: min(h/LINE_FACTOR, w/textWidth), LINE_FACTOR = 1.35.
1144        let longest_units: u32 = display_lines
1145            .iter()
1146            .map(|l| l.iter().map(|&b| face.glyph_width(b) as u32).sum())
1147            .max()
1148            .unwrap_or(0);
1149        let by_height = inner_h / 1.35;
1150        let by_width = if longest_units > 0 {
1151            inner_w * 1000.0 / longest_units as f32
1152        } else {
1153            by_height
1154        };
1155        by_height.min(by_width).clamp(2.0, 144.0)
1156    };
1157
1158    let mut buf = Vec::with_capacity(256);
1159    let _ = writeln!(buf, "/Tx BMC");
1160    buf.extend_from_slice(b"q\n");
1161    // Clip to the inner box so overlong values cannot paint outside the
1162    // widget (ISO 32000-1 §12.7.3.3: text shall be clipped to the BBox).
1163    let _ = writeln!(buf, "{inset} {inset} {inner_w} {inner_h} re W n");
1164    buf.extend_from_slice(b"BT\n");
1165    write_da_color(&mut buf, da);
1166    let font_alias = da.font_name.as_deref().unwrap_or("Helv");
1167    let _ = writeln!(buf, "/{font_alias} {font_size} Tf");
1168
1169    if comb {
1170        // Comb cells span the FULL widget width (Acrobat behavior), one
1171        // glyph centered per cell.
1172        let cells = max_len.unwrap_or(1).max(1);
1173        let cell_w = w / cells as f32;
1174        let baseline = (h - font_size) / 2.0 + font_size * 0.22;
1175        let line = display_lines.first().cloned().unwrap_or_default();
1176        let mut prev_x = 0.0_f32;
1177        let mut prev_y = 0.0_f32;
1178        for (i, &byte) in line.iter().take(cells as usize).enumerate() {
1179            let glyph_w = face.glyph_width(byte) as f32 * font_size / 1000.0;
1180            let x = cell_w * i as f32 + (cell_w - glyph_w) / 2.0;
1181            let _ = writeln!(buf, "{} {} Td", x - prev_x, baseline - prev_y);
1182            prev_x = x;
1183            prev_y = baseline;
1184            let esc = escape_string_bytes(&[byte]);
1185            buf.extend_from_slice(b"(");
1186            buf.extend_from_slice(&esc);
1187            buf.extend_from_slice(b") Tj\n");
1188        }
1189    } else if multiline {
1190        let leading = font_size * 1.2;
1191        let _ = writeln!(buf, "{leading} TL");
1192        // Word-wrap each logical line to the inner width.
1193        let wrapped = wrap_lines(&display_lines, face, font_size, inner_w);
1194        let first_baseline = h - inset - font_size;
1195        let _ = writeln!(buf, "{inset} {first_baseline} Td");
1196        for (i, line) in wrapped.iter().enumerate() {
1197            if i > 0 {
1198                buf.extend_from_slice(b"T*\n");
1199            }
1200            let esc = escape_string_bytes(line);
1201            buf.extend_from_slice(b"(");
1202            buf.extend_from_slice(&esc);
1203            buf.extend_from_slice(b") Tj\n");
1204        }
1205    } else {
1206        let line = display_lines.first().cloned().unwrap_or_default();
1207        let text_w = face.text_width(&line, font_size);
1208        let x = match quadding {
1209            1 => inset + (inner_w - text_w) / 2.0,
1210            2 => inset + inner_w - text_w,
1211            _ => inset,
1212        }
1213        .max(inset);
1214        let baseline = (h - font_size) / 2.0 + font_size * 0.22;
1215        let _ = writeln!(buf, "{x} {baseline} Td");
1216        let esc = escape_string_bytes(&line);
1217        buf.extend_from_slice(b"(");
1218        buf.extend_from_slice(&esc);
1219        buf.extend_from_slice(b") Tj\n");
1220    }
1221
1222    buf.extend_from_slice(b"ET\nQ\nEMC\n");
1223    buf
1224}
1225
1226/// Greedy word-wrap of encoded lines to `width` points at `font_size`.
1227fn wrap_lines(lines: &[Vec<u8>], face: StandardFace, font_size: f32, width: f32) -> Vec<Vec<u8>> {
1228    let mut out = Vec::new();
1229    for line in lines {
1230        if line.is_empty() {
1231            out.push(Vec::new());
1232            continue;
1233        }
1234        let mut current: Vec<u8> = Vec::new();
1235        for word in line.split(|&b| b == b' ') {
1236            let candidate_len = if current.is_empty() {
1237                face.text_width(word, font_size)
1238            } else {
1239                face.text_width(&current, font_size)
1240                    + face.glyph_width(b' ') as f32 * font_size / 1000.0
1241                    + face.text_width(word, font_size)
1242            };
1243            if !current.is_empty() && candidate_len > width {
1244                out.push(std::mem::take(&mut current));
1245            }
1246            if !current.is_empty() {
1247                current.push(b' ');
1248            }
1249            current.extend_from_slice(word);
1250        }
1251        out.push(current);
1252    }
1253    out
1254}
1255
1256fn write_da_color(buf: &mut Vec<u8>, da: &DefaultAppearance) {
1257    let op = match (da.color.len(), da.color_op.as_deref()) {
1258        (1, Some("g")) => "g",
1259        (3, Some("rg")) => "rg",
1260        (4, Some("k")) => "k",
1261        _ => {
1262            buf.extend_from_slice(b"0 g\n");
1263            return;
1264        }
1265    };
1266    for c in &da.color {
1267        let _ = write!(buf, "{c} ");
1268    }
1269    let _ = writeln!(buf, "{op}");
1270}
1271
1272/// Wrap content in a Form XObject with proper `/Resources /Font` and install
1273/// it as the widget's `/AP /N`.
1274fn install_widget_appearance(
1275    doc: &mut Document,
1276    widget: ObjectId,
1277    rect: [f32; 4],
1278    content: Vec<u8>,
1279    font_alias: &str,
1280) {
1281    let w = rect[2] - rect[0];
1282    let h = rect[3] - rect[1];
1283
1284    // Resources: ALWAYS bind the DA alias to our own self-contained
1285    // Standard-14 font dict with explicit /Encoding /WinAnsiEncoding,
1286    // shadowing the form-level /DR entry. The show-text bytes in the content
1287    // are WinAnsi-encoded, and real-world /DR fonts routinely carry other
1288    // encodings (PDFDocEncoding-style /Differences are common in
1289    // government forms) — referencing them would silently remap our bytes
1290    // (€ 0x80 → bullet). pdfium's GenerateFallbackFontDict makes the same
1291    // choice. The DA's face is preserved through the BaseFont mapping; DA
1292    // fonts outside the Standard-14 render as their nearest standard face.
1293    let face = StandardFace::from_font_name(font_alias);
1294    let font_obj = Object::Dictionary(dictionary! {
1295        "Type" => Object::Name(b"Font".to_vec()),
1296        "Subtype" => Object::Name(b"Type1".to_vec()),
1297        "BaseFont" => Object::Name(face.base_font_name().as_bytes().to_vec()),
1298        "Encoding" => Object::Name(b"WinAnsiEncoding".to_vec()),
1299    });
1300    let mut fonts = Dictionary::new();
1301    fonts.set(font_alias.as_bytes().to_vec(), font_obj);
1302    let resources = dictionary! {
1303        "Font" => Object::Dictionary(fonts),
1304    };
1305    let xobj = Stream::new(
1306        dictionary! {
1307            "Type" => Object::Name(b"XObject".to_vec()),
1308            "Subtype" => Object::Name(b"Form".to_vec()),
1309            "BBox" => Object::Array(vec![
1310                Object::Real(0.0), Object::Real(0.0), Object::Real(w), Object::Real(h),
1311            ]),
1312            "Resources" => Object::Dictionary(resources),
1313        },
1314        content,
1315    );
1316    let ap_ref = doc.add_object(Object::Stream(xobj));
1317    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(widget) {
1318        let mut ap = Dictionary::new();
1319        ap.set("N", Object::Reference(ap_ref));
1320        d.set("AP", Object::Dictionary(ap));
1321    }
1322}