Skip to main content

zpdf_document/
forms.rs

1//! AcroForm interactive-form support (PDF 32000-1 §12.7).
2//!
3//! Two responsibilities:
4//!
5//! 1. **Field model** ([`AcroForm`]) — walks `/Root /AcroForm /Fields`,
6//!    resolving the field tree into terminal [`FormField`]s with
7//!    fully-qualified names and inherited attributes (`/FT` `/V` `/DA` `/Ff`
8//!    `/Q`). Each terminal field records its widget-annotation object ids, so a
9//!    consumer can map a page widget back to the field that owns it.
10//!
11//! 2. **Appearance generation** ([`generate_widget_appearance`]) — for text and
12//!    choice fields whose producer left no appearance stream (or set
13//!    `/NeedAppearances`), synthesizes a form XObject that draws the field
14//!    value, honoring the `/DA` font/size/color, `/Q` justification, and the
15//!    multiline / comb flags. The result feeds the interpreter's annotation
16//!    painter exactly like a real `/AP /N` stream.
17//!
18//! Buttons (checkbox/radio) keep their producer-supplied `/AP` states; only the
19//! `/AS` selection is hardened (see the annotation module). Signatures are
20//! modelled but never generate an appearance.
21
22use std::collections::{HashMap, HashSet};
23
24use zpdf_core::{Matrix, ObjectId, PdfDict, PdfName, PdfObject, Rect};
25use zpdf_parser::PdfFile;
26
27/// Hard cap on the field-tree walk depth and total field count — bounds
28/// malformed or adversarial `/Kids` graphs (in concert with the visited set).
29const MAX_FIELD_DEPTH: usize = 50;
30const MAX_FIELDS: usize = 20_000;
31
32// Field flags (`/Ff`, PDF Tables 226/228/230). Bit numbering is 1-based in the
33// spec; the shift is `bit - 1`.
34/// Common: field is read-only.
35pub const FF_READONLY: i64 = 1 << 0;
36/// Tx (bit 13): the text field holds multiple lines.
37pub const FF_MULTILINE: i64 = 1 << 12;
38/// Tx (bit 14): the value is a password — never rendered.
39pub const FF_PASSWORD: i64 = 1 << 13;
40/// Btn (bit 16): radio button (mutually-exclusive set).
41pub const FF_RADIO: i64 = 1 << 15;
42/// Btn (bit 17): push button (no persistent value).
43pub const FF_PUSHBUTTON: i64 = 1 << 16;
44/// Ch (bit 18): combo box (vs. list box).
45pub const FF_COMBO: i64 = 1 << 17;
46/// Tx (bit 25): comb formatting — `/MaxLen` equally-spaced cells.
47pub const FF_COMB: i64 = 1 << 24;
48
49/// The four AcroForm field types (`/FT`), plus an `Unknown` catch-all.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum FieldKind {
52    Text,
53    Button,
54    Choice,
55    Signature,
56    Unknown,
57}
58
59impl FieldKind {
60    pub fn as_str(self) -> &'static str {
61        match self {
62            FieldKind::Text => "Tx",
63            FieldKind::Button => "Btn",
64            FieldKind::Choice => "Ch",
65            FieldKind::Signature => "Sig",
66            FieldKind::Unknown => "?",
67        }
68    }
69}
70
71/// A resolved field value (`/V`).
72#[derive(Debug, Clone, PartialEq)]
73pub enum FieldValue {
74    /// Text fields, combo boxes, single-select list boxes.
75    Text(String),
76    /// Button on/off state (`/Yes`, `/Off`, …).
77    Name(String),
78    /// Multi-select list box: one entry per selected option.
79    List(Vec<String>),
80}
81
82/// A terminal interactive-form field.
83#[derive(Debug, Clone)]
84pub struct FormField {
85    /// The field dictionary's own object id — where `/V` lives (distinct from
86    /// the widget ids when the field has separate widget kids).
87    pub field_id: ObjectId,
88    /// Fully-qualified name: the `/T` partial names of this field and its
89    /// ancestors joined by `.` (PDF 12.7.3.2).
90    pub name: String,
91    pub kind: FieldKind,
92    /// `/Ff` field flags (inherited).
93    pub flags: i64,
94    /// `/V` value (inherited).
95    pub value: Option<FieldValue>,
96    /// `/DA` default appearance string (inherited, falling back to the
97    /// AcroForm-level `/DA`).
98    pub default_appearance: Option<String>,
99    /// `/Q` quadding: 0 left, 1 centered, 2 right (inherited).
100    pub quadding: i64,
101    /// `/MaxLen` (text fields) — also the comb cell count.
102    pub max_len: Option<i64>,
103    /// `/Opt` `(export, display)` pairs (choice fields). For plain-string
104    /// options the two halves are equal.
105    pub options: Vec<(String, String)>,
106    /// Widget-annotation object ids that present this field on a page. When the
107    /// field dict is itself the widget (the common single-widget case), this is
108    /// the field's own object id.
109    pub widgets: Vec<ObjectId>,
110}
111
112impl FormField {
113    /// The string a renderer should draw for this field, or `None` when there
114    /// is nothing to show (no value, the `Off` button state, or empty text).
115    /// Choice values (which store the `/Opt` *export* value) are mapped to their
116    /// human-visible display label (PDF 12.7.4.4).
117    pub fn display_value(&self) -> Option<String> {
118        let s = match self.value.as_ref()? {
119            FieldValue::Text(s) => self.choice_label(s),
120            FieldValue::Name(n) if n != "Off" => n.clone(),
121            FieldValue::Name(_) => return None,
122            FieldValue::List(v) => v
123                .iter()
124                .map(|s| self.choice_label(s))
125                .collect::<Vec<_>>()
126                .join("\n"),
127        };
128        (!s.is_empty()).then_some(s)
129    }
130
131    /// Map a choice export value to its display label, or return it unchanged
132    /// (for text fields, or exports with no matching option).
133    fn choice_label(&self, value: &str) -> String {
134        if self.kind == FieldKind::Choice {
135            if let Some((_, display)) = self.options.iter().find(|(export, _)| export == value) {
136                return display.clone();
137            }
138        }
139        value.to_string()
140    }
141
142    pub fn is_multiline(&self) -> bool {
143        self.kind == FieldKind::Text && self.flags & FF_MULTILINE != 0
144    }
145
146    pub fn is_password(&self) -> bool {
147        self.kind == FieldKind::Text && self.flags & FF_PASSWORD != 0
148    }
149
150    pub fn is_comb(&self) -> bool {
151        self.kind == FieldKind::Text
152            // Comb (bit 25) is meaningful only when Multiline/Password are clear.
153            && self.flags & (FF_COMB | FF_MULTILINE | FF_PASSWORD) == FF_COMB
154            && self.max_len.unwrap_or(0) > 0
155    }
156}
157
158/// The document's interactive form.
159pub struct AcroForm {
160    /// Terminal fields, in document order.
161    pub fields: Vec<FormField>,
162    /// `/NeedAppearances`: the producer relies on the viewer to (re)generate
163    /// appearance streams.
164    pub need_appearances: bool,
165    /// `/DR /Font`: default font resources referenced by `/DA` font names.
166    pub dr_fonts: Option<PdfDict>,
167    /// Widget object id → index into `fields`.
168    widget_owner: HashMap<ObjectId, usize>,
169}
170
171impl AcroForm {
172    /// Parse the document's `/AcroForm`, or `None` when the document has no
173    /// interactive form.
174    pub fn parse(file: &PdfFile) -> Option<AcroForm> {
175        let root_ref = file.trailer.get_ref("Root").ok()?;
176        let root = file.resolve(root_ref).ok()?;
177        let root = root.as_dict().ok()?;
178        let af = deref(file, root.get("AcroForm")?);
179        let af = af.as_dict().ok()?;
180
181        let need_appearances = matches!(af.get("NeedAppearances"), Some(PdfObject::Bool(true)));
182        let dr_fonts = deref_opt(file, af.get("DR"))
183            .and_then(|dr| dr.as_dict().ok().cloned())
184            .and_then(|dr| match dr.get("Font") {
185                Some(obj) => deref(file, obj).as_dict().ok().cloned(),
186                None => None,
187            });
188
189        let root_inherited = Inherited {
190            ft: None,
191            flags: 0,
192            value: None,
193            da: af.get("DA").and_then(|o| text_string(file, o)),
194            quadding: int_value(file, af.get("Q")).unwrap_or(0),
195        };
196
197        let mut state = WalkState {
198            file,
199            fields: Vec::new(),
200            widget_owner: HashMap::new(),
201            visited: HashSet::new(),
202        };
203        if let Some(arr) = deref_array(file, af.get("Fields")) {
204            for obj in &arr {
205                if let PdfObject::Ref(r) = obj {
206                    walk_field(&mut state, *r, "", &root_inherited, 0);
207                }
208            }
209        }
210
211        Some(AcroForm {
212            fields: state.fields,
213            need_appearances,
214            dr_fonts,
215            widget_owner: state.widget_owner,
216        })
217    }
218
219    /// The terminal field presented by the given widget-annotation id.
220    pub fn field_for_widget(&self, id: ObjectId) -> Option<&FormField> {
221        self.widget_owner.get(&id).and_then(|&i| self.fields.get(i))
222    }
223}
224
225/// Attributes inherited down the field tree (PDF 12.7.3.2).
226#[derive(Clone)]
227struct Inherited {
228    ft: Option<String>,
229    flags: i64,
230    value: Option<FieldValue>,
231    da: Option<String>,
232    quadding: i64,
233}
234
235struct WalkState<'a> {
236    file: &'a PdfFile,
237    fields: Vec<FormField>,
238    widget_owner: HashMap<ObjectId, usize>,
239    visited: HashSet<ObjectId>,
240}
241
242fn walk_field(
243    state: &mut WalkState,
244    id: ObjectId,
245    parent_name: &str,
246    inherited: &Inherited,
247    depth: usize,
248) {
249    if depth > MAX_FIELD_DEPTH || state.fields.len() >= MAX_FIELDS {
250        return;
251    }
252    if !state.visited.insert(id) {
253        return; // cycle
254    }
255    let file = state.file;
256    let obj = match file.resolve(id) {
257        Ok(o) => o,
258        Err(_) => return,
259    };
260    let Ok(dict) = obj.as_dict() else { return };
261
262    // Fully-qualified name: append this node's partial name `/T` (if any),
263    // resolving one level of indirection like the other inherited attributes.
264    let partial = dict.get("T").and_then(|o| text_string(file, o));
265    let name = match &partial {
266        Some(t) if parent_name.is_empty() => t.clone(),
267        Some(t) => format!("{parent_name}.{t}"),
268        None => parent_name.to_string(),
269    };
270
271    // Merge inheritable attributes (this node's own values win).
272    let merged = Inherited {
273        ft: dict
274            .get_name("FT")
275            .ok()
276            .map(String::from)
277            .or_else(|| inherited.ft.clone()),
278        flags: int_value(file, dict.get("Ff")).unwrap_or(inherited.flags),
279        value: field_value(file, dict.get("V")).or_else(|| inherited.value.clone()),
280        da: dict
281            .get("DA")
282            .and_then(|o| text_string(file, o))
283            .or_else(|| inherited.da.clone()),
284        quadding: int_value(file, dict.get("Q")).unwrap_or(inherited.quadding),
285    };
286
287    // Classify the kids: those with a `/T` are child *fields* (recurse); those
288    // without are this terminal field's widget annotations.
289    let kids = deref_array(file, dict.get("Kids")).unwrap_or_default();
290    let mut child_fields = Vec::new();
291    let mut widget_kids = Vec::new();
292    for kid in &kids {
293        if let PdfObject::Ref(r) = kid {
294            let kid_obj = file.resolve(*r).ok();
295            let has_t = kid_obj
296                .as_ref()
297                .and_then(|o| o.as_dict().ok())
298                .map(|d| d.get("T").is_some())
299                .unwrap_or(false);
300            if has_t {
301                child_fields.push(*r);
302            } else {
303                widget_kids.push(*r);
304            }
305        }
306    }
307
308    // Descend into child fields (interior node behavior).
309    let has_child_fields = !child_fields.is_empty();
310    for r in child_fields {
311        walk_field(state, r, &name, &merged, depth + 1);
312    }
313
314    // Emit a terminal field for this node's own widgets:
315    //  - its widget-only kids, or
316    //  - the node dict itself when it has no kids at all (merged field+widget).
317    // A pure interior node (only field kids) owns no widgets and emits nothing;
318    // a *mixed* node (both field and widget kids) still maps its own widgets so
319    // their value can be rendered.
320    let widgets = if !widget_kids.is_empty() {
321        widget_kids
322    } else if has_child_fields {
323        Vec::new()
324    } else {
325        vec![id] // the field dict is itself the widget
326    };
327    if widgets.is_empty() {
328        return;
329    }
330
331    let kind = field_kind(merged.ft.as_deref());
332    let options = if kind == FieldKind::Choice {
333        parse_options(file, dict)
334    } else {
335        Vec::new()
336    };
337    let max_len = int_value(file, dict.get("MaxLen"));
338
339    let index = state.fields.len();
340    for &w in &widgets {
341        state.widget_owner.entry(w).or_insert(index);
342    }
343    state.fields.push(FormField {
344        field_id: id,
345        name,
346        kind,
347        flags: merged.flags,
348        value: merged.value,
349        default_appearance: merged.da,
350        quadding: merged.quadding,
351        max_len,
352        options,
353        widgets,
354    });
355}
356
357fn field_kind(ft: Option<&str>) -> FieldKind {
358    match ft {
359        Some("Tx") => FieldKind::Text,
360        Some("Btn") => FieldKind::Button,
361        Some("Ch") => FieldKind::Choice,
362        Some("Sig") => FieldKind::Signature,
363        _ => FieldKind::Unknown,
364    }
365}
366
367/// `/Opt`: each entry is a display string, or an `[export, display]` pair. The
368/// returned `(export, display)` keeps both; plain strings export == display.
369fn parse_options(file: &PdfFile, dict: &PdfDict) -> Vec<(String, String)> {
370    let as_text = |o: &PdfObject| match o {
371        PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
372        _ => None,
373    };
374    deref_array(file, dict.get("Opt"))
375        .map(|arr| {
376            arr.iter()
377                .map(|o| match deref(file, o) {
378                    PdfObject::String(s) => {
379                        let t = pdf_string_to_unicode(s.as_bytes());
380                        (t.clone(), t)
381                    }
382                    PdfObject::Array(a) => {
383                        let export = a.first().and_then(as_text).unwrap_or_default();
384                        let display = a.get(1).and_then(as_text).unwrap_or_else(|| export.clone());
385                        (export, display)
386                    }
387                    _ => (String::new(), String::new()),
388                })
389                .collect()
390        })
391        .unwrap_or_default()
392}
393
394// ---------------------------------------------------------------------------
395// Appearance generation
396// ---------------------------------------------------------------------------
397
398/// Cap on the number of characters laid out for any synthesized text
399/// appearance — no real field value or `FreeText` note is longer, and it bounds
400/// the word-wrap / measurement work against adversarial input. Shared by the
401/// widget generator here and the `FreeText` generator in
402/// [`crate::annot_appearance`].
403pub(crate) const MAX_APPEARANCE_TEXT_CHARS: usize = 50_000;
404
405/// A synthesized appearance stream for a widget the producer left without one
406/// (or that `/NeedAppearances` asks the viewer to regenerate). Mirrors a form
407/// XObject: a `/BBox`, `/Matrix`, `/Resources` and a content byte stream.
408#[derive(Debug, Clone)]
409pub struct GeneratedAppearance {
410    pub bbox: Rect,
411    pub matrix: Matrix,
412    pub resources: PdfDict,
413    pub content: Vec<u8>,
414}
415
416/// Build a generated appearance for a widget, or `None` when nothing should be
417/// drawn (button/signature fields, empty/absent values, password fields, or a
418/// degenerate rectangle). `dr_fonts` is the AcroForm `/DR /Font` dictionary,
419/// used to resolve the `/DA` font name to a concrete font object.
420pub fn generate_widget_appearance(
421    field: &FormField,
422    rect: Rect,
423    dr_fonts: Option<&PdfDict>,
424) -> Option<GeneratedAppearance> {
425    if !matches!(field.kind, FieldKind::Text | FieldKind::Choice) || field.is_password() {
426        return None;
427    }
428    // Cap pathological value lengths — no real field shows this much, and it
429    // bounds the synthesized content size / measurement work.
430    let text: String = field
431        .display_value()?
432        .chars()
433        .take(MAX_APPEARANCE_TEXT_CHARS)
434        .collect();
435    let rect = rect.normalize();
436    let (w, h) = (rect.width(), rect.height());
437    if w <= 1.0 || h <= 1.0 {
438        return None;
439    }
440
441    let da = field
442        .default_appearance
443        .as_deref()
444        .unwrap_or("/Helv 0 Tf 0 g");
445    let da = parse_da(da);
446    // The font name becomes both a content-stream token and a resource key, so
447    // sanitize it to a safe charset (fall back to the standard Helvetica key).
448    let font_res_name = da
449        .font
450        .as_deref()
451        .filter(|n| is_safe_resource_name(n))
452        .unwrap_or("Helv")
453        .to_string();
454    let base_font = resolve_base_font(dr_fonts, &font_res_name);
455
456    const PAD: f64 = 2.0;
457    let comb = field.is_comb();
458    let mut body: Vec<u8> = Vec::new();
459    push_str(&mut body, "BT\n");
460
461    // List boxes (a non-combo choice) stack their selected lines like a
462    // multiline text field; combo boxes and plain text fields are single-line.
463    let stacked =
464        field.is_multiline() || (field.kind == FieldKind::Choice && field.flags & FF_COMBO == 0);
465
466    if comb {
467        comb_layout(
468            &mut body,
469            &one_line(&text),
470            &da,
471            &base_font,
472            &font_res_name,
473            w,
474            h,
475            field,
476        );
477    } else if stacked {
478        multiline_layout(
479            &mut body,
480            &text,
481            &da,
482            &base_font,
483            &font_res_name,
484            w,
485            h,
486            PAD,
487            field.quadding,
488        );
489    } else {
490        single_line_layout(
491            &mut body,
492            &one_line(&text),
493            &da,
494            &base_font,
495            &font_res_name,
496            w,
497            h,
498            PAD,
499            field.quadding,
500        );
501    }
502    push_str(&mut body, "ET\n");
503
504    // Wrap in a marked-content `/Tx` block, clipped to the field. Text/multiline
505    // use a 2pt inset; comb cells span the full width, so they clip to the BBox.
506    let inset = if comb { 0.0 } else { PAD };
507    let clip_w = (w - 2.0 * inset).max(0.0);
508    let clip_h = (h - 2.0 * inset).max(0.0);
509    let mut content: Vec<u8> = Vec::new();
510    push_str(&mut content, "/Tx BMC\nq\n");
511    push_str(&mut content, &fmt_num(inset));
512    push_str(&mut content, " ");
513    push_str(&mut content, &fmt_num(inset));
514    push_str(&mut content, " ");
515    push_str(&mut content, &fmt_num(clip_w));
516    push_str(&mut content, " ");
517    push_str(&mut content, &fmt_num(clip_h));
518    push_str(&mut content, " re W n\n");
519    content.extend_from_slice(&body);
520    push_str(&mut content, "Q\nEMC\n");
521
522    Some(GeneratedAppearance {
523        bbox: Rect::new(0.0, 0.0, w, h),
524        matrix: Matrix::identity(),
525        resources: build_resources(dr_fonts, &font_res_name),
526        content,
527    })
528}
529
530#[allow(clippy::too_many_arguments)]
531fn single_line_layout(
532    body: &mut Vec<u8>,
533    text: &str,
534    da: &DaInfo,
535    base_font: &str,
536    font_res_name: &str,
537    w: f64,
538    h: f64,
539    pad: f64,
540    quadding: i64,
541) {
542    let usable = (w - 2.0 * pad).max(1.0);
543    let mut size = if da.size > 0.0 {
544        da.size
545    } else {
546        // Auto: fit the field height (capped), then shrink to fit the width.
547        let mut s = (h * 0.7).clamp(4.0, 12.0);
548        let tw = measure(text, base_font, s);
549        if tw > usable {
550            s *= usable / tw;
551        }
552        s.max(2.0)
553    };
554    if size <= 0.0 {
555        size = 12.0;
556    }
557
558    let tw = measure(text, base_font, size);
559    let x = match quadding {
560        1 => (w - tw) / 2.0, // centered
561        2 => w - pad - tw,   // right
562        _ => pad,            // left (default)
563    };
564    let y = vertical_baseline(h, size);
565
566    emit_font(body, da, font_res_name, size);
567    emit_line(body, x, y, text);
568}
569
570#[allow(clippy::too_many_arguments)]
571pub(crate) fn multiline_layout(
572    body: &mut Vec<u8>,
573    text: &str,
574    da: &DaInfo,
575    base_font: &str,
576    font_res_name: &str,
577    w: f64,
578    h: f64,
579    pad: f64,
580    quadding: i64,
581) {
582    let usable = (w - 2.0 * pad).max(1.0);
583    let usable_h = (h - 2.0 * pad).max(1.0);
584
585    // Auto (DA size 0): shrink so the wrapped lines fit the box height, capped
586    // at 12pt; otherwise honor the explicit size.
587    let size = if da.size > 0.0 {
588        da.size
589    } else {
590        let mut s = 12.0_f64;
591        while s > 4.0 {
592            let lines = wrap_lines(text, base_font, s, usable);
593            if lines.len() as f64 * s * 1.15 <= usable_h {
594                break;
595            }
596            s -= 1.0;
597        }
598        s
599    };
600    let leading = size * 1.15;
601    let lines = wrap_lines(text, base_font, size, usable);
602
603    emit_font(body, da, font_res_name, size);
604    // Top line baseline sits one ascent below the top inset.
605    let mut y = h - pad - size * 0.72;
606    for line in &lines {
607        if y < -size {
608            break; // fully below the box
609        }
610        let lw = measure(line, base_font, size);
611        let x = match quadding {
612            1 => (w - lw) / 2.0, // centered
613            2 => w - pad - lw,   // right
614            _ => pad,            // left (default)
615        };
616        emit_line(body, x, y, line);
617        y -= leading;
618    }
619}
620
621#[allow(clippy::too_many_arguments)]
622fn comb_layout(
623    body: &mut Vec<u8>,
624    text: &str,
625    da: &DaInfo,
626    base_font: &str,
627    font_res_name: &str,
628    w: f64,
629    h: f64,
630    field: &FormField,
631) {
632    let n = field.max_len.unwrap_or(1).max(1) as f64;
633    let cell = w / n;
634    let size = if da.size > 0.0 {
635        da.size
636    } else {
637        ((h - 4.0).min(cell)).clamp(2.0, 12.0)
638    };
639    let y = vertical_baseline(h, size);
640
641    emit_font(body, da, font_res_name, size);
642    for (i, ch) in text.chars().take(n as usize).enumerate() {
643        let s = ch.to_string();
644        let cw = measure(&s, base_font, size);
645        let x = cell * i as f64 + (cell - cw) / 2.0;
646        emit_line(body, x, y, &s);
647    }
648}
649
650/// Baseline y that vertically centers a line of the given font size in a box of
651/// height `h`. Uses nominal Helvetica ascent/descent ratios.
652fn vertical_baseline(h: f64, size: f64) -> f64 {
653    // Glyph box spans [baseline - 0.21·size, baseline + 0.72·size]; centering
654    // its midpoint at h/2 gives baseline = h/2 - 0.255·size.
655    (h / 2.0 - 0.255 * size).max(0.0)
656}
657
658/// Emit the font/color setup: the DA color (or black) then `/Font size Tf`.
659fn emit_font(body: &mut Vec<u8>, da: &DaInfo, font_res_name: &str, size: f64) {
660    push_str(body, &format!("{}\n", da.color_ops));
661    push_str(body, &format!("/{font_res_name} {} Tf\n", fmt_num(size)));
662}
663
664/// Emit one absolutely-positioned line: `1 0 0 1 x y Tm (text) Tj`.
665fn emit_line(body: &mut Vec<u8>, x: f64, y: f64, text: &str) {
666    push_str(body, &format!("1 0 0 1 {} {} Tm\n", fmt_num(x), fmt_num(y)));
667    body.push(b'(');
668    escape_text(text, body);
669    push_str(body, ") Tj\n");
670}
671
672/// Format a coordinate/size for the content stream, mapping any non-finite
673/// value (an overflowed measurement from an adversarial DA size) to `0` so the
674/// emitted stream never contains `inf`/`-inf`/`NaN` tokens.
675fn fmt_num(v: f64) -> String {
676    if v.is_finite() {
677        format!("{v:.2}")
678    } else {
679        "0".to_string()
680    }
681}
682
683/// A font resource name safe to emit as a content-stream `/Name` token and use
684/// as a resource-dict key (no delimiters, whitespace, or `(`/`)`).
685pub(crate) fn is_safe_resource_name(name: &str) -> bool {
686    !name.is_empty()
687        && name.len() <= 64
688        && name
689            .chars()
690            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
691}
692
693/// Greedy word-wrap, also breaking on explicit newlines.
694fn wrap_lines(text: &str, base_font: &str, size: f64, usable: f64) -> Vec<String> {
695    // Anti-runaway ceiling, checked at the top so a newline-heavy value cannot
696    // bypass it through the empty-paragraph fast path.
697    const MAX_LINES: usize = 1000;
698    let mut out = Vec::new();
699    for paragraph in text.split('\n') {
700        if out.len() > MAX_LINES {
701            break;
702        }
703        if paragraph.is_empty() {
704            out.push(String::new());
705            continue;
706        }
707        let mut line = String::new();
708        for word in paragraph.split(' ') {
709            let candidate = if line.is_empty() {
710                word.to_string()
711            } else {
712                format!("{line} {word}")
713            };
714            if measure(&candidate, base_font, size) <= usable || line.is_empty() {
715                line = candidate;
716            } else {
717                out.push(std::mem::take(&mut line));
718                line = word.to_string();
719            }
720        }
721        out.push(line);
722    }
723    out
724}
725
726/// Text width in text-space units at `size`, from the standard-14 metrics of
727/// `base_font` (or a 0.5-em estimate for non-standard faces).
728fn measure(text: &str, base_font: &str, size: f64) -> f64 {
729    let metrics = zpdf_font::standard_fonts::lookup(base_font);
730    let mut total = 0.0;
731    for ch in text.chars() {
732        let w1000 = match metrics {
733            Some(m) => {
734                let code = unicode_to_winansi(ch).unwrap_or(b'?') as usize;
735                m.widths[code] as f64
736            }
737            None => 500.0,
738        };
739        let w1000 = if w1000 == 0.0 { 500.0 } else { w1000 };
740        total += w1000 / 1000.0 * size;
741    }
742    total
743}
744
745/// Parsed `/DA` default-appearance pieces we care about. Shared with the
746/// markup-annotation appearance generator ([`crate::annot_appearance`]), which
747/// reuses this whole text-layout engine for `FreeText` annotations.
748pub(crate) struct DaInfo {
749    pub(crate) font: Option<String>,
750    pub(crate) size: f64,
751    /// A color-setting fragment (`0 g`, `1 0 0 rg`, …) ready to emit verbatim.
752    pub(crate) color_ops: String,
753}
754
755/// Extract the font resource name, size, and color operators from a `/DA`
756/// content fragment (e.g. `0 0 1 rg /Helv 12 Tf`).
757pub(crate) fn parse_da(da: &str) -> DaInfo {
758    let mut font = None;
759    let mut size: f64 = 0.0;
760    let mut color = String::new();
761    let mut operands: Vec<&str> = Vec::new();
762
763    for tok in da.split_whitespace() {
764        match tok {
765            "Tf" => {
766                if operands.len() >= 2 {
767                    if let Some(name) = operands[operands.len() - 2].strip_prefix('/') {
768                        font = Some(name.to_string());
769                    }
770                    size = operands[operands.len() - 1].parse().unwrap_or(0.0);
771                }
772                operands.clear();
773            }
774            "g" if !operands.is_empty() => {
775                if let Some(c) = da_color(&operands, 1, "g") {
776                    color = c;
777                }
778                operands.clear();
779            }
780            "rg" if operands.len() >= 3 => {
781                if let Some(c) = da_color(&operands, 3, "rg") {
782                    color = c;
783                }
784                operands.clear();
785            }
786            "k" if operands.len() >= 4 => {
787                if let Some(c) = da_color(&operands, 4, "k") {
788                    color = c;
789                }
790                operands.clear();
791            }
792            other => operands.push(other),
793        }
794    }
795
796    // Clamp the font size to a sane ceiling so an adversarial DA (`/Helv 1e308
797    // Tf`) cannot overflow downstream width math to infinity.
798    const MAX_FONT_SIZE: f64 = 1000.0;
799    DaInfo {
800        font,
801        size: if size.is_finite() && size >= 0.0 {
802            size.min(MAX_FONT_SIZE)
803        } else {
804            0.0
805        },
806        color_ops: if color.is_empty() {
807            "0 g".to_string()
808        } else {
809            color
810        },
811    }
812}
813
814/// Build a validated color-setting operator from the last `n` DA operands,
815/// accepting only finite numbers (clamped to `[0,1]`). Returns `None` when any
816/// operand is not a number — so adversarial tokens never reach the content
817/// stream verbatim.
818fn da_color(operands: &[&str], n: usize, op: &str) -> Option<String> {
819    let vals: Option<Vec<f64>> = operands[operands.len() - n..]
820        .iter()
821        .map(|t| {
822            t.parse::<f64>()
823                .ok()
824                .filter(|v| v.is_finite())
825                .map(|v| v.clamp(0.0, 1.0))
826        })
827        .collect();
828    let parts: Vec<String> = vals?.iter().map(|v| format!("{v:.4}")).collect();
829    Some(format!("{} {op}", parts.join(" ")))
830}
831
832/// Resolve a `/DA` font resource name to a base-font name for metrics: prefer
833/// the `/DR` font's `/BaseFont`, else map the conventional Acrobat resource
834/// name (`Helv`, `Cour`, …), else Helvetica.
835pub(crate) fn resolve_base_font(dr_fonts: Option<&PdfDict>, res_name: &str) -> String {
836    if let Some(dr) = dr_fonts {
837        if let Some(PdfObject::Dict(fd)) = dr.get(res_name) {
838            if let Ok(bf) = fd.get_name("BaseFont") {
839                return strip_subset_prefix(bf).to_string();
840            }
841        }
842    }
843    acrobat_standard_name(res_name).to_string()
844}
845
846/// The conventional AcroForm `/DR` resource names for the standard-14 fonts.
847fn acrobat_standard_name(res_name: &str) -> &str {
848    match res_name {
849        "Helv" => "Helvetica",
850        "HeBO" | "HeBo" => "Helvetica-Bold",
851        "HeOb" => "Helvetica-Oblique",
852        "Cour" => "Courier",
853        "CoBO" | "CoBo" => "Courier-Bold",
854        "TiRo" => "Times-Roman",
855        "TiBo" => "Times-Bold",
856        "TiIt" => "Times-Italic",
857        "Symb" => "Symbol",
858        "ZaDb" => "ZapfDingbats",
859        other => other,
860    }
861}
862
863fn strip_subset_prefix(name: &str) -> &str {
864    // "ABCDEF+Helvetica" → "Helvetica"
865    name.rsplit('+').next().unwrap_or(name)
866}
867
868/// Build the appearance `/Resources`: a `/Font` dict mapping the DA font name to
869/// the `/DR` font object (if any) or a synthesized standard Helvetica.
870pub fn build_resources(dr_fonts: Option<&PdfDict>, font_res_name: &str) -> PdfDict {
871    let font_entry = dr_fonts
872        .and_then(|dr| dr.get(font_res_name).cloned())
873        .unwrap_or_else(|| PdfObject::Dict(standard_font_dict("Helvetica")));
874
875    let mut fonts = PdfDict::new();
876    fonts.insert(PdfName::new(font_res_name), font_entry);
877    let mut res = PdfDict::new();
878    res.insert(PdfName::new("Font"), PdfObject::Dict(fonts));
879    res
880}
881
882/// A synthesized standard-14 Type1 font dict (`/BaseFont base`, WinAnsi). Shared
883/// by the widget generator and the markup/annotation appearance generator so the
884/// font-dict shape lives in one place.
885pub fn standard_font_dict(base: &str) -> PdfDict {
886    let mut d = PdfDict::new();
887    d.insert(PdfName::new("Type"), PdfObject::Name(PdfName::new("Font")));
888    d.insert(
889        PdfName::new("Subtype"),
890        PdfObject::Name(PdfName::new("Type1")),
891    );
892    d.insert(
893        PdfName::new("BaseFont"),
894        PdfObject::Name(PdfName::new(base)),
895    );
896    d.insert(
897        PdfName::new("Encoding"),
898        PdfObject::Name(PdfName::new("WinAnsiEncoding")),
899    );
900    d
901}
902
903/// Escape a string into a PDF literal-string body (`(`/`)`/`\` and CR), encoding
904/// each character as its WinAnsiEncoding byte (the declared appearance-font
905/// encoding); characters with no WinAnsi byte fall back to `?`.
906pub fn escape_text(s: &str, out: &mut Vec<u8>) {
907    for ch in s.chars() {
908        let b = unicode_to_winansi(ch).unwrap_or(b'?');
909        match b {
910            b'\\' => out.extend_from_slice(b"\\\\"),
911            b'(' => out.extend_from_slice(b"\\("),
912            b')' => out.extend_from_slice(b"\\)"),
913            b'\r' => out.extend_from_slice(b"\\r"),
914            _ => out.push(b),
915        }
916    }
917}
918
919/// Map a Unicode scalar to its WinAnsiEncoding byte. ASCII (0x20–0x7E) and
920/// Latin-1 (0xA0–0xFF) are identity; the WinAnsi C1 block (0x80–0x9F) holds
921/// typographic punctuation / currency whose Unicode code points are ≥ 0x100.
922/// Returns `None` for code points with no WinAnsi representation.
923pub fn unicode_to_winansi(ch: char) -> Option<u8> {
924    let cp = ch as u32;
925    match cp {
926        0x20..=0x7E | 0xA0..=0xFF => Some(cp as u8),
927        0x20AC => Some(0x80),
928        0x201A => Some(0x82),
929        0x0192 => Some(0x83),
930        0x201E => Some(0x84),
931        0x2026 => Some(0x85),
932        0x2020 => Some(0x86),
933        0x2021 => Some(0x87),
934        0x02C6 => Some(0x88),
935        0x2030 => Some(0x89),
936        0x0160 => Some(0x8A),
937        0x2039 => Some(0x8B),
938        0x0152 => Some(0x8C),
939        0x017D => Some(0x8E),
940        0x2018 => Some(0x91),
941        0x2019 => Some(0x92),
942        0x201C => Some(0x93),
943        0x201D => Some(0x94),
944        0x2022 => Some(0x95),
945        0x2013 => Some(0x96),
946        0x2014 => Some(0x97),
947        0x02DC => Some(0x98),
948        0x2122 => Some(0x99),
949        0x0161 => Some(0x9A),
950        0x203A => Some(0x9B),
951        0x0153 => Some(0x9C),
952        0x017E => Some(0x9E),
953        0x0178 => Some(0x9F),
954        _ => None,
955    }
956}
957
958fn push_str(out: &mut Vec<u8>, s: &str) {
959    out.extend_from_slice(s.as_bytes());
960}
961
962/// Collapse line breaks and tabs to spaces for single-line / comb rendering.
963fn one_line(s: &str) -> String {
964    s.chars()
965        .map(|c| {
966            if c == '\n' || c == '\r' || c == '\t' {
967                ' '
968            } else {
969                c
970            }
971        })
972        .collect()
973}
974
975// ---------------------------------------------------------------------------
976// Small resolution helpers
977// ---------------------------------------------------------------------------
978
979/// Resolve one level of indirection, returning `Null` on failure.
980fn deref(file: &PdfFile, obj: &PdfObject) -> PdfObject {
981    match obj {
982        PdfObject::Ref(r) => file.resolve(*r).unwrap_or(PdfObject::Null),
983        other => other.clone(),
984    }
985}
986
987fn deref_opt(file: &PdfFile, obj: Option<&PdfObject>) -> Option<PdfObject> {
988    obj.map(|o| deref(file, o))
989}
990
991fn deref_array(file: &PdfFile, obj: Option<&PdfObject>) -> Option<Vec<PdfObject>> {
992    match deref(file, obj?) {
993        PdfObject::Array(a) => Some(a),
994        _ => None,
995    }
996}
997
998/// A string's text, resolving one level of indirection.
999fn text_string(file: &PdfFile, obj: &PdfObject) -> Option<String> {
1000    match deref(file, obj) {
1001        PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
1002        _ => None,
1003    }
1004}
1005
1006fn field_value(file: &PdfFile, obj: Option<&PdfObject>) -> Option<FieldValue> {
1007    match deref(file, obj?) {
1008        PdfObject::String(s) => Some(FieldValue::Text(pdf_string_to_unicode(s.as_bytes()))),
1009        PdfObject::Name(n) => Some(FieldValue::Name(n.0)),
1010        PdfObject::Array(a) => {
1011            let items: Vec<String> = a
1012                .iter()
1013                .filter_map(|o| match o {
1014                    PdfObject::String(s) => Some(pdf_string_to_unicode(s.as_bytes())),
1015                    _ => None,
1016                })
1017                .collect();
1018            (!items.is_empty()).then_some(FieldValue::List(items))
1019        }
1020        _ => None,
1021    }
1022}
1023
1024fn int_value(file: &PdfFile, obj: Option<&PdfObject>) -> Option<i64> {
1025    match deref(file, obj?) {
1026        PdfObject::Integer(n) => Some(n),
1027        PdfObject::Real(r) => Some(r as i64),
1028        _ => None,
1029    }
1030}
1031
1032/// Decode a PDF text string: UTF-16BE when it carries the `FE FF` BOM, else the
1033/// bytes as PDFDocEncoding (approximated by Latin-1 for the common range).
1034pub(crate) fn pdf_string_to_unicode(bytes: &[u8]) -> String {
1035    if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
1036        let units: Vec<u16> = bytes[2..]
1037            .chunks_exact(2)
1038            .map(|c| u16::from_be_bytes([c[0], c[1]]))
1039            .collect();
1040        String::from_utf16_lossy(&units)
1041    } else {
1042        bytes.iter().map(|&b| b as char).collect()
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049    use crate::test_util::build_pdf;
1050    use crate::PdfDocument;
1051
1052    #[test]
1053    fn field_tree_names_inheritance_and_widgets() {
1054        let doc = PdfDocument::open(build_pdf(&[
1055            "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
1056            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1057            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
1058            "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 8 0 R >> >> >>",
1059            // Parent field carries /FT and is the inheritance source.
1060            "<< /T (address) /FT /Tx /Kids [6 0 R 7 0 R] >>",
1061            "<< /T (street) /V (Main St) >>",
1062            "<< /T (city) /V (Springfield) /Q 1 >>",
1063            "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
1064        ]))
1065        .expect("open");
1066
1067        let form = doc.acro_form().expect("acroform");
1068        assert!(!form.need_appearances);
1069        assert!(form.dr_fonts.is_some());
1070        assert_eq!(form.fields.len(), 2);
1071
1072        let street = &form.fields[0];
1073        assert_eq!(street.name, "address.street");
1074        assert_eq!(street.kind, FieldKind::Text); // inherited /FT
1075        assert_eq!(street.value, Some(FieldValue::Text("Main St".into())));
1076        assert_eq!(street.default_appearance.as_deref(), Some("/Helv 0 Tf 0 g")); // inherited /DA
1077        assert_eq!(street.quadding, 0);
1078        // The terminal field with no widget kids is itself the widget.
1079        assert_eq!(street.widgets, vec![ObjectId(6, 0)]);
1080        assert_eq!(
1081            form.field_for_widget(ObjectId(6, 0))
1082                .map(|f| f.name.as_str()),
1083            Some("address.street")
1084        );
1085
1086        let city = &form.fields[1];
1087        assert_eq!(city.name, "address.city");
1088        assert_eq!(city.quadding, 1); // own /Q overrides
1089    }
1090
1091    #[test]
1092    fn single_widget_field_and_button_value() {
1093        let doc = PdfDocument::open(build_pdf(&[
1094            "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
1095            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1096            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>",
1097            "<< /Fields [5 0 R] /NeedAppearances true >>",
1098            // A checkbox that is its own widget (merged field+annotation).
1099            "<< /T (agree) /FT /Btn /V /Yes /AS /Yes /Subtype /Widget /Rect [10 10 30 30] >>",
1100        ]))
1101        .expect("open");
1102
1103        let form = doc.acro_form().expect("acroform");
1104        assert!(form.need_appearances);
1105        assert_eq!(form.fields.len(), 1);
1106        let f = &form.fields[0];
1107        assert_eq!(f.name, "agree");
1108        assert_eq!(f.kind, FieldKind::Button);
1109        assert_eq!(f.value, Some(FieldValue::Name("Yes".into())));
1110        // A button never generates an appearance.
1111        assert!(generate_widget_appearance(f, Rect::new(10.0, 10.0, 30.0, 30.0), None).is_none());
1112    }
1113
1114    #[test]
1115    fn no_acroform_returns_none() {
1116        let doc = PdfDocument::open(build_pdf(&[
1117            "<< /Type /Catalog /Pages 2 0 R >>",
1118            "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
1119            "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>",
1120        ]))
1121        .expect("open");
1122        assert!(doc.acro_form().is_none());
1123    }
1124
1125    #[test]
1126    fn da_parsing_extracts_font_size_color() {
1127        // Color operands are validated and re-emitted with fixed precision.
1128        let da = parse_da("0 0 1 rg /Helv 12 Tf");
1129        assert_eq!(da.font.as_deref(), Some("Helv"));
1130        assert_eq!(da.size, 12.0);
1131        assert_eq!(da.color_ops, "0.0000 0.0000 1.0000 rg");
1132
1133        let da = parse_da("/Cour 0 Tf 0.2 g");
1134        assert_eq!(da.font.as_deref(), Some("Cour"));
1135        assert_eq!(da.size, 0.0);
1136        assert_eq!(da.color_ops, "0.2000 g");
1137
1138        // Missing color defaults to black.
1139        let da = parse_da("/Helv 10 Tf");
1140        assert_eq!(da.color_ops, "0 g");
1141
1142        // Adversarial size is clamped; injected non-numeric color is dropped.
1143        let da = parse_da("/Helv 1e308 Tf");
1144        assert_eq!(da.size, 1000.0);
1145        let da = parse_da("1)Tj/Evil 0 0 rg /Helv 10 Tf");
1146        assert_eq!(da.color_ops, "0 g"); // bad operand → color rejected → default
1147    }
1148
1149    #[test]
1150    fn winansi_punctuation_round_trips() {
1151        // Smart quote / em dash / euro map to their WinAnsi bytes, not '?'.
1152        assert_eq!(unicode_to_winansi('\u{2019}'), Some(0x92));
1153        assert_eq!(unicode_to_winansi('\u{2014}'), Some(0x97));
1154        assert_eq!(unicode_to_winansi('\u{20AC}'), Some(0x80));
1155        assert_eq!(unicode_to_winansi('A'), Some(0x41));
1156        assert_eq!(unicode_to_winansi('\u{00E9}'), Some(0xE9)); // é (Latin-1)
1157        assert_eq!(unicode_to_winansi('\u{4E2D}'), None); // CJK → fallback
1158    }
1159
1160    #[test]
1161    fn non_finite_numbers_never_reach_output() {
1162        assert_eq!(fmt_num(f64::INFINITY), "0");
1163        assert_eq!(fmt_num(f64::NAN), "0");
1164        assert_eq!(fmt_num(-1.5), "-1.50");
1165    }
1166
1167    #[test]
1168    fn utf16be_value_is_decoded() {
1169        // BOM + "Hi" in UTF-16BE.
1170        let bytes = [0xFE, 0xFF, 0x00, b'H', 0x00, b'i'];
1171        assert_eq!(pdf_string_to_unicode(&bytes), "Hi");
1172    }
1173
1174    #[test]
1175    fn escape_handles_parens_and_backslash() {
1176        let mut out = Vec::new();
1177        escape_text("a(b)\\c", &mut out);
1178        assert_eq!(out, b"a\\(b\\)\\\\c");
1179    }
1180
1181    #[test]
1182    fn standard_name_mapping() {
1183        assert_eq!(acrobat_standard_name("Helv"), "Helvetica");
1184        assert_eq!(acrobat_standard_name("ZaDb"), "ZapfDingbats");
1185        assert_eq!(acrobat_standard_name("F1"), "F1");
1186    }
1187
1188    #[test]
1189    fn choice_value_maps_export_to_display_label() {
1190        let f = FormField {
1191            field_id: ObjectId(0, 0),
1192            name: "month".into(),
1193            kind: FieldKind::Choice,
1194            flags: 0,
1195            value: Some(FieldValue::Text("01".into())),
1196            default_appearance: None,
1197            quadding: 0,
1198            max_len: None,
1199            options: vec![
1200                ("01".into(), "January".into()),
1201                ("02".into(), "February".into()),
1202            ],
1203            widgets: vec![],
1204        };
1205        // /V holds the export value "01"; the rendered label is "January".
1206        assert_eq!(f.display_value().as_deref(), Some("January"));
1207        // An export with no matching option falls back to the raw value.
1208        let f2 = FormField {
1209            value: Some(FieldValue::Text("99".into())),
1210            ..f
1211        };
1212        assert_eq!(f2.display_value().as_deref(), Some("99"));
1213    }
1214
1215    #[test]
1216    fn comb_is_suppressed_when_multiline() {
1217        let base = FormField {
1218            field_id: ObjectId(0, 0),
1219            name: "x".into(),
1220            kind: FieldKind::Text,
1221            flags: FF_COMB | FF_MULTILINE,
1222            value: Some(FieldValue::Text("AB".into())),
1223            default_appearance: None,
1224            quadding: 0,
1225            max_len: Some(4),
1226            options: vec![],
1227            widgets: vec![],
1228        };
1229        // Comb (bit 25) is meaningless with Multiline set.
1230        assert!(!base.is_comb());
1231        assert!(base.is_multiline());
1232    }
1233
1234    #[test]
1235    fn comb_field_detection() {
1236        let f = FormField {
1237            field_id: ObjectId(0, 0),
1238            name: "x".into(),
1239            kind: FieldKind::Text,
1240            flags: FF_COMB,
1241            value: Some(FieldValue::Text("AB".into())),
1242            default_appearance: None,
1243            quadding: 0,
1244            max_len: Some(4),
1245            options: vec![],
1246            widgets: vec![],
1247        };
1248        assert!(f.is_comb());
1249        // Comb without MaxLen is not comb.
1250        let f2 = FormField {
1251            max_len: None,
1252            ..f.clone()
1253        };
1254        assert!(!f2.is_comb());
1255    }
1256
1257    #[test]
1258    fn generated_appearance_draws_value() {
1259        let f = FormField {
1260            field_id: ObjectId(0, 0),
1261            name: "name".into(),
1262            kind: FieldKind::Text,
1263            flags: 0,
1264            value: Some(FieldValue::Text("Test".into())),
1265            default_appearance: Some("/Helv 12 Tf 0 g".into()),
1266            quadding: 0,
1267            max_len: None,
1268            options: vec![],
1269            widgets: vec![],
1270        };
1271        let ap = generate_widget_appearance(&f, Rect::new(0.0, 0.0, 200.0, 40.0), None)
1272            .expect("appearance");
1273        assert_eq!(ap.bbox, Rect::new(0.0, 0.0, 200.0, 40.0));
1274        let s = String::from_utf8_lossy(&ap.content);
1275        assert!(s.contains("/Tx BMC"));
1276        assert!(s.contains("Tf"));
1277        assert!(s.contains("(Test) Tj"));
1278        // Resources define the DA font name.
1279        assert!(ap.resources.get("Font").is_some());
1280    }
1281
1282    #[test]
1283    fn empty_and_button_values_generate_nothing() {
1284        let base = FormField {
1285            field_id: ObjectId(0, 0),
1286            name: "x".into(),
1287            kind: FieldKind::Text,
1288            flags: 0,
1289            value: Some(FieldValue::Text(String::new())),
1290            default_appearance: None,
1291            quadding: 0,
1292            max_len: None,
1293            options: vec![],
1294            widgets: vec![],
1295        };
1296        assert!(
1297            generate_widget_appearance(&base, Rect::new(0.0, 0.0, 100.0, 20.0), None).is_none()
1298        );
1299
1300        let button = FormField {
1301            kind: FieldKind::Button,
1302            value: Some(FieldValue::Name("Yes".into())),
1303            ..base.clone()
1304        };
1305        assert!(
1306            generate_widget_appearance(&button, Rect::new(0.0, 0.0, 100.0, 20.0), None).is_none()
1307        );
1308
1309        let password = FormField {
1310            flags: FF_PASSWORD,
1311            value: Some(FieldValue::Text("secret".into())),
1312            ..base
1313        };
1314        assert!(
1315            generate_widget_appearance(&password, Rect::new(0.0, 0.0, 100.0, 20.0), None).is_none()
1316        );
1317    }
1318}