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