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}
309
310/// The same again, for a widget a form session is currently editing.
311///
312/// `caret_and_selection` is the focused-field overlay and `live` is what the
313/// session is showing in place of the stored `/V`, `/I` and `/TI`. Passing
314/// [`None`] for both is exactly `generate_with_text`, byte for byte — the
315/// two differ only in what this one is allowed to be handed.
316///
317/// # Superseded by [`generate_with_live_faces`]
318///
319/// This one forwards no substitute, so a live edit whose text needs a second
320/// face writes the `/DA` font's low bytes for it — Latin glyphs where the
321/// value is Hebrew. `pdfrum-form`'s `route.rs` has since migrated to
322/// [`generate_with_live_faces`], which takes the same two answers plus that
323/// face in one [`LiveInput`], so nothing in the library calls this any more.
324/// It stays under `#[cfg(test)]` because the tests beside it are what pin
325/// that the no-substitute spelling still agrees with `generate_with_text`
326/// byte for byte.
327#[must_use]
328#[cfg(test)]
329pub(crate) fn generate_with_live<R: Resolve>(
330    dict: &Dict,
331    catalog: &Dict,
332    font: &crate::ap::TextFont<'_>,
333    r: &R,
334    caret_and_selection: Option<&crate::ap::field_body::Highlight>,
335    live: Option<&crate::ap::field_body::LiveState<'_>>,
336) -> Option<GeneratedAp> {
337    generate_with_live_faces(
338        dict,
339        catalog,
340        font,
341        r,
342        LiveInput {
343            caret_and_selection,
344            live,
345            // Both spelled out rather than left to `..Default::default()`:
346            // this function's contract is that it produces exactly what it
347            // produced before either field existed, and naming them is what
348            // makes a third addition a compile error here rather than a
349            // silent change of behaviour.
350            substitute: None,
351            appearance_state: None,
352        },
353    )
354}
355
356/// The live entry point that can reach a **second face**.
357///
358/// `generate_with_live` with the substitute carried in the same record as
359/// the overlay and the live text. A field being typed into asks the same
360/// charset question a stored value does — the face is chosen per character,
361/// and nothing in that choice knows where the characters came from — so the
362/// typed path needs the same answer the stored one gets from
363/// `generate_with_text`'s `substitute`.
364///
365/// `LiveInput::default()` here is `generate`-with-a-font, byte for byte:
366/// the three fields are each [`None`] and nothing downstream distinguishes
367/// them from the stored path's arguments.
368#[must_use]
369pub fn generate_with_live_faces<R: Resolve>(
370    dict: &Dict,
371    catalog: &Dict,
372    font: &crate::ap::TextFont<'_>,
373    r: &R,
374    input: LiveInput<'_>,
375) -> Option<GeneratedAp> {
376    build(dict, Some(catalog), Some(font), input, r)
377}
378
379/// The shared builder: chrome, then the body when there is a font for one.
380/// The shared builder's live answers travel as one [`LiveInput`] rather than
381/// as four positional `Option`s, which is the same reason the public entry
382/// point takes one: a fifth answer then costs no call site a change.
383fn build<R: Resolve>(
384    dict: &Dict,
385    catalog: Option<&Dict>,
386    font: Option<&crate::ap::TextFont<'_>>,
387    input: LiveInput<'_>,
388    r: &R,
389) -> Option<GeneratedAp> {
390    if !needs_appearance_in(dict, catalog, r) {
391        return None;
392    }
393    let rect = rotated_rect(dict, r);
394    let mk = dict.dict(names::MK, r);
395    let background = mk
396        .as_ref()
397        .and_then(|mk| mk.array(names::BG, r))
398        .map_or(Color::Transparent, |array| Color::from_array(&array));
399    let border_color = mk
400        .as_ref()
401        .and_then(|mk| mk.array(names::BC, r))
402        .map_or(Color::Transparent, |array| Color::from_array(&array));
403
404    let mut out = Content::new();
405    let fill = color_op(background, PaintOp::Fill);
406    if !fill.is_empty() {
407        out.raw("q\n");
408        out.raw(&fill);
409        out.rect(rect, Float::Shortest);
410        out.raw("re f\nQ\n");
411    }
412
413    let info = widget_border(dict, r);
414    let border = crate::ap::border::border_path(rect, info, border_color);
415    if !border.is_empty() {
416        out.raw("q\n");
417        out.raw(&border);
418        out.raw("Q\n");
419    }
420
421    // A checkbox or radio button's glyph belongs to its **on** state alone:
422    // the generator writes four streams — on and off, normal and down — and
423    // only the two on-states carry the shape. Which one a reader sees is
424    // decided by `/AS`, so a button sitting at `Off` shows chrome and nothing
425    // more. The shape is drawn whatever the text colour is: a transparent one
426    // writes no colour operator but leaves the path behind, which is why an
427    // unadorned radio button still reports one path object.
428    if is_checked_with(dict, r, input.appearance_state)
429        && let Some(style) = check_style(dict, r)
430    {
431        let client = geom::deflate(rect, info.width, info.width);
432        let color = text_color(dict, r);
433        out.raw(&if is_radio(dict, r) {
434            crate::ap::shapes::radio_button(client, style, color)
435        } else {
436            crate::ap::shapes::check_box(client, style, color)
437        });
438    }
439
440    // The body follows the chrome, and only a caller with a font can ask for
441    // one. A button reaches here with `None` from the dispatch below, which is
442    // how a checkbox keeps producing exactly the stream it did before.
443    let body = catalog.zip(font).and_then(|(catalog, font)| {
444        crate::ap::field_body::generate(
445            dict,
446            catalog,
447            font,
448            input.substitute,
449            r,
450            input.caret_and_selection,
451            input.live,
452        )
453    });
454    let fonts = body.as_ref().and_then(|body| body.font_resources.clone());
455    if let Some(body) = &body {
456        out.raw(&String::from_utf8_lossy(&body.stream));
457    }
458
459    Some(GeneratedAp {
460        stream: out.into_bytes(),
461        bbox: rect,
462        matrix: kurbo::Affine::IDENTITY,
463        resources: resources_dict(crate::ap::ext_gstate_dict(dict, false, r), fonts),
464        rect_override: None,
465        as_override: None,
466    })
467}
468
469/// Whether the widget's inherited `/FT` names a type the builder dispatches
470/// on.
471///
472/// Three of the eight field types reach no builder: a signature, and the two
473/// ways a type can be unknown — no `/FT` anywhere up the `/Parent` chain, and
474/// an `/FT` naming something outside the three the spec defines. Each falls
475/// off the end of the dispatch, and nothing is written.
476#[must_use]
477pub(crate) fn has_known_field_type<R: Resolve>(dict: &Dict, r: &R) -> bool {
478    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
479    let kind = attr::field_attr(dict, names::FT, r, &limits, &mut diags)
480        .map(|value| value.to_byte_string())
481        .unwrap_or_default();
482    matches!(kind.as_slice(), b"Btn" | b"Tx" | b"Ch")
483}
484
485/// Whether a button widget is showing its on-state.
486///
487/// `/AS` names the state the reader sees; anything but `Off` is on. A button
488/// with no `/AS` at all is off, because that is what the generator writes
489/// when it finds none.
490#[must_use]
491#[cfg(test)]
492pub(crate) fn is_checked<R: Resolve>(dict: &Dict, r: &R) -> bool {
493    is_checked_with(dict, r, None)
494}
495
496/// [`is_checked`], with a session's appearance state allowed to override the
497/// dictionary's `/AS`.
498///
499/// `None` is exactly [`is_checked`]. A `Some` is read by the same rule the
500/// dictionary's own value is — anything but `Off` is on — so a session that
501/// passes a sibling's `Off` draws chrome alone and one that passes the chosen
502/// control's on-state draws the shape, whatever that state happens to be
503/// named. See [`LiveInput::appearance_state`] for why the session cannot say
504/// this through the dictionary instead.
505#[must_use]
506pub(crate) fn is_checked_with<R: Resolve>(
507    dict: &Dict,
508    r: &R,
509    override_state: Option<&[u8]>,
510) -> bool {
511    match override_state {
512        Some(state) => state != names::OFF.as_bytes(),
513        None => match dict.byte_string(names::AS, r) {
514            Some(state) => state != names::OFF.as_bytes(),
515            None => false,
516        },
517    }
518}
519
520/// Which glyph a button widget draws, or nothing when it is not a button.
521///
522/// The style comes from the first character of `/MK /CA`, read as a
523/// ZapfDingbats code point — a naming convention, not a font lookup. An
524/// unrecognized or absent caption falls back per kind: a check mark for a
525/// checkbox, a circle for a radio button.
526#[must_use]
527pub(crate) fn check_style<R: Resolve>(dict: &Dict, r: &R) -> Option<CheckStyle> {
528    if !is_button(dict, r) {
529        return None;
530    }
531    let caption = dict
532        .dict(names::MK, r)
533        .and_then(|mk| mk.text(names::CA, r))
534        .unwrap_or_default();
535    Some(
536        shapes::style_from_caption(&caption).unwrap_or(if is_radio(dict, r) {
537            CheckStyle::Circle
538        } else {
539            CheckStyle::Check
540        }),
541    )
542}
543
544/// Whether the widget presents a checkbox or radio button.
545///
546/// Push buttons are excluded: they carry a caption and an icon rather than a
547/// glyph.
548#[must_use]
549pub(crate) fn is_button<R: Resolve>(dict: &Dict, r: &R) -> bool {
550    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
551    let kind = attr::field_attr(dict, names::FT, r, &limits, &mut diags)
552        .map(|value| value.to_byte_string())
553        .unwrap_or_default();
554    if kind != b"Btn" {
555        return false;
556    }
557    // Bit 17 is the push-button flag.
558    let flags = attr::field_attr(dict, names::FF, r, &limits, &mut diags)
559        .and_then(|value| value.as_int())
560        .unwrap_or(0);
561    flags & (1 << 16) == 0
562}
563
564/// Whether the widget is specifically a radio button — bit 16 of `/Ff`.
565#[must_use]
566pub(crate) fn is_radio<R: Resolve>(dict: &Dict, r: &R) -> bool {
567    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
568    let flags = attr::field_attr(dict, names::FF, r, &limits, &mut diags)
569        .and_then(|value| value.as_int())
570        .unwrap_or(0);
571    flags & (1 << 15) != 0
572}
573
574/// The colour a widget's glyph and text take, from its inherited `/DA`.
575///
576/// A `/DA` with no colour operator leaves the colour **transparent**, which
577/// writes no colour operator into the stream — the glyph then takes whatever
578/// the enclosing stream had set.
579#[must_use]
580pub(crate) fn text_color<R: Resolve>(dict: &Dict, r: &R) -> Color {
581    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
582    attr::field_attr(dict, names::DA, r, &limits, &mut diags)
583        .map(|value| value.to_byte_string())
584        .and_then(|da| da::color(&da))
585        .unwrap_or(Color::Transparent)
586}
587
588/// The widget's bounding box in its own, rotation-corrected space.
589///
590/// A widget's `/MK /R` names a quarter turn, folded through
591/// [`geom::WidgetRotation::from_degrees`]; at 90 or 270 degrees the box's
592/// width and height swap, and any angle that names no quadrant — a
593/// non-multiple of 90 — leaves the box upright. There is no value of `/R`
594/// that empties it.
595#[must_use]
596pub(crate) fn rotated_rect<R: Resolve>(dict: &Dict, r: &R) -> Rect {
597    let rect = dict.rect(obj_names::RECT, r);
598    let (width, height) = (geom::width(rect), geom::height(rect));
599    if widget_rotation(dict, r).swaps_axes() {
600        geom::rect(0.0, 0.0, height, width)
601    } else {
602        geom::rect(0.0, 0.0, width, height)
603    }
604}
605
606/// The widget's `/MK /R`, as the quadrant its appearance stream is set into.
607///
608/// The one reader of the key: `pdfrum-form`'s routing calls this too, so a
609/// click lands in the box this function measured.
610#[must_use]
611pub fn widget_rotation<R: Resolve>(dict: &Dict, r: &R) -> geom::WidgetRotation {
612    let degrees = dict
613        .dict(names::MK, r)
614        .and_then(|mk| mk.int(names::R, r))
615        .unwrap_or(0);
616    geom::WidgetRotation::from_degrees(degrees)
617}
618
619/// The widget's border style, which is read from the same `/BS` a markup
620/// annotation uses but defaults differently: a widget with no `/BS` gets a
621/// **one-unit solid** border, and only draws it when `/MK /BC` names a
622/// colour.
623#[must_use]
624pub fn widget_border<R: Resolve>(dict: &Dict, r: &R) -> BorderStyleInfo {
625    let bs = dict.dict(names::BS, r);
626    let mut info = crate::ap::border::border_style_info(bs.as_ref(), r);
627    if bs.is_none() {
628        info = BorderStyleInfo {
629            width: crate::ap::border::border_width(dict, r),
630            style: BorderStyle::Solid,
631            dash: Dash::default(),
632        };
633    }
634    info
635}
636
637#[cfg(test)]
638mod tests {
639    use super::{checked_ap_state, generate, needs_appearance, needs_appearance_in, rotated_rect};
640    use crate::geom;
641    use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Stream};
642
643    fn dict(pairs: &[(&str, Object)]) -> Dict {
644        Dict::from_pairs(
645            pairs
646                .iter()
647                .map(|(k, v)| (Name::from(*k), v.clone()))
648                .collect::<Vec<_>>(),
649        )
650    }
651
652    fn numbers(values: &[f32]) -> Object {
653        Object::Array(Array::of(values.iter().copied().map(Object::from)))
654    }
655
656    /// A widget of a field type the builder knows, since one it does not is
657    /// refused outright and would test nothing below.
658    fn widget(extra: &[(&str, Object)]) -> Dict {
659        let mut pairs = vec![
660            ("Subtype", Object::Name(Name::from("Widget"))),
661            ("FT", Object::Name(Name::from("Btn"))),
662            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
663        ];
664        pairs.extend_from_slice(extra);
665        dict(&pairs)
666    }
667
668    #[test]
669    fn a_widget_with_no_appearance_dictionary_gets_one() {
670        assert!(needs_appearance(&widget(&[]), &NoResolve));
671    }
672
673    #[test]
674    fn a_field_type_the_builder_does_not_dispatch_on_gets_nothing() {
675        // An intermediate node with `/Kids` and no `/FT` of its own, and a
676        // signature — the two shapes that fall off the end of the dispatch.
677        let no_type = dict(&[
678            ("Subtype", Object::Name(Name::from("Widget"))),
679            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
680        ]);
681        assert!(!needs_appearance(&no_type, &NoResolve));
682        assert!(generate(&no_type, &NoResolve).is_none());
683
684        let signature = dict(&[
685            ("Subtype", Object::Name(Name::from("Widget"))),
686            ("FT", Object::Name(Name::from("Sig"))),
687            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
688        ]);
689        assert!(!needs_appearance(&signature, &NoResolve));
690
691        // And the type is inherited, so a kid whose parent names it qualifies.
692        let parent = dict(&[("FT", Object::Name(Name::from("Tx")))]);
693        let kid = dict(&[
694            ("Subtype", Object::Name(Name::from("Widget"))),
695            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
696            ("Parent", Object::Dict(parent)),
697        ]);
698        assert!(needs_appearance(&kid, &NoResolve));
699    }
700
701    #[test]
702    fn an_appearance_that_resolves_leaves_the_widget_alone() {
703        let with_stream = widget(&[(
704            "AP",
705            Object::Dict(dict(&[(
706                "N",
707                Object::Stream(Box::new(Stream::new(
708                    Dict::new(),
709                    ByteSpan::from(b"x".to_vec()),
710                ))),
711            )])),
712        )]);
713        assert!(!needs_appearance(&with_stream, &NoResolve));
714    }
715
716    /// A catalog whose form sets `/NeedAppearances` to the given value.
717    fn form_catalog(need: Object) -> Dict {
718        dict(&[("AcroForm", Object::Dict(dict(&[("NeedAppearances", need)])))])
719    }
720
721    /// An `/AP` whose `/N` lists the given sub-states, each a stream.
722    fn states(names: &[&str]) -> Object {
723        Object::Dict(dict(&[(
724            "N",
725            Object::Dict(Dict::from_pairs(
726                names
727                    .iter()
728                    .map(|state| {
729                        (
730                            Name::from(*state),
731                            Object::Stream(Box::new(Stream::new(
732                                Dict::new(),
733                                ByteSpan::from(b"x".to_vec()),
734                            ))),
735                        )
736                    })
737                    .collect::<Vec<_>>(),
738            )),
739        )]))
740    }
741
742    #[test]
743    fn need_appearances_rebuilds_a_text_field_that_already_has_one() {
744        // Only a checkbox and a radio button write sub-states, so nothing
745        // filters a rebuilt text field: it is always seen.
746        let field = dict(&[
747            ("Subtype", Object::Name(Name::from("Widget"))),
748            ("FT", Object::Name(Name::from("Tx"))),
749            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
750            (
751                "AP",
752                Object::Dict(dict(&[(
753                    "N",
754                    Object::Stream(Box::new(Stream::new(
755                        Dict::new(),
756                        ByteSpan::from(b"x".to_vec()),
757                    ))),
758                )])),
759            ),
760        ]);
761        assert!(!needs_appearance(&field, &NoResolve));
762        for (need, expected) in [
763            (Object::Bool(true), true),
764            (Object::Bool(false), false),
765            // `GetBooleanFor` reads a boolean and nothing else.
766            (Object::Name(Name::from("true")), false),
767            (
768                Object::Str(pdfrum_object::PdfString::literal(b"true")),
769                false,
770            ),
771        ] {
772            assert_eq!(
773                needs_appearance_in(&field, Some(&form_catalog(need.clone())), &NoResolve),
774                expected,
775                "{need:?}"
776            );
777        }
778    }
779
780    #[test]
781    fn a_rebuild_a_button_would_never_read_back_does_not_happen() {
782        let catalog = form_catalog(Object::Bool(true));
783        let button = |extra: &[(&str, Object)]| {
784            let mut pairs = vec![
785                ("FT", Object::Name(Name::from("Btn"))),
786                ("AP", states(&["Yes", "Off"])),
787            ];
788            pairs.extend_from_slice(extra);
789            widget(&pairs)
790        };
791
792        // `/AS` names `Off`, which a rebuild always writes literally.
793        let off = button(&[("AS", Object::Name(Name::from("Off")))]);
794        assert!(needs_appearance_in(&off, Some(&catalog), &NoResolve));
795
796        // `/AS` names the on-state a rebuild would write.
797        let on = button(&[("AS", Object::Name(Name::from("Yes")))]);
798        assert!(needs_appearance_in(&on, Some(&catalog), &NoResolve));
799
800        // `/AS` names a third state, which a rebuild leaves untouched — so
801        // the file's own stream keeps drawing and nothing is regenerated.
802        let elsewhere = button(&[("AS", Object::Name(Name::from("Maybe")))]);
803        assert!(!needs_appearance_in(&elsewhere, Some(&catalog), &NoResolve));
804
805        // No `/AS` at all reads back the empty key, which is never written.
806        assert!(!needs_appearance_in(
807            &button(&[]),
808            Some(&catalog),
809            &NoResolve
810        ));
811    }
812
813    #[test]
814    fn an_opt_array_makes_the_on_state_a_control_index() {
815        // `bug_861842`'s shape: `/Opt` present, so the rebuilt on-state is the
816        // widget's control index — `0` — while `/AS` still reads `1`. The two
817        // never meet and the file's own stream survives.
818        let catalog = form_catalog(Object::Bool(true));
819        let with_opt = widget(&[
820            ("FT", Object::Name(Name::from("Btn"))),
821            ("AP", states(&["1", "Off"])),
822            ("AS", Object::Name(Name::from("1"))),
823            ("Opt", numbers(&[0.0, 0.0])),
824        ]);
825        assert_eq!(checked_ap_state(&with_opt, &NoResolve), b"0".to_vec());
826        assert!(!needs_appearance_in(&with_opt, Some(&catalog), &NoResolve));
827
828        // Without `/Opt` the on-state is the first non-`Off` key, `/AS`
829        // matches it, and the rebuild is seen.
830        let without = widget(&[
831            ("FT", Object::Name(Name::from("Btn"))),
832            ("AP", states(&["1", "Off"])),
833            ("AS", Object::Name(Name::from("1"))),
834        ]);
835        assert_eq!(checked_ap_state(&without, &NoResolve), b"1".to_vec());
836        assert!(needs_appearance_in(&without, Some(&catalog), &NoResolve));
837    }
838
839    #[test]
840    fn the_on_state_is_the_first_key_in_sorted_order_and_falls_back_to_yes() {
841        // The C++ walks a `std::map`, so the order is the keys' own, not the
842        // document's. Written `Zed` first, `Alpha` wins.
843        let sorted = widget(&[
844            ("FT", Object::Name(Name::from("Btn"))),
845            ("AP", states(&["Zed", "Off", "Alpha"])),
846        ]);
847        assert_eq!(checked_ap_state(&sorted, &NoResolve), b"Alpha".to_vec());
848
849        // An `/AP /N` with nothing but `Off` — or none at all — answers `Yes`.
850        let off_only = widget(&[
851            ("FT", Object::Name(Name::from("Btn"))),
852            ("AP", states(&["Off"])),
853        ]);
854        assert_eq!(checked_ap_state(&off_only, &NoResolve), b"Yes".to_vec());
855        assert_eq!(
856            checked_ap_state(
857                &widget(&[("FT", Object::Name(Name::from("Btn")))]),
858                &NoResolve
859            ),
860            b"Yes".to_vec()
861        );
862    }
863
864    #[test]
865    fn an_unusable_appearance_is_still_an_appearance() {
866        // `/AP /N` lists only the on-state while `/AS` reads `Off`, so nothing
867        // resolves — and the regeneration test does not care, because it is
868        // `!!GetDictFor("AP")` and no more. Neither a checkbox nor a radio is
869        // rebuilt. What draws them is `annot_render::invalid_outline`, which
870        // asks the *deeper* question on a different code path.
871        let unusable = [
872            (
873                "AP",
874                Object::Dict(dict(&[(
875                    "N",
876                    Object::Dict(dict(&[(
877                        "Yes",
878                        Object::Stream(Box::new(Stream::new(
879                            Dict::new(),
880                            ByteSpan::from(b"x".to_vec()),
881                        ))),
882                    )])),
883                )])),
884            ),
885            ("AS", Object::Name(Name::from("Off"))),
886            ("FT", Object::Name(Name::from("Btn"))),
887        ];
888        assert!(!needs_appearance(&widget(&unusable), &NoResolve));
889
890        let mut radio_pairs = unusable.to_vec();
891        // Bit 16 is the radio flag.
892        radio_pairs.push(("Ff", Object::Int(1 << 15)));
893        assert!(!needs_appearance(&widget(&radio_pairs), &NoResolve));
894    }
895
896    #[test]
897    fn a_buttons_glyph_belongs_to_its_on_state_alone() {
898        let base = [
899            ("FT", Object::Name(Name::from("Btn"))),
900            ("Ff", Object::Int(1 << 15)),
901        ];
902        let mut off = base.to_vec();
903        off.push(("AS", Object::Name(Name::from("Off"))));
904        let off = generate(&widget(&off), &NoResolve).expect("is a widget");
905        assert!(off.stream.is_empty());
906
907        let mut on = base.to_vec();
908        on.push(("AS", Object::Name(Name::from("Yes"))));
909        let on = generate(&widget(&on), &NoResolve).expect("is a widget");
910        let stream = String::from_utf8_lossy(&on.stream).into_owned();
911        // A circle, since a radio with no caption defaults to one.
912        assert!(stream.contains(" c\n"), "{stream}");
913        assert!(stream.ends_with("f\nQ\n"), "{stream}");
914    }
915
916    #[test]
917    fn a_non_widget_is_left_alone() {
918        let square = dict(&[("Subtype", Object::Name(Name::from("Square")))]);
919        assert!(!needs_appearance(&square, &NoResolve));
920        assert!(generate(&square, &NoResolve).is_none());
921    }
922
923    #[test]
924    fn a_plain_widget_produces_an_empty_stream() {
925        // No `/MK`, so neither the background nor the border has a colour and
926        // nothing is drawn — a valid appearance with no page objects in it.
927        let got = generate(&widget(&[]), &NoResolve).expect("is a widget");
928        assert!(got.stream.is_empty());
929        assert_eq!(got.bbox, geom::rect(0.0, 0.0, 100.0, 30.0));
930        // The rectangle is untouched: only the sticky-note and ink
931        // generators move one.
932        assert_eq!(got.rect_override, None);
933    }
934
935    #[test]
936    fn a_background_colour_fills_the_box() {
937        let coloured = widget(&[(
938            "MK",
939            Object::Dict(dict(&[("BG", numbers(&[1.0, 0.0, 0.0]))])),
940        )]);
941        let got = generate(&coloured, &NoResolve).expect("is a widget");
942        assert_eq!(
943            String::from_utf8_lossy(&got.stream),
944            "q\n1 0 0 rg\n0 0 100 30 re f\nQ\n"
945        );
946    }
947
948    #[test]
949    fn a_border_colour_draws_the_border() {
950        let bordered = widget(&[(
951            "MK",
952            Object::Dict(dict(&[("BC", numbers(&[0.0, 0.0, 0.0]))])),
953        )]);
954        let got = generate(&bordered, &NoResolve).expect("is a widget");
955        let stream = String::from_utf8_lossy(&got.stream).into_owned();
956        assert!(stream.starts_with("q\n0 0 0 rg\n"), "{stream}");
957        assert!(stream.ends_with("Q\n"), "{stream}");
958    }
959
960    #[test]
961    fn a_quarter_turn_swaps_the_boxs_extents() {
962        let rotated = |degrees: i64| {
963            rotated_rect(
964                &widget(&[("MK", Object::Dict(dict(&[("R", Object::Int(degrees))])))]),
965                &NoResolve,
966            )
967        };
968        assert_eq!(rotated(0), geom::rect(0.0, 0.0, 100.0, 30.0));
969        assert_eq!(rotated(180), geom::rect(0.0, 0.0, 100.0, 30.0));
970        assert_eq!(rotated(90), geom::rect(0.0, 0.0, 30.0, 100.0));
971        assert_eq!(rotated(270), geom::rect(0.0, 0.0, 30.0, 100.0));
972    }
973
974    #[test]
975    fn a_rotation_that_is_not_a_quarter_turn_leaves_the_box_upright() {
976        // No value of `/R` empties the box. A non-multiple of 90 names no
977        // quadrant — PDFium's `default:` arm and pdf.js's `angle % 90 === 0`
978        // gate both land on upright — and a negative angle names the
979        // quadrant it counts counterclockwise to.
980        let rotated = |degrees: i64| {
981            rotated_rect(
982                &widget(&[("MK", Object::Dict(dict(&[("R", Object::Int(degrees))])))]),
983                &NoResolve,
984            )
985        };
986        let upright = geom::rect(0.0, 0.0, 100.0, 30.0);
987        let turned = geom::rect(0.0, 0.0, 30.0, 100.0);
988
989        assert_eq!(rotated(45), upright);
990        assert_eq!(rotated(-45), upright);
991        assert_eq!(rotated(1), upright);
992
993        // `-90` is `270`: a swap, not an empty box. PDFium's `abs()` sends it
994        // to `90`, which swaps the same axes but is the wrong quadrant for
995        // the matrix — see the `[oracle-bug]` note on `WidgetRotation`.
996        assert_eq!(rotated(-90), turned);
997        assert_eq!(rotated(-270), turned);
998        assert_eq!(rotated(-180), upright);
999        assert_eq!(rotated(450), turned);
1000    }
1001
1002    /// A text widget with a `/DA` the font resource below satisfies.
1003    fn text_widget(value: &str) -> Dict {
1004        dict(&[
1005            ("Subtype", Object::Name(Name::from("Widget"))),
1006            ("FT", Object::Name(Name::from("Tx"))),
1007            ("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
1008            (
1009                "DA",
1010                Object::Str(pdfrum_object::PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
1011            ),
1012            ("V", Object::Str(pdfrum_object::PdfString::literal(value))),
1013        ])
1014    }
1015
1016    fn text_catalog() -> Dict {
1017        dict(&[(
1018            "AcroForm",
1019            Object::Dict(dict(&[(
1020                "DR",
1021                Object::Dict(dict(&[(
1022                    "Font",
1023                    Object::Dict(dict(&[(
1024                        "Helv",
1025                        Object::Dict(crate::ap::freetext::fallback_font()),
1026                    )])),
1027                )])),
1028            )])),
1029        )])
1030    }
1031
1032    #[test]
1033    fn the_live_entry_point_carries_its_override_down_to_the_body() {
1034        let cache = pdfrum_font::FontCache::new();
1035        let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1036        let width = |code: u32| crate::ap::TextFont::char_width(&face, code);
1037        let font = crate::ap::TextFont {
1038            metrics: crate::ap::TextFont::metrics_of(&face, &width),
1039            font: &face,
1040        };
1041        let (widget, catalog) = (text_widget("stored"), text_catalog());
1042        let stream = |ap: Option<super::GeneratedAp>| {
1043            String::from_utf8_lossy(&ap.expect("an appearance").stream).into_owned()
1044        };
1045
1046        let stored = stream(super::generate_with_text(
1047            &widget, &catalog, &font, None, &NoResolve,
1048        ));
1049        assert!(stored.contains("(stored) Tj\n"), "{stored}");
1050
1051        let live = crate::ap::field_body::LiveState {
1052            text: "typed",
1053            ..crate::ap::field_body::LiveState::default()
1054        };
1055        let edited = stream(super::generate_with_live(
1056            &widget,
1057            &catalog,
1058            &font,
1059            &NoResolve,
1060            None,
1061            Some(&live),
1062        ));
1063        assert!(edited.contains("(typed) Tj\n"), "{edited}");
1064        assert!(!edited.contains("stored"), "{edited}");
1065
1066        // And handed nothing, the live entry point is the stored one.
1067        assert_eq!(
1068            stored,
1069            stream(super::generate_with_live(
1070                &widget, &catalog, &font, &NoResolve, None, None,
1071            ))
1072        );
1073    }
1074
1075    /// Text a session is **typing** reaches the second face, which is the one
1076    /// thing [`super::generate_with_live`] cannot do: it forwards no
1077    /// substitute, so the same string comes out as the `/DA` font's low
1078    /// bytes.
1079    ///
1080    /// The expectations are the stored path's, from
1081    /// `field_body::tests::a_value_the_da_font_cannot_write_switches_to_a_second_face`:
1082    /// aleph is written `\340` and bet `\341` — code page 1255 — and not
1083    /// `\320`/`\321`, which are the low bytes of U+05D0 and U+05D1 and the
1084    /// mojibake this replaces.
1085    #[test]
1086    fn the_live_path_with_a_second_face_writes_hebrew_through_it() {
1087        let cache = pdfrum_font::FontCache::new();
1088        let options = pdfrum_font::SubstitutionOptions::default();
1089        let mut ctx = pdfrum_page::BuildContext::with_substitution(options);
1090        let catalog = text_catalog();
1091        let fonts = crate::ap::FormFonts::load(&catalog, &NoResolve, &mut ctx);
1092        let substitute = fonts
1093            .substitute(pdfrum_font::Charset::Hebrew)
1094            .expect("a Hebrew substitute");
1095
1096        let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1097        let charset = crate::ap::font_map::font_charset(&face);
1098        // The run is measured by the face that writes each character, or it
1099        // is set in two faces and laid out by one.
1100        let width = |code: u32| {
1101            if crate::ap::font_map::da_font_writes(&face, charset, code) {
1102                crate::ap::TextFont::char_width(&face, code)
1103            } else {
1104                crate::ap::font_map::substitute_width(substitute.font, code)
1105            }
1106        };
1107        let font = crate::ap::TextFont {
1108            metrics: crate::ap::TextFont::metrics_of(&face, &width),
1109            font: &face,
1110        };
1111
1112        // Typed, not stored: the widget's `/V` is Latin and stays unread.
1113        let widget = text_widget("stored");
1114        let state = crate::ap::field_body::LiveState {
1115            text: "ab\u{5D0}\u{5D1}",
1116            ..crate::ap::field_body::LiveState::default()
1117        };
1118        let live = |substitute| {
1119            super::generate_with_live_faces(
1120                &widget,
1121                &catalog,
1122                &font,
1123                &NoResolve,
1124                super::LiveInput {
1125                    live: Some(&state),
1126                    substitute,
1127                    ..super::LiveInput::default()
1128                },
1129            )
1130            .expect("an appearance")
1131        };
1132
1133        // Compared as bytes: a code-page byte is not valid UTF-8, so reading
1134        // the stream as text could not tell 0xE0 from 0xD0.
1135        let got = live(Some(substitute));
1136        let stream = got.stream.clone();
1137        let has = |needle: &[u8]| stream.windows(needle.len()).any(|w| w == needle);
1138
1139        assert!(has(b"/Helv 12 Tf\n"), "{stream:02X?}");
1140        let mut tf = b"/".to_vec();
1141        tf.extend_from_slice(substitute.alias.as_bytes());
1142        tf.extend_from_slice(b" 12 Tf\n");
1143        assert!(has(&tf), "the second face names itself: {stream:02X?}");
1144        assert!(has(b"\\340"), "aleph as 0xE0: {stream:02X?}");
1145        assert!(has(b"\\341"), "bet as 0xE1: {stream:02X?}");
1146        assert!(!has(b"\\320"), "no low-byte aleph: {stream:02X?}");
1147        assert!(!has(b"\\321"), "no low-byte bet: {stream:02X?}");
1148        assert!(
1149            got.resources
1150                .dict(crate::names::FONT, &NoResolve)
1151                .is_some_and(|fonts| fonts.contains_key(substitute.alias)),
1152            "the second face is in the appearance's own resources: {:?}",
1153            got.resources
1154        );
1155
1156        // Without it, the same string is the mojibake this closes — which is
1157        // exactly what `generate_with_live` still produces.
1158        let plain = live(None);
1159        assert_ne!(plain.stream, stream);
1160        let plain_has = |needle: &[u8]| plain.stream.windows(needle.len()).any(|w| w == needle);
1161        assert!(plain_has(b"\\320"), "{:02X?}", plain.stream);
1162        assert_eq!(
1163            plain.stream,
1164            super::generate_with_live(&widget, &catalog, &font, &NoResolve, None, Some(&state),)
1165                .expect("an appearance")
1166                .stream,
1167            "the old entry point is the new one with no substitute"
1168        );
1169    }
1170
1171    /// A session's appearance state overrides the widget's own `/AS`, in both
1172    /// directions.
1173    ///
1174    /// This is what lets a radio group's click be drawn. Checking one kid
1175    /// sets that control's `/AS` to its own on-state and every other
1176    /// control's to `Off` — one click, one state per kid, in as many
1177    /// different names. A session holds one record per **field**, so it can
1178    /// only say which control was chosen; this is how it says what each kid
1179    /// draws.
1180    ///
1181    /// The dictionary is untouched either way: the same widget answers both
1182    /// ways depending only on what is passed.
1183    #[test]
1184    fn a_sessions_appearance_state_overrides_the_dictionarys_own() {
1185        let catalog = Dict::new();
1186        let cache = pdfrum_font::FontCache::new();
1187        let font = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
1188        let width = |code: u32| crate::ap::TextFont::char_width(&font, code);
1189        let text = crate::ap::TextFont {
1190            metrics: crate::ap::TextFont::metrics_of(&font, &width),
1191            font: &font,
1192        };
1193        let radio = |state: &str| {
1194            widget(&[
1195                ("FT", Object::Name(Name::from("Btn"))),
1196                ("Ff", Object::Int(1 << 15)),
1197                ("AS", Object::Name(Name::from(state))),
1198            ])
1199        };
1200        let draw = |dict: &Dict, override_state: Option<&[u8]>| {
1201            super::generate_with_live_faces(
1202                dict,
1203                &catalog,
1204                &text,
1205                &NoResolve,
1206                super::LiveInput {
1207                    appearance_state: override_state,
1208                    ..super::LiveInput::default()
1209                },
1210            )
1211            .expect("is a widget")
1212            .stream
1213        };
1214        // A circle is what a radio with no caption draws, so its presence is
1215        // the on-state and its absence the off.
1216        let drawn = |stream: &[u8]| String::from_utf8_lossy(stream).contains(" c\n");
1217
1218        // On by its dictionary, forced off by the session — the sibling of a
1219        // control that was just clicked.
1220        let on = radio("Yes");
1221        assert!(drawn(&draw(&on, None)), "its own /AS says Yes");
1222        assert!(!drawn(&draw(&on, Some(b"Off"))), "the session says Off");
1223
1224        // Off by its dictionary, forced on — the control that was clicked,
1225        // whose on-state is its own name and not the sibling's.
1226        let off = radio("Off");
1227        assert!(!drawn(&draw(&off, None)), "its own /AS says Off");
1228        assert!(drawn(&draw(&off, Some(b"Yes"))), "the session says Yes");
1229        assert!(
1230            drawn(&draw(&off, Some(b"2"))),
1231            "any non-Off state is on, whatever it is named"
1232        );
1233
1234        // And `None` is byte-for-byte the unoverridden stream, which is what
1235        // keeps every existing caller where it was.
1236        assert_eq!(draw(&on, None), draw(&on, None));
1237        assert_eq!(
1238            draw(&on, Some(b"Yes")),
1239            draw(&on, None),
1240            "an override naming the state already shown changes nothing"
1241        );
1242    }
1243
1244    /// `is_checked` is `is_checked_with` handed no override, and the public
1245    /// signature `pdfrum/src/form.rs` and `form/field.rs` call is unchanged.
1246    #[test]
1247    fn is_checked_is_the_unoverridden_case_of_is_checked_with() {
1248        for state in ["Off", "Yes", "2", ""] {
1249            let dict = widget(&[("AS", Object::Name(Name::from(state)))]);
1250            assert_eq!(
1251                super::is_checked(&dict, &NoResolve),
1252                super::is_checked_with(&dict, &NoResolve, None),
1253                "{state:?}"
1254            );
1255        }
1256        // A widget with no `/AS` at all is off, and an override still speaks.
1257        let bare = widget(&[]);
1258        assert!(!super::is_checked(&bare, &NoResolve));
1259        assert!(!super::is_checked_with(&bare, &NoResolve, Some(b"Off")));
1260        assert!(super::is_checked_with(&bare, &NoResolve, Some(b"Yes")));
1261    }
1262}