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}