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