Skip to main content

pdfrum_doc/ap/
widget.rs

1//! Widget appearances: the chrome a form control gets when its dictionary
2//! carries none.
3//!
4//! # Why this exists, and how far it goes
5//!
6//! A widget annotation whose dictionary has **no `/AP` dictionary at all**
7//! gets an appearance built for it the moment its page is opened — not only
8//! under `/NeedAppearances`, which is what the wider form-regeneration path
9//! is gated on, but unconditionally. So an unadorned text field in a file
10//! that never mentions `/NeedAppearances` still ends up with an appearance
11//! stream, and everything reading the file afterwards sees one.
12//!
13//! What that appearance contains is a background rectangle, a border, and
14//! then — for the three field types that show text — a **body**: the value,
15//! the selected option, or the option rows. This module builds the chrome;
16//! [`field_body`](crate::ap::field_body) builds the body over the same
17//! variable-text engine the free-text generator already uses, and
18//! `generate_with_text` is the entry point that has a font to build one
19//! with.
20//!
21//! `generate` is the font-less door and produces chrome alone. Both are
22//! kept because they answer different questions: a caller with no font in
23//! hand still needs a widget's background and border, and a widget with
24//! neither `/MK` colour nor a value produces an empty stream either way —
25//! which is what the oracle produces too.
26
27use kurbo::Rect;
28use pdfrum_common::{Diagnostics, Limits};
29use pdfrum_object::{Dict, Resolve, names as obj_names};
30
31use crate::ap::border::{BorderStyle, BorderStyleInfo, Dash};
32use crate::ap::emit::{Content, Float, PaintOp, color_op};
33use crate::ap::shapes::{self, CheckStyle};
34use crate::ap::{GeneratedAp, da, resources_dict};
35use crate::color::Color;
36use crate::form::attr;
37use crate::geom;
38use crate::names;
39
40/// Whether this annotation is a widget that will be given an appearance.
41///
42/// The subtype must be `/Widget`, and the **field type must be one the
43/// appearance builder knows**: the builder dispatches on it and a type it does
44/// not recognize falls off the end, writing nothing at all — so an intermediate
45/// field node that carries `/Kids` and no `/FT` of its own keeps having no
46/// appearance, which is visible in the dump because the two colour lines report
47/// a colour exactly when no appearance stream exists. `field_methods`'s
48/// `MyField` is that node.
49///
50/// # The appearance test is one dictionary lookup
51///
52/// Past those, a widget is regenerated exactly when it has **no `/AP`
53/// dictionary at all** — the presence of the key, nothing more. Not whether
54/// `/N` resolves, not whether `/AS` names a state that exists. So a radio
55/// button whose `/AP /N` lists only its on-state while `/AS` reads `Off`
56/// **keeps having no drawable appearance** and is never regenerated.
57///
58/// What draws that widget instead is the grey outline
59/// [`crate::annot_render`] strokes over an invalid checkbox or radio — a
60/// *deeper* validity test, on a different code path. Porting either of those
61/// two without the other is a measured loss, which is why they landed
62/// together.
63///
64/// # `/NeedAppearances`, and why it so often changes nothing
65///
66/// A document whose `/AcroForm` sets `/NeedAppearances` rebuilds *every*
67/// widget's appearance, consulting no `/AP` at all. That rebuild always runs
68/// — but for a checkbox or a radio button it is very often **invisible**, and
69/// the reason is a key mismatch rather than a gate:
70///
71/// A rebuilt checkbox or radio button writes exactly two sub-states,
72/// `/AP /N /<`[`checked_ap_state`]`>` and `/AP /N /Off`, while readback
73/// resolves `/AP /N /<AS>`. When `/AS` names neither of those, the new
74/// streams land in keys nothing looks up and the file's own stream is what
75/// draws.
76///
77/// [`checked_ap_state`] is where that goes wrong most often. It answers the
78/// first non-`Off` key of `/AP /N` — **unless** the field carries an `/Opt`
79/// array, in which case it answers the widget's *control index* as a decimal
80/// string. `bug_861842` is that file: `/Opt` present, control index 0, and
81/// `/AS /1`, so the rebuild writes `/0` and `/Off` while the reader keeps
82/// asking for `/1`. Honouring the flag without this rule takes it from .99986
83/// to .93548; with the rule it is untouched, and `bug_707673`'s radios — no
84/// `/Opt`, `/AS /Off`, and `Off` is always written literally — do rebuild.
85///
86/// Measured with gdb on the oracle: both files reach `ResetAppearance`, and
87/// only one of them shows it.
88#[must_use]
89#[cfg(test)]
90pub(crate) fn needs_appearance<R: Resolve>(dict: &Dict, r: &R) -> bool {
91    needs_appearance_in(dict, None, r)
92}
93
94/// The same, knowing the document the widget belongs to.
95///
96/// The catalog is what `/NeedAppearances` is read from; a caller without one
97/// answers as a form that does not set it would.
98#[must_use]
99pub(crate) fn needs_appearance_in<R: Resolve>(dict: &Dict, catalog: Option<&Dict>, r: &R) -> bool {
100    // Read coercively, matching how the annotation list classifies subtypes.
101    if dict.byte_string(obj_names::SUBTYPE, r).as_deref() != Some(b"Widget") {
102        return false;
103    }
104    if !has_known_field_type(dict, r) {
105        return false;
106    }
107    if has_kids(dict, r) {
108        return false;
109    }
110    if dict.dict(names::AP, r).is_none() {
111        return true;
112    }
113    needs_construct_ap(catalog, r) && rebuild_would_be_seen(dict, r)
114}
115
116/// Whether the dictionary is a form **field** with children rather than a
117/// control of its own.
118///
119/// A field that carries `/Kids` delegates its geometry to them, and the form
120/// loader never registers it as a control: a control is made for a field dict
121/// with no `/Kids`, and otherwise for each kid instead. So such a dictionary
122/// has no appearance to build even when it names `/Subtype /Widget` and a
123/// field type, which a field shared by several controls routinely does.
124///
125/// **Without this the parent generates chrome as if it were a control.** Its
126/// `/Rect` is `[0 0 0 0]`, so the stream is empty over an empty box —
127/// invisible on the page, and yet enough to make the annotation dump report
128/// the colour keys as unreadable, because any appearance outranks them.
129/// `example_014` and `example_054` are that file.
130fn has_kids<R: Resolve>(dict: &Dict, r: &R) -> bool {
131    dict.array(names::KIDS, r)
132        .is_some_and(|kids| !kids.is_empty())
133}
134
135/// Whether the document's form asks for every appearance to be rebuilt.
136///
137/// `/AcroForm /NeedAppearances`, read strictly as a **boolean** — so a
138/// `/NeedAppearances (true)` written as a string does not set it, and neither
139/// does a document with no `/AcroForm`.
140fn needs_construct_ap<R: Resolve>(catalog: Option<&Dict>, r: &R) -> bool {
141    let Some(form) = catalog.and_then(|catalog| catalog.dict(names::ACRO_FORM, r)) else {
142        return false;
143    };
144    form.get(names::NEED_APPEARANCES, r)
145        .and_then(|value| value.as_direct().and_then(pdfrum_object::Object::as_bool))
146        .unwrap_or(false)
147}
148
149/// Whether a rebuild would land in the sub-state `/AS` reads back.
150///
151/// Only a checkbox or a radio button writes sub-states at all; every other
152/// field type's builder writes `/AP /N` as one stream, which `/AS` never
153/// filters, so a rebuild is always seen. For the two that do, it is seen
154/// exactly when `/AS` names `Off` or [`checked_ap_state`] — and a widget with
155/// no `/AS` reads back the empty key, which a rebuild never writes.
156fn rebuild_would_be_seen<R: Resolve>(dict: &Dict, r: &R) -> bool {
157    if !is_button(dict, r) {
158        return true;
159    }
160    let Some(state) = dict.byte_string(names::AS, r) else {
161        return false;
162    };
163    state == names::OFF.as_bytes() || state == checked_ap_state(dict, r)
164}
165
166/// The sub-state key a rebuilt checkbox or radio button writes its on-state
167/// into.
168///
169/// The first non-`Off` key of `/AP /N`, taken in **sorted** key order rather
170/// than the document order this crate's dictionaries keep — except that a
171/// field carrying an `/Opt` array answers the widget's control index as a
172/// decimal string instead, and an answer that comes back empty becomes
173/// `Yes`.
174#[must_use]
175pub(crate) fn checked_ap_state<R: Resolve>(dict: &Dict, r: &R) -> Vec<u8> {
176    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
177    if attr::field_attr(dict, names::OPT, r, &limits, &mut diags)
178        .is_some_and(|value| matches!(value, pdfrum_object::Object::Array(_)))
179    {
180        return control_index(dict, r).to_string().into_bytes();
181    }
182    let on = dict
183        .dict(names::AP, r)
184        .and_then(|ap| ap.dict(names::N, r))
185        .map(|normal| {
186            let mut keys: Vec<&[u8]> = normal
187                .keys()
188                .map(pdfrum_object::Name::as_bytes)
189                .filter(|key| *key != names::OFF.as_bytes())
190                .collect();
191            keys.sort_unstable();
192            keys.first().map_or_else(Vec::new, |key| key.to_vec())
193        })
194        .unwrap_or_default();
195    if on.is_empty() { b"Yes".to_vec() } else { on }
196}
197
198/// Which of its field's widgets this one is, by position in `/Kids`.
199///
200/// A merged field-and-widget — the dictionary is its own only control — is
201/// index zero, which is also what an unfindable widget answers, because
202/// `GetControlIndex` returns zero for a control the field does not list.
203fn control_index<R: Resolve>(dict: &Dict, r: &R) -> usize {
204    let Some(kids) = dict
205        .dict(names::PARENT, r)
206        .and_then(|parent| parent.array(names::KIDS, r))
207    else {
208        return 0;
209    };
210    (0..kids.len())
211        .find(|index| kids.dict_at(*index, r).as_ref() == Some(dict))
212        .unwrap_or(0)
213}
214
215/// Builds a widget's appearance chrome, with no text body.
216///
217/// Returns nothing when the widget has an appearance already, or is not a
218/// widget. The stream is the background fill followed by the border path;
219/// with neither colour present — the ordinary case — it comes out empty,
220/// which is a valid appearance and is what the oracle writes too.
221#[must_use]
222pub(crate) fn generate<R: Resolve>(dict: &Dict, r: &R) -> Option<GeneratedAp> {
223    build(dict, None, None, LiveInput::default(), r)
224}
225
226/// The same, with the field's own text set into it.
227///
228/// A text field, a combo box or a list box gains its body; every other field
229/// type produces exactly what `generate` does, because only those three set
230/// text at all.
231///
232/// The text is the one the **file** stores. A field being edited shows
233/// something else, and `generate_with_live` is the entry point for that.
234#[must_use]
235pub(crate) fn generate_with_text<R: Resolve>(
236    dict: &Dict,
237    catalog: &Dict,
238    font: &crate::ap::TextFont<'_>,
239    substitute: Option<crate::ap::Substitute<'_>>,
240    r: &R,
241) -> Option<GeneratedAp> {
242    build(
243        dict,
244        Some(catalog),
245        Some(font),
246        LiveInput {
247            substitute,
248            ..LiveInput::default()
249        },
250        r,
251    )
252}
253
254/// What a form session shows a widget it is editing, beyond the widget's own
255/// dictionary.
256///
257/// Three borrows that travel together because the live path needs all three
258/// to draw one field: the overlay it paints, the text it paints instead of
259/// the stored `/V`, and the second face for the characters the `/DA` font
260/// cannot write. A record rather than three more parameters, so a fourth
261/// answer can be added without moving anyone's call.
262///
263/// [`Default`] is "a field with nothing live about it", which
264/// [`generate_with_live_faces`] renders exactly as `generate_with_text`
265/// does with no substitute.
266/// Adding a field to this struct breaks every exhaustive literal outside
267/// this crate. `#[non_exhaustive]` is NOT the fix: it forbids the literal form
268/// entirely outside the crate, `..Default::default()` included (E0639),
269/// and the two external callers (`pdfrum-form::route`,
270/// `pdfrum-tool::chrome`) legitimately build the whole value. Before a
271/// fourth field lands, give it a constructor — `LiveInput::new()` plus
272/// `with_*` setters — and convert those two sites; that keeps additions
273/// source-compatible without taking literal construction away.
274#[derive(Debug, Clone, Copy, Default)]
275pub struct LiveInput<'a> {
276    /// The focused-field caret and selection bands.
277    pub caret_and_selection: Option<&'a crate::ap::field_body::Highlight>,
278    /// What the session is showing in place of the stored `/V`, `/I` and
279    /// `/TI`.
280    pub live: Option<&'a crate::ap::field_body::LiveState<'a>>,
281    /// The second face, for characters the `/DA` font's charset does not
282    /// cover. [`None`] leaves every character to the `/DA` font, which is
283    /// what a Hebrew value being typed into a Latin field used to get: the
284    /// low byte of each code point, drawn as Latin.
285    pub substitute: Option<crate::ap::Substitute<'a>>,
286    /// The appearance state a session is showing, overriding the widget
287    /// dictionary's own `/AS`. [`None`] reads `/AS` as before, byte for byte.
288    ///
289    /// # Why a session cannot say this through `/AS`
290    ///
291    /// A radio group is one field with several kid controls, and each kid
292    /// carries a **different** on-state name in its own `/AP /N`. Checking one
293    /// sets the clicked control's `/AS` to that control's own on-state and
294    /// every *other* control's to `Off` — so one click restates the state of
295    /// every kid in the group, in as many different names.
296    ///
297    /// A session holds one state record per **field**, so all it can say is
298    /// which control the click chose. Turning that into what each kid draws is
299    /// per-kid, and the widget dictionary on disk still names the state before
300    /// the click. This is how the session tells the generator which kid to
301    /// draw off — the value it passes being `Off` for a sibling and the
302    /// control's own on-state for the chosen one.
303    ///
304    /// Only the on/off question is overridden, not the state's *name*: what
305    /// the generator does with it is `is_checked_with`'s single comparison
306    /// against `Off`, so any non-`Off` bytes draw the on-state shape.
307    pub appearance_state: Option<&'a [u8]>,
308    /// A session's `Field.borderStyle` write, overriding the widget's `/BS
309    /// /S`. [`None`] reads `/BS` as before.
310    ///
311    /// The setter records a request rather than mutating the dictionary from
312    /// inside the script; the host spends it here so the regenerated
313    /// appearance sees the new style and the file is left alone.
314    pub border_style: Option<BorderStyle>,
315    /// Vertically centre each list row in its plate (`SetAlignmentV(1)`).
316    /// The stored list-box appearance stacks from the top; a combo popup
317    /// is a `CPWL_ListBox` and centres.
318    pub center_rows: bool,
319}
320
321/// The same again, for a widget a form session is currently editing.
322///
323/// `caret_and_selection` is the focused-field overlay and `live` is what the
324/// session is showing in place of the stored `/V`, `/I` and `/TI`. Passing
325/// [`None`] for both is exactly `generate_with_text`, byte for byte — the
326/// two differ only in what this one is allowed to be handed.
327///
328/// # Superseded by [`generate_with_live_faces`]
329///
330/// This one forwards no substitute, so a live edit whose text needs a second
331/// face writes the `/DA` font's low bytes for it — Latin glyphs where the
332/// value is Hebrew. `pdfrum-form`'s `route.rs` has since migrated to
333/// [`generate_with_live_faces`], which takes the same two answers plus that
334/// face in one [`LiveInput`], so nothing in the library calls this any more.
335/// It stays under `#[cfg(test)]` because the tests beside it are what pin
336/// that the no-substitute spelling still agrees with `generate_with_text`
337/// byte for byte.
338#[must_use]
339#[cfg(test)]
340pub(crate) fn generate_with_live<R: Resolve>(
341    dict: &Dict,
342    catalog: &Dict,
343    font: &crate::ap::TextFont<'_>,
344    r: &R,
345    caret_and_selection: Option<&crate::ap::field_body::Highlight>,
346    live: Option<&crate::ap::field_body::LiveState<'_>>,
347) -> Option<GeneratedAp> {
348    generate_with_live_faces(
349        dict,
350        catalog,
351        font,
352        r,
353        LiveInput {
354            caret_and_selection,
355            live,
356            // Both spelled out rather than left to `..Default::default()`:
357            // this function's contract is that it produces exactly what it
358            // produced before either field existed, and naming them is what
359            // makes a third addition a compile error here rather than a
360            // silent change of behaviour.
361            substitute: None,
362            appearance_state: None,
363            border_style: None,
364            center_rows: false,
365        },
366    )
367}
368
369/// The live entry point that can reach a **second face**.
370///
371/// `generate_with_live` with the substitute carried in the same record as
372/// the overlay and the live text. A field being typed into asks the same
373/// charset question a stored value does — the face is chosen per character,
374/// and nothing in that choice knows where the characters came from — so the
375/// typed path needs the same answer the stored one gets from
376/// `generate_with_text`'s `substitute`.
377///
378/// `LiveInput::default()` here is `generate`-with-a-font, byte for byte:
379/// the three fields are each [`None`] and nothing downstream distinguishes
380/// them from the stored path's arguments.
381#[must_use]
382pub fn generate_with_live_faces<R: Resolve>(
383    dict: &Dict,
384    catalog: &Dict,
385    font: &crate::ap::TextFont<'_>,
386    r: &R,
387    input: LiveInput<'_>,
388) -> Option<GeneratedAp> {
389    build(dict, Some(catalog), Some(font), input, r)
390}
391
392/// The shared builder: chrome, then the body when there is a font for one.
393/// The shared builder's live answers travel as one [`LiveInput`] rather than
394/// as four positional `Option`s, which is the same reason the public entry
395/// point takes one: a fifth answer then costs no call site a change.
396fn build<R: Resolve>(
397    dict: &Dict,
398    catalog: Option<&Dict>,
399    font: Option<&crate::ap::TextFont<'_>>,
400    input: LiveInput<'_>,
401    r: &R,
402) -> Option<GeneratedAp> {
403    if !needs_appearance_in(dict, catalog, r) {
404        return None;
405    }
406    let rect = rotated_rect(dict, r);
407    let mk = dict.dict(names::MK, r);
408    let background = mk
409        .as_ref()
410        .and_then(|mk| mk.array(names::BG, r))
411        .map_or(Color::Transparent, |array| Color::from_array(&array));
412    let border_color = mk
413        .as_ref()
414        .and_then(|mk| mk.array(names::BC, r))
415        .map_or(Color::Transparent, |array| Color::from_array(&array));
416
417    let mut out = Content::new();
418    let fill = color_op(background, PaintOp::Fill);
419    if !fill.is_empty() {
420        out.raw("q\n");
421        out.raw(&fill);
422        out.rect(rect, Float::Shortest);
423        out.raw("re f\nQ\n");
424    }
425
426    let info = border_info(dict, r, input.border_style);
427    let border = crate::ap::border::border_path(rect, info, border_color);
428    if !border.is_empty() {
429        out.raw("q\n");
430        out.raw(&border);
431        out.raw("Q\n");
432    }
433
434    // A checkbox or radio button's glyph belongs to its **on** state alone:
435    // the generator writes four streams — on and off, normal and down — and
436    // only the two on-states carry the shape. Which one a reader sees is
437    // decided by `/AS`, so a button sitting at `Off` shows chrome and nothing
438    // more. The shape is drawn whatever the text colour is: a transparent one
439    // writes no colour operator but leaves the path behind, which is why an
440    // unadorned radio button still reports one path object.
441    if is_checked_with(dict, r, input.appearance_state)
442        && let Some(style) = check_style(dict, r)
443    {
444        let client = geom::deflate(rect, info.width, info.width);
445        let color = text_color(dict, r);
446        out.raw(&if is_radio(dict, r) {
447            crate::ap::shapes::radio_button(client, style, color)
448        } else {
449            crate::ap::shapes::check_box(client, style, color)
450        });
451    }
452
453    // The body follows the chrome, and only a caller with a font can ask for
454    // one. A button reaches here with `None` from the dispatch below, which is
455    // how a checkbox keeps producing exactly the stream it did before.
456    let body = catalog.zip(font).and_then(|(catalog, font)| {
457        crate::ap::field_body::generate(
458            dict,
459            catalog,
460            font,
461            input.substitute,
462            r,
463            input.caret_and_selection,
464            input.live,
465            input.center_rows,
466        )
467    });
468    let fonts = body.as_ref().and_then(|body| body.font_resources.clone());
469    if let Some(body) = &body {
470        out.raw(&String::from_utf8_lossy(&body.stream));
471    }
472
473    Some(GeneratedAp {
474        stream: out.into_bytes(),
475        bbox: rect,
476        matrix: kurbo::Affine::IDENTITY,
477        resources: resources_dict(crate::ap::ext_gstate_dict(dict, false, r), fonts),
478        rect_override: None,
479        as_override: None,
480    })
481}
482
483/// Whether the widget's inherited `/FT` names a type the builder dispatches
484/// on.
485///
486/// Three of the eight field types reach no builder: a signature, and the two
487/// ways a type can be unknown — no `/FT` anywhere up the `/Parent` chain, and
488/// an `/FT` naming something outside the three the spec defines. Each falls
489/// off the end of the dispatch, and nothing is written.
490#[must_use]
491pub(crate) fn has_known_field_type<R: Resolve>(dict: &Dict, r: &R) -> bool {
492    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
493    let kind = attr::field_attr(dict, names::FT, r, &limits, &mut diags)
494        .map(|value| value.to_byte_string())
495        .unwrap_or_default();
496    matches!(kind.as_slice(), b"Btn" | b"Tx" | b"Ch")
497}
498
499/// Whether a button widget is showing its on-state.
500///
501/// `/AS` names the state the reader sees; anything but `Off` is on. A button
502/// with no `/AS` at all is off, because that is what the generator writes
503/// when it finds none.
504#[must_use]
505#[cfg(test)]
506pub(crate) fn is_checked<R: Resolve>(dict: &Dict, r: &R) -> bool {
507    is_checked_with(dict, r, None)
508}
509
510/// [`is_checked`], with a session's appearance state allowed to override the
511/// dictionary's `/AS`.
512///
513/// `None` is exactly [`is_checked`]. A `Some` is read by the same rule the
514/// dictionary's own value is — anything but `Off` is on — so a session that
515/// passes a sibling's `Off` draws chrome alone and one that passes the chosen
516/// control's on-state draws the shape, whatever that state happens to be
517/// named. See [`LiveInput::appearance_state`] for why the session cannot say
518/// this through the dictionary instead.
519#[must_use]
520pub(crate) fn is_checked_with<R: Resolve>(
521    dict: &Dict,
522    r: &R,
523    override_state: Option<&[u8]>,
524) -> bool {
525    match override_state {
526        Some(state) => state != names::OFF.as_bytes(),
527        None => match dict.byte_string(names::AS, r) {
528            Some(state) => state != names::OFF.as_bytes(),
529            None => false,
530        },
531    }
532}
533
534/// Which glyph a button widget draws, or nothing when it is not a button.
535///
536/// The style comes from the first character of `/MK /CA`, read as a
537/// ZapfDingbats code point — a naming convention, not a font lookup. An
538/// unrecognized or absent caption falls back per kind: a check mark for a
539/// checkbox, a circle for a radio button.
540#[must_use]
541pub(crate) fn check_style<R: Resolve>(dict: &Dict, r: &R) -> Option<CheckStyle> {
542    if !is_button(dict, r) {
543        return None;
544    }
545    let caption = dict
546        .dict(names::MK, r)
547        .and_then(|mk| mk.text(names::CA, r))
548        .unwrap_or_default();
549    Some(
550        shapes::style_from_caption(&caption).unwrap_or(if is_radio(dict, r) {
551            CheckStyle::Circle
552        } else {
553            CheckStyle::Check
554        }),
555    )
556}
557
558/// Whether the widget presents a checkbox or radio button.
559///
560/// Push buttons are excluded: they carry a caption and an icon rather than a
561/// glyph.
562#[must_use]
563pub(crate) fn is_button<R: Resolve>(dict: &Dict, r: &R) -> bool {
564    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
565    let kind = attr::field_attr(dict, names::FT, r, &limits, &mut diags)
566        .map(|value| value.to_byte_string())
567        .unwrap_or_default();
568    if kind != b"Btn" {
569        return false;
570    }
571    // Bit 17 is the push-button flag.
572    let flags = attr::field_attr(dict, names::FF, r, &limits, &mut diags)
573        .and_then(|value| value.as_int())
574        .unwrap_or(0);
575    flags & (1 << 16) == 0
576}
577
578/// Whether the widget is specifically a radio button — bit 16 of `/Ff`.
579#[must_use]
580pub(crate) fn is_radio<R: Resolve>(dict: &Dict, r: &R) -> bool {
581    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
582    let flags = attr::field_attr(dict, names::FF, r, &limits, &mut diags)
583        .and_then(|value| value.as_int())
584        .unwrap_or(0);
585    flags & (1 << 15) != 0
586}
587
588/// The colour a widget's glyph and text take, from its inherited `/DA`.
589///
590/// A `/DA` with no colour operator leaves the colour **transparent**, which
591/// writes no colour operator into the stream — the glyph then takes whatever
592/// the enclosing stream had set.
593#[must_use]
594pub(crate) fn text_color<R: Resolve>(dict: &Dict, r: &R) -> Color {
595    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
596    attr::field_attr(dict, names::DA, r, &limits, &mut diags)
597        .map(|value| value.to_byte_string())
598        .and_then(|da| da::color(&da))
599        .unwrap_or(Color::Transparent)
600}
601
602/// The widget's bounding box in its own, rotation-corrected space.
603///
604/// A widget's `/MK /R` names a quarter turn, folded through
605/// [`geom::WidgetRotation::from_degrees`]; at 90 or 270 degrees the box's
606/// width and height swap, and any angle that names no quadrant — a
607/// non-multiple of 90 — leaves the box upright. There is no value of `/R`
608/// that empties it.
609#[must_use]
610pub(crate) fn rotated_rect<R: Resolve>(dict: &Dict, r: &R) -> Rect {
611    let rect = dict.rect(obj_names::RECT, r);
612    let (width, height) = (geom::width(rect), geom::height(rect));
613    if widget_rotation(dict, r).swaps_axes() {
614        geom::rect(0.0, 0.0, height, width)
615    } else {
616        geom::rect(0.0, 0.0, width, height)
617    }
618}
619
620/// The widget's `/MK /R`, as the quadrant its appearance stream is set into.
621///
622/// The one reader of the key: `pdfrum-form`'s routing calls this too, so a
623/// click lands in the box this function measured.
624#[must_use]
625pub fn widget_rotation<R: Resolve>(dict: &Dict, r: &R) -> geom::WidgetRotation {
626    let degrees = dict
627        .dict(names::MK, r)
628        .and_then(|mk| mk.int(names::R, r))
629        .unwrap_or(0);
630    geom::WidgetRotation::from_degrees(degrees)
631}
632
633/// The widget's border style, which is read from the same `/BS` a markup
634/// annotation uses but defaults differently: a widget with no `/BS` gets a
635/// **one-unit solid** border, and only draws it when `/MK /BC` names a
636/// colour.
637#[must_use]
638pub fn widget_border<R: Resolve>(dict: &Dict, r: &R) -> BorderStyleInfo {
639    border_info(dict, r, None)
640}
641
642/// [`widget_border`], with a session's `Field.borderStyle` allowed to
643/// override `/BS /S`.
644///
645/// Width and dash still come from the dictionary. A beveled or inset
646/// override doubles the width the same way `/S /B` and `/S /I` do when they
647/// are read from the file.
648fn border_info<R: Resolve>(dict: &Dict, r: &R, style: Option<BorderStyle>) -> BorderStyleInfo {
649    let bs = dict.dict(names::BS, r);
650    let mut info = crate::ap::border::border_style_info(bs.as_ref(), r);
651    if bs.is_none() {
652        info = BorderStyleInfo {
653            width: crate::ap::border::border_width(dict, r),
654            style: BorderStyle::Solid,
655            dash: Dash::default(),
656        };
657    }
658    let Some(style) = style else {
659        return info;
660    };
661    let width = crate::ap::border::border_width(dict, r);
662    let mut info = BorderStyleInfo {
663        width,
664        style,
665        dash: info.dash,
666    };
667    if matches!(style, BorderStyle::Beveled | BorderStyle::Inset) {
668        info.width *= 2.0;
669    }
670    info
671}
672
673#[cfg(test)]
674mod tests {
675    use super::{
676        BorderStyle, checked_ap_state, generate, needs_appearance, needs_appearance_in,
677        rotated_rect,
678    };
679    use crate::geom;
680    use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Stream};
681
682    fn dict(pairs: &[(&str, Object)]) -> Dict {
683        Dict::from_pairs(
684            pairs
685                .iter()
686                .map(|(k, v)| (Name::from(*k), v.clone()))
687                .collect::<Vec<_>>(),
688        )
689    }
690
691    fn numbers(values: &[f32]) -> Object {
692        Object::Array(Array::of(values.iter().copied().map(Object::from)))
693    }
694
695    /// A widget of a field type the builder knows, since one it does not is
696    /// refused outright and would test nothing below.
697    fn widget(extra: &[(&str, Object)]) -> Dict {
698        let mut pairs = vec![
699            ("Subtype", Object::Name(Name::from("Widget"))),
700            ("FT", Object::Name(Name::from("Btn"))),
701            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
702        ];
703        pairs.extend_from_slice(extra);
704        dict(&pairs)
705    }
706
707    #[test]
708    fn a_widget_with_no_appearance_dictionary_gets_one() {
709        assert!(needs_appearance(&widget(&[]), &NoResolve));
710    }
711
712    #[test]
713    fn a_field_type_the_builder_does_not_dispatch_on_gets_nothing() {
714        // An intermediate node with `/Kids` and no `/FT` of its own, and a
715        // signature — the two shapes that fall off the end of the dispatch.
716        let no_type = dict(&[
717            ("Subtype", Object::Name(Name::from("Widget"))),
718            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
719        ]);
720        assert!(!needs_appearance(&no_type, &NoResolve));
721        assert!(generate(&no_type, &NoResolve).is_none());
722
723        let signature = dict(&[
724            ("Subtype", Object::Name(Name::from("Widget"))),
725            ("FT", Object::Name(Name::from("Sig"))),
726            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
727        ]);
728        assert!(!needs_appearance(&signature, &NoResolve));
729
730        // And the type is inherited, so a kid whose parent names it qualifies.
731        let parent = dict(&[("FT", Object::Name(Name::from("Tx")))]);
732        let kid = dict(&[
733            ("Subtype", Object::Name(Name::from("Widget"))),
734            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
735            ("Parent", Object::Dict(parent)),
736        ]);
737        assert!(needs_appearance(&kid, &NoResolve));
738    }
739
740    #[test]
741    fn an_appearance_that_resolves_leaves_the_widget_alone() {
742        let with_stream = widget(&[(
743            "AP",
744            Object::Dict(dict(&[(
745                "N",
746                Object::Stream(Box::new(Stream::new(
747                    Dict::new(),
748                    ByteSpan::from(b"x".to_vec()),
749                ))),
750            )])),
751        )]);
752        assert!(!needs_appearance(&with_stream, &NoResolve));
753    }
754
755    /// A catalog whose form sets `/NeedAppearances` to the given value.
756    fn form_catalog(need: Object) -> Dict {
757        dict(&[("AcroForm", Object::Dict(dict(&[("NeedAppearances", need)])))])
758    }
759
760    /// An `/AP` whose `/N` lists the given sub-states, each a stream.
761    fn states(names: &[&str]) -> Object {
762        Object::Dict(dict(&[(
763            "N",
764            Object::Dict(Dict::from_pairs(
765                names
766                    .iter()
767                    .map(|state| {
768                        (
769                            Name::from(*state),
770                            Object::Stream(Box::new(Stream::new(
771                                Dict::new(),
772                                ByteSpan::from(b"x".to_vec()),
773                            ))),
774                        )
775                    })
776                    .collect::<Vec<_>>(),
777            )),
778        )]))
779    }
780
781    #[test]
782    fn need_appearances_rebuilds_a_text_field_that_already_has_one() {
783        // Only a checkbox and a radio button write sub-states, so nothing
784        // filters a rebuilt text field: it is always seen.
785        let field = dict(&[
786            ("Subtype", Object::Name(Name::from("Widget"))),
787            ("FT", Object::Name(Name::from("Tx"))),
788            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
789            (
790                "AP",
791                Object::Dict(dict(&[(
792                    "N",
793                    Object::Stream(Box::new(Stream::new(
794                        Dict::new(),
795                        ByteSpan::from(b"x".to_vec()),
796                    ))),
797                )])),
798            ),
799        ]);
800        assert!(!needs_appearance(&field, &NoResolve));
801        for (need, expected) in [
802            (Object::Bool(true), true),
803            (Object::Bool(false), false),
804            // `GetBooleanFor` reads a boolean and nothing else.
805            (Object::Name(Name::from("true")), false),
806            (
807                Object::Str(pdfrum_object::PdfString::literal(b"true")),
808                false,
809            ),
810        ] {
811            assert_eq!(
812                needs_appearance_in(&field, Some(&form_catalog(need.clone())), &NoResolve),
813                expected,
814                "{need:?}"
815            );
816        }
817    }
818
819    #[test]
820    fn a_rebuild_a_button_would_never_read_back_does_not_happen() {
821        let catalog = form_catalog(Object::Bool(true));
822        let button = |extra: &[(&str, Object)]| {
823            let mut pairs = vec![
824                ("FT", Object::Name(Name::from("Btn"))),
825                ("AP", states(&["Yes", "Off"])),
826            ];
827            pairs.extend_from_slice(extra);
828            widget(&pairs)
829        };
830
831        // `/AS` names `Off`, which a rebuild always writes literally.
832        let off = button(&[("AS", Object::Name(Name::from("Off")))]);
833        assert!(needs_appearance_in(&off, Some(&catalog), &NoResolve));
834
835        // `/AS` names the on-state a rebuild would write.
836        let on = button(&[("AS", Object::Name(Name::from("Yes")))]);
837        assert!(needs_appearance_in(&on, Some(&catalog), &NoResolve));
838
839        // `/AS` names a third state, which a rebuild leaves untouched — so
840        // the file's own stream keeps drawing and nothing is regenerated.
841        let elsewhere = button(&[("AS", Object::Name(Name::from("Maybe")))]);
842        assert!(!needs_appearance_in(&elsewhere, Some(&catalog), &NoResolve));
843
844        // No `/AS` at all reads back the empty key, which is never written.
845        assert!(!needs_appearance_in(
846            &button(&[]),
847            Some(&catalog),
848            &NoResolve
849        ));
850    }
851
852    #[test]
853    fn an_opt_array_makes_the_on_state_a_control_index() {
854        // `bug_861842`'s shape: `/Opt` present, so the rebuilt on-state is the
855        // widget's control index — `0` — while `/AS` still reads `1`. The two
856        // never meet and the file's own stream survives.
857        let catalog = form_catalog(Object::Bool(true));
858        let with_opt = widget(&[
859            ("FT", Object::Name(Name::from("Btn"))),
860            ("AP", states(&["1", "Off"])),
861            ("AS", Object::Name(Name::from("1"))),
862            ("Opt", numbers(&[0.0, 0.0])),
863        ]);
864        assert_eq!(checked_ap_state(&with_opt, &NoResolve), b"0".to_vec());
865        assert!(!needs_appearance_in(&with_opt, Some(&catalog), &NoResolve));
866
867        // Without `/Opt` the on-state is the first non-`Off` key, `/AS`
868        // matches it, and the rebuild is seen.
869        let without = widget(&[
870            ("FT", Object::Name(Name::from("Btn"))),
871            ("AP", states(&["1", "Off"])),
872            ("AS", Object::Name(Name::from("1"))),
873        ]);
874        assert_eq!(checked_ap_state(&without, &NoResolve), b"1".to_vec());
875        assert!(needs_appearance_in(&without, Some(&catalog), &NoResolve));
876    }
877
878    #[test]
879    fn the_on_state_is_the_first_key_in_sorted_order_and_falls_back_to_yes() {
880        // The C++ walks a `std::map`, so the order is the keys' own, not the
881        // document's. Written `Zed` first, `Alpha` wins.
882        let sorted = widget(&[
883            ("FT", Object::Name(Name::from("Btn"))),
884            ("AP", states(&["Zed", "Off", "Alpha"])),
885        ]);
886        assert_eq!(checked_ap_state(&sorted, &NoResolve), b"Alpha".to_vec());
887
888        // An `/AP /N` with nothing but `Off` — or none at all — answers `Yes`.
889        let off_only = widget(&[
890            ("FT", Object::Name(Name::from("Btn"))),
891            ("AP", states(&["Off"])),
892        ]);
893        assert_eq!(checked_ap_state(&off_only, &NoResolve), b"Yes".to_vec());
894        assert_eq!(
895            checked_ap_state(
896                &widget(&[("FT", Object::Name(Name::from("Btn")))]),
897                &NoResolve
898            ),
899            b"Yes".to_vec()
900        );
901    }
902
903    #[test]
904    fn an_unusable_appearance_is_still_an_appearance() {
905        // `/AP /N` lists only the on-state while `/AS` reads `Off`, so nothing
906        // resolves — and the regeneration test does not care, because it is
907        // `!!GetDictFor("AP")` and no more. Neither a checkbox nor a radio is
908        // rebuilt. What draws them is `annot_render::invalid_outline`, which
909        // asks the *deeper* question on a different code path.
910        let unusable = [
911            (
912                "AP",
913                Object::Dict(dict(&[(
914                    "N",
915                    Object::Dict(dict(&[(
916                        "Yes",
917                        Object::Stream(Box::new(Stream::new(
918                            Dict::new(),
919                            ByteSpan::from(b"x".to_vec()),
920                        ))),
921                    )])),
922                )])),
923            ),
924            ("AS", Object::Name(Name::from("Off"))),
925            ("FT", Object::Name(Name::from("Btn"))),
926        ];
927        assert!(!needs_appearance(&widget(&unusable), &NoResolve));
928
929        let mut radio_pairs = unusable.to_vec();
930        // Bit 16 is the radio flag.
931        radio_pairs.push(("Ff", Object::Int(1 << 15)));
932        assert!(!needs_appearance(&widget(&radio_pairs), &NoResolve));
933    }
934
935    #[test]
936    fn a_buttons_glyph_belongs_to_its_on_state_alone() {
937        let base = [
938            ("FT", Object::Name(Name::from("Btn"))),
939            ("Ff", Object::Int(1 << 15)),
940        ];
941        let mut off = base.to_vec();
942        off.push(("AS", Object::Name(Name::from("Off"))));
943        let off = generate(&widget(&off), &NoResolve).expect("is a widget");
944        assert!(off.stream.is_empty());
945
946        let mut on = base.to_vec();
947        on.push(("AS", Object::Name(Name::from("Yes"))));
948        let on = generate(&widget(&on), &NoResolve).expect("is a widget");
949        let stream = String::from_utf8_lossy(&on.stream).into_owned();
950        // A circle, since a radio with no caption defaults to one.
951        assert!(stream.contains(" c\n"), "{stream}");
952        assert!(stream.ends_with("f\nQ\n"), "{stream}");
953    }
954
955    #[test]
956    fn a_non_widget_is_left_alone() {
957        let square = dict(&[("Subtype", Object::Name(Name::from("Square")))]);
958        assert!(!needs_appearance(&square, &NoResolve));
959        assert!(generate(&square, &NoResolve).is_none());
960    }
961
962    #[test]
963    fn a_plain_widget_produces_an_empty_stream() {
964        // No `/MK`, so neither the background nor the border has a colour and
965        // nothing is drawn — a valid appearance with no page objects in it.
966        let got = generate(&widget(&[]), &NoResolve).expect("is a widget");
967        assert!(got.stream.is_empty());
968        assert_eq!(got.bbox, geom::rect(0.0, 0.0, 100.0, 30.0));
969        // The rectangle is untouched: only the sticky-note and ink
970        // generators move one.
971        assert_eq!(got.rect_override, None);
972    }
973
974    #[test]
975    fn a_background_colour_fills_the_box() {
976        let coloured = widget(&[(
977            "MK",
978            Object::Dict(dict(&[("BG", numbers(&[1.0, 0.0, 0.0]))])),
979        )]);
980        let got = generate(&coloured, &NoResolve).expect("is a widget");
981        assert_eq!(
982            String::from_utf8_lossy(&got.stream),
983            "q\n1 0 0 rg\n0 0 100 30 re f\nQ\n"
984        );
985    }
986
987    #[test]
988    fn a_border_colour_draws_the_border() {
989        let bordered = widget(&[(
990            "MK",
991            Object::Dict(dict(&[("BC", numbers(&[0.0, 0.0, 0.0]))])),
992        )]);
993        let got = generate(&bordered, &NoResolve).expect("is a widget");
994        let stream = String::from_utf8_lossy(&got.stream).into_owned();
995        assert!(stream.starts_with("q\n0 0 0 rg\n"), "{stream}");
996        assert!(stream.ends_with("Q\n"), "{stream}");
997    }
998
999    #[test]
1000    fn a_quarter_turn_swaps_the_boxs_extents() {
1001        let rotated = |degrees: i64| {
1002            rotated_rect(
1003                &widget(&[("MK", Object::Dict(dict(&[("R", Object::Int(degrees))])))]),
1004                &NoResolve,
1005            )
1006        };
1007        assert_eq!(rotated(0), geom::rect(0.0, 0.0, 100.0, 30.0));
1008        assert_eq!(rotated(180), geom::rect(0.0, 0.0, 100.0, 30.0));
1009        assert_eq!(rotated(90), geom::rect(0.0, 0.0, 30.0, 100.0));
1010        assert_eq!(rotated(270), geom::rect(0.0, 0.0, 30.0, 100.0));
1011    }
1012
1013    #[test]
1014    fn a_rotation_that_is_not_a_quarter_turn_leaves_the_box_upright() {
1015        // No value of `/R` empties the box. A non-multiple of 90 names no
1016        // quadrant — PDFium's `default:` arm and pdf.js's `angle % 90 === 0`
1017        // gate both land on upright — and a negative angle names the
1018        // quadrant it counts counterclockwise to.
1019        let rotated = |degrees: i64| {
1020            rotated_rect(
1021                &widget(&[("MK", Object::Dict(dict(&[("R", Object::Int(degrees))])))]),
1022                &NoResolve,
1023            )
1024        };
1025        let upright = geom::rect(0.0, 0.0, 100.0, 30.0);
1026        let turned = geom::rect(0.0, 0.0, 30.0, 100.0);
1027
1028        assert_eq!(rotated(45), upright);
1029        assert_eq!(rotated(-45), upright);
1030        assert_eq!(rotated(1), upright);
1031
1032        // `-90` is `270`: a swap, not an empty box. PDFium's `abs()` sends it
1033        // to `90`, which swaps the same axes but is the wrong quadrant for
1034        // the matrix — see the `[oracle-bug]` note on `WidgetRotation`.
1035        assert_eq!(rotated(-90), turned);
1036        assert_eq!(rotated(-270), turned);
1037        assert_eq!(rotated(-180), upright);
1038        assert_eq!(rotated(450), turned);
1039    }
1040
1041    /// A text widget with a `/DA` the font resource below satisfies.
1042    fn text_widget(value: &str) -> Dict {
1043        dict(&[
1044            ("Subtype", Object::Name(Name::from("Widget"))),
1045            ("FT", Object::Name(Name::from("Tx"))),
1046            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
1047            (
1048                "DA",
1049                Object::Str(pdfrum_object::PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
1050            ),
1051            ("V", Object::Str(pdfrum_object::PdfString::literal(value))),
1052        ])
1053    }
1054
1055    fn text_catalog() -> Dict {
1056        dict(&[(
1057            "AcroForm",
1058            Object::Dict(dict(&[(
1059                "DR",
1060                Object::Dict(dict(&[(
1061                    "Font",
1062                    Object::Dict(dict(&[(
1063                        "Helv",
1064                        Object::Dict(crate::ap::freetext::fallback_font()),
1065                    )])),
1066                )])),
1067            )])),
1068        )])
1069    }
1070
1071    #[test]
1072    fn the_live_entry_point_carries_its_override_down_to_the_body() {
1073        let cache = pdfrum_font::FontCache::new();
1074        let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1075        let width = |code: u32| crate::ap::TextFont::char_width(&face, code);
1076        let font = crate::ap::TextFont {
1077            metrics: crate::ap::TextFont::metrics_of(&face, &width),
1078            font: &face,
1079        };
1080        let (widget, catalog) = (text_widget("stored"), text_catalog());
1081        let stream = |ap: Option<super::GeneratedAp>| {
1082            String::from_utf8_lossy(&ap.expect("an appearance").stream).into_owned()
1083        };
1084
1085        let stored = stream(super::generate_with_text(
1086            &widget, &catalog, &font, None, &NoResolve,
1087        ));
1088        assert!(stored.contains("(stored) Tj\n"), "{stored}");
1089
1090        let live = crate::ap::field_body::LiveState {
1091            text: "typed",
1092            ..crate::ap::field_body::LiveState::default()
1093        };
1094        let edited = stream(super::generate_with_live(
1095            &widget,
1096            &catalog,
1097            &font,
1098            &NoResolve,
1099            None,
1100            Some(&live),
1101        ));
1102        assert!(edited.contains("(typed) Tj\n"), "{edited}");
1103        assert!(!edited.contains("stored"), "{edited}");
1104
1105        // And handed nothing, the live entry point is the stored one.
1106        assert_eq!(
1107            stored,
1108            stream(super::generate_with_live(
1109                &widget, &catalog, &font, &NoResolve, None, None,
1110            ))
1111        );
1112    }
1113
1114    /// Text a session is **typing** reaches the second face, which is the one
1115    /// thing [`super::generate_with_live`] cannot do: it forwards no
1116    /// substitute, so the same string comes out as the `/DA` font's low
1117    /// bytes.
1118    ///
1119    /// The expectations are the stored path's, from
1120    /// `field_body::tests::a_value_the_da_font_cannot_write_switches_to_a_second_face`:
1121    /// aleph is written `\340` and bet `\341` — code page 1255 — and not
1122    /// `\320`/`\321`, which are the low bytes of U+05D0 and U+05D1 and the
1123    /// mojibake this replaces.
1124    #[test]
1125    fn the_live_path_with_a_second_face_writes_hebrew_through_it() {
1126        let cache = pdfrum_font::FontCache::new();
1127        let options = pdfrum_font::SubstitutionOptions::default();
1128        let mut ctx = pdfrum_page::BuildContext::with_substitution(options);
1129        let catalog = text_catalog();
1130        let fonts = crate::ap::FormFonts::load(&catalog, &NoResolve, &mut ctx);
1131        let substitute = fonts
1132            .substitute(pdfrum_font::Charset::Hebrew)
1133            .expect("a Hebrew substitute");
1134
1135        let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1136        let charset = crate::ap::font_map::font_charset(&face);
1137        // The run is measured by the face that writes each character, or it
1138        // is set in two faces and laid out by one.
1139        let width = |code: u32| {
1140            if crate::ap::font_map::da_font_writes(&face, charset, code) {
1141                crate::ap::TextFont::char_width(&face, code)
1142            } else {
1143                crate::ap::font_map::substitute_width(substitute.font, code)
1144            }
1145        };
1146        let font = crate::ap::TextFont {
1147            metrics: crate::ap::TextFont::metrics_of(&face, &width),
1148            font: &face,
1149        };
1150
1151        // Typed, not stored: the widget's `/V` is Latin and stays unread.
1152        let widget = text_widget("stored");
1153        let state = crate::ap::field_body::LiveState {
1154            text: "ab\u{5D0}\u{5D1}",
1155            ..crate::ap::field_body::LiveState::default()
1156        };
1157        let live = |substitute| {
1158            super::generate_with_live_faces(
1159                &widget,
1160                &catalog,
1161                &font,
1162                &NoResolve,
1163                super::LiveInput {
1164                    live: Some(&state),
1165                    substitute,
1166                    ..super::LiveInput::default()
1167                },
1168            )
1169            .expect("an appearance")
1170        };
1171
1172        // Compared as bytes: a code-page byte is not valid UTF-8, so reading
1173        // the stream as text could not tell 0xE0 from 0xD0.
1174        let got = live(Some(substitute));
1175        let stream = got.stream.clone();
1176        let has = |needle: &[u8]| stream.windows(needle.len()).any(|w| w == needle);
1177
1178        assert!(has(b"/Helv 12 Tf\n"), "{stream:02X?}");
1179        let mut tf = b"/".to_vec();
1180        tf.extend_from_slice(substitute.alias.as_bytes());
1181        tf.extend_from_slice(b" 12 Tf\n");
1182        assert!(has(&tf), "the second face names itself: {stream:02X?}");
1183        assert!(has(b"\\340"), "aleph as 0xE0: {stream:02X?}");
1184        assert!(has(b"\\341"), "bet as 0xE1: {stream:02X?}");
1185        assert!(!has(b"\\320"), "no low-byte aleph: {stream:02X?}");
1186        assert!(!has(b"\\321"), "no low-byte bet: {stream:02X?}");
1187        assert!(
1188            got.resources
1189                .dict(crate::names::FONT, &NoResolve)
1190                .is_some_and(|fonts| fonts.contains_key(substitute.alias)),
1191            "the second face is in the appearance's own resources: {:?}",
1192            got.resources
1193        );
1194
1195        // Without it, the same string is the mojibake this closes — which is
1196        // exactly what `generate_with_live` still produces.
1197        let plain = live(None);
1198        assert_ne!(plain.stream, stream);
1199        let plain_has = |needle: &[u8]| plain.stream.windows(needle.len()).any(|w| w == needle);
1200        assert!(plain_has(b"\\320"), "{:02X?}", plain.stream);
1201        assert_eq!(
1202            plain.stream,
1203            super::generate_with_live(&widget, &catalog, &font, &NoResolve, None, Some(&state),)
1204                .expect("an appearance")
1205                .stream,
1206            "the old entry point is the new one with no substitute"
1207        );
1208    }
1209
1210    /// A session's appearance state overrides the widget's own `/AS`, in both
1211    /// directions.
1212    ///
1213    /// This is what lets a radio group's click be drawn. Checking one kid
1214    /// sets that control's `/AS` to its own on-state and every other
1215    /// control's to `Off` — one click, one state per kid, in as many
1216    /// different names. A session holds one record per **field**, so it can
1217    /// only say which control was chosen; this is how it says what each kid
1218    /// draws.
1219    ///
1220    /// The dictionary is untouched either way: the same widget answers both
1221    /// ways depending only on what is passed.
1222    #[test]
1223    fn a_sessions_appearance_state_overrides_the_dictionarys_own() {
1224        let catalog = Dict::new();
1225        let cache = pdfrum_font::FontCache::new();
1226        let font = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1227        let width = |code: u32| crate::ap::TextFont::char_width(&font, code);
1228        let text = crate::ap::TextFont {
1229            metrics: crate::ap::TextFont::metrics_of(&font, &width),
1230            font: &font,
1231        };
1232        let radio = |state: &str| {
1233            widget(&[
1234                ("FT", Object::Name(Name::from("Btn"))),
1235                ("Ff", Object::Int(1 << 15)),
1236                ("AS", Object::Name(Name::from(state))),
1237            ])
1238        };
1239        let draw = |dict: &Dict, override_state: Option<&[u8]>| {
1240            super::generate_with_live_faces(
1241                dict,
1242                &catalog,
1243                &text,
1244                &NoResolve,
1245                super::LiveInput {
1246                    appearance_state: override_state,
1247                    ..super::LiveInput::default()
1248                },
1249            )
1250            .expect("is a widget")
1251            .stream
1252        };
1253        // A circle is what a radio with no caption draws, so its presence is
1254        // the on-state and its absence the off.
1255        let drawn = |stream: &[u8]| String::from_utf8_lossy(stream).contains(" c\n");
1256
1257        // On by its dictionary, forced off by the session — the sibling of a
1258        // control that was just clicked.
1259        let on = radio("Yes");
1260        assert!(drawn(&draw(&on, None)), "its own /AS says Yes");
1261        assert!(!drawn(&draw(&on, Some(b"Off"))), "the session says Off");
1262
1263        // Off by its dictionary, forced on — the control that was clicked,
1264        // whose on-state is its own name and not the sibling's.
1265        let off = radio("Off");
1266        assert!(!drawn(&draw(&off, None)), "its own /AS says Off");
1267        assert!(drawn(&draw(&off, Some(b"Yes"))), "the session says Yes");
1268        assert!(
1269            drawn(&draw(&off, Some(b"2"))),
1270            "any non-Off state is on, whatever it is named"
1271        );
1272
1273        // And `None` is byte-for-byte the unoverridden stream, which is what
1274        // keeps every existing caller where it was.
1275        assert_eq!(draw(&on, None), draw(&on, None));
1276        assert_eq!(
1277            draw(&on, Some(b"Yes")),
1278            draw(&on, None),
1279            "an override naming the state already shown changes nothing"
1280        );
1281    }
1282
1283    /// `is_checked` is `is_checked_with` handed no override, and the public
1284    /// signature `pdfrum/src/form.rs` and `form/field.rs` call is unchanged.
1285    #[test]
1286    fn is_checked_is_the_unoverridden_case_of_is_checked_with() {
1287        for state in ["Off", "Yes", "2", ""] {
1288            let dict = widget(&[("AS", Object::Name(Name::from(state)))]);
1289            assert_eq!(
1290                super::is_checked(&dict, &NoResolve),
1291                super::is_checked_with(&dict, &NoResolve, None),
1292                "{state:?}"
1293            );
1294        }
1295        // A widget with no `/AS` at all is off, and an override still speaks.
1296        let bare = widget(&[]);
1297        assert!(!super::is_checked(&bare, &NoResolve));
1298        assert!(!super::is_checked_with(&bare, &NoResolve, Some(b"Off")));
1299        assert!(super::is_checked_with(&bare, &NoResolve, Some(b"Yes")));
1300    }
1301
1302    /// A session's `Field.borderStyle` write changes the chrome without
1303    /// mutating `/BS`. Dashed is the spelling `Bug765384` assigns.
1304    #[test]
1305    fn a_sessions_border_style_overrides_the_dictionarys_own() {
1306        let catalog = Dict::new();
1307        let cache = pdfrum_font::FontCache::new();
1308        let font = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1309        let width = |code: u32| crate::ap::TextFont::char_width(&font, code);
1310        let text = crate::ap::TextFont {
1311            metrics: crate::ap::TextFont::metrics_of(&font, &width),
1312            font: &font,
1313        };
1314        let dict = widget(&[
1315            ("FT", Object::Name(Name::from("Tx"))),
1316            ("MK", Object::Dict(dict(&[("BC", numbers(&[0.0]))]))),
1317        ]);
1318        let draw = |style: Option<BorderStyle>| {
1319            super::generate_with_live_faces(
1320                &dict,
1321                &catalog,
1322                &text,
1323                &NoResolve,
1324                super::LiveInput {
1325                    border_style: style,
1326                    ..super::LiveInput::default()
1327                },
1328            )
1329            .expect("is a widget")
1330            .stream
1331        };
1332        let solid = draw(None);
1333        let dashed = draw(Some(BorderStyle::Dash));
1334        assert_ne!(solid, dashed, "the override must change the stream");
1335        assert!(
1336            String::from_utf8_lossy(&dashed).contains(" d\n"),
1337            "a dashed override writes a dash pattern: {}",
1338            String::from_utf8_lossy(&dashed)
1339        );
1340    }
1341}