Skip to main content

pdfrum_doc/
annot_render.rs

1//! Painting a page's annotation appearances onto the page.
2//!
3//! An annotation's `/AP` form is part of the page image rather than an
4//! overlay a viewer adds. But it does not all arrive by one route, and the
5//! split is the thing to know:
6//!
7//! - **Pass A** is the page render, and it draws the *non*-widgets: the walk
8//!   over the annotation list skips every `/Widget`.
9//! - **Pass B** is the form-fill draw that follows every bitmap render,
10//!   unconditionally. That is where widgets are drawn, one at a time through
11//!   their own one-layer render context.
12//!
13//! Both end in the same placement arithmetic and both draw the normal
14//! appearance, so one traversal reproduces them — but **their visibility
15//! tests differ**, and merging them would be wrong:
16//!
17//! | flag | Pass A (non-widgets) | Pass B (widgets) |
18//! |---|---|---|
19//! | `Invisible` (bit 1) | not tested | **suppresses** |
20//! | `Hidden` (bit 2) | suppresses | suppresses |
21//! | `Print` (bit 3) | required when printing | not tested |
22//! | `NoView` (bit 6) | suppresses on screen | suppresses |
23//!
24//! `is_visible` therefore keys on the subtype: a widget goes through Pass
25//! B's rules and everything else through Pass A's, which is the only place
26//! the `Invisible` bit is read.
27//!
28//! Two more decisions change pixels and neither is obvious from the spec:
29//!
30//! - **The appearance is placed by fitting, not by translating.** The form's
31//!   `/BBox` — mapped through the form's own `/Matrix` and re-bounded — is
32//!   fitted into the annotation's `/Rect`, so a form whose `BBox` is a
33//!   different size from the rect is *scaled* to it. A degenerate axis takes
34//!   scale **1**, not zero, and the skew terms are forced to zero whatever
35//!   `/Matrix` said.
36//! - **Order is `/Annots` order.** The only sort is a stable one that lifts
37//!   pop-ups above everything else, so among non-pop-ups nothing moves. Later
38//!   annotations paint over earlier ones with no z-ordering of their own;
39//!   `bug_1304714.in` stacks three widgets to pin exactly that.
40//!
41//! A pop-up is in the list and painted only while it is **open**, and the one
42//! thing that opens it is the pointer entering the *parent* annotation's
43//! rectangle. So a plain render draws no note cards at all, and a render
44//! driven by a script that moves the mouse over an annotated passage draws
45//! exactly one.
46
47use pdfrum_common::{Diagnostics, Limits};
48use pdfrum_object::{ByteSpan, Dict, Resolve, Stream};
49use pdfrum_page::{BuildContext, Page, Resources, build_form_object};
50
51use crate::annot::appearance::{ApMode, annot_ap, annot_matrix};
52use crate::annot::{AnnotList, Annotation, Subtype};
53use crate::ap;
54use crate::names;
55
56/// The five stages of this pass, timed into `pdfrum-page`'s accumulator under
57/// this crate's `profiling`.
58///
59/// A module of its own so the pass below reads as the pass rather than as the
60/// instrument, and so the feature-off build names no `renderprofile` item at
61/// all — the module is `pub` in `pdfrum-page` only with the feature, and a
62/// crate cannot `#[cfg]` on another crate's flag.
63#[cfg(feature = "profiling")]
64mod profile {
65    pub use pdfrum_page::renderprofile::{Stage, stage};
66}
67
68/// The feature-off twin: the stage names, and a `stage` that is its body.
69#[cfg(not(feature = "profiling"))]
70mod profile {
71    /// The stages this pass names. Only the variants it uses, because with the
72    /// feature off nothing reads them and the set exists to keep one spelling
73    /// at the call sites.
74    #[derive(Debug, Clone, Copy)]
75    pub enum Stage {
76        AnnotList,
77        FormFonts,
78        GenerateAppearances,
79        OpenAction,
80        AnnotLoop,
81    }
82
83    /// The body, unclocked.
84    #[inline]
85    pub fn stage<T>(_stage: Stage, body: impl FnOnce() -> T) -> T {
86        body()
87    }
88}
89
90use profile::{Stage, stage};
91
92/// Appends every visible annotation's appearance to a built page.
93///
94/// The page's own resources are the fallback for an appearance form that
95/// declares none, so a form with no `/Resources` of its own resolves names
96/// against the page.
97pub fn overlay<R: Resolve>(
98    page: &mut Page,
99    page_dict: &Dict,
100    catalog: &Dict,
101    r: &R,
102    ctx: &mut BuildContext,
103    limits: &Limits,
104    diags: &mut Diagnostics,
105) {
106    overlay_with(page, page_dict, catalog, r, ctx, limits, diags, None);
107}
108
109/// The same pass, with an overlay the caller has already filled in.
110///
111/// `supplied` is laid over what this function generates, per
112/// [`ap::AnnotOverlay::merge_over`]: wherever it has something to say about
113/// an annotation the caller's entry wins, and wherever it is untouched the
114/// generated one stands. Passing [`None`] is exactly [`overlay`], down to the
115/// operators emitted.
116///
117/// This is how a live edit reaches the page. A form session holds appearances
118/// for the fields it has touched — a focused field with a caret, a committed
119/// value, or a field whose appearance it has cleared — and hands them here
120/// rather than having them regenerated from the document, which would not
121/// know about the edit.
122///
123/// # Keying
124///
125/// Both overlays are keyed by the **raw** `/Annots` index — the index into
126/// the array as the file writes it, which is what `AnnotList::source_indices`
127/// recovers after the list has dropped and reordered entries. A caller
128/// building `supplied` must use that index and not the position an annotation
129/// ended up at in the loaded list.
130///
131/// # Focus
132///
133/// `supplied` may also name the annotation that holds the keyboard focus,
134/// through [`ap::AnnotOverlay::set_focus`]. That annotation is drawn
135/// *without* the widget tint and with `focus_rect`'s dashed outline over
136/// whatever focus box it declares — see `focus_rect` for why the two travel
137/// together and why most field types declare none.
138#[expect(
139    clippy::too_many_arguments,
140    reason = "the pass reads six independent inputs plus its two sinks; \
141              bundling them into a context struct is the god-object shape \
142              STYLE §1 forbids"
143)]
144pub fn overlay_with<R: Resolve>(
145    page: &mut Page,
146    page_dict: &Dict,
147    catalog: &Dict,
148    r: &R,
149    ctx: &mut BuildContext,
150    limits: &Limits,
151    diags: &mut Diagnostics,
152    supplied: Option<&ap::AnnotOverlay>,
153) {
154    #[expect(
155        clippy::cast_possible_truncation,
156        reason = "a page width beyond f32 has already lost meaning, and the \
157                  value only places a synthesized pop-up, which never paints"
158    )]
159    let page_width = page.crop_box.width() as f32;
160    let list = stage(Stage::AnnotList, || {
161        AnnotList::load(page_dict, page_width, r)
162    });
163    let resources = Resources::for_page(page.resources.clone());
164    // `CPDF_Annot`'s constructor runs `GenerateAPIfNeeded`, so an annotation
165    // that arrives without a usable `/AP /N` is given one *before* anything
166    // asks it to draw.
167    //
168    // The *text-bearing* variant, because `GenerateAPIfNeeded` reaches
169    // `GenerateFreeTextAP` on the same constructor as every other generator
170    // (`cpdf_generateap.cpp:1603`) — there is no second pass, and no route by
171    // which a free-text annotation is described but not drawn. Taking the
172    // font-less walk here painted a synthesized free-text appearance as
173    // nothing at all while `--annot` reported it in full, which is exactly
174    // the shape that let it survive: the tier that compares text matched.
175    //
176    // The fonts the form's default resources declare, loaded through the same
177    // substitution the rest of the page uses. See `ap::FormFonts` for why
178    // stock Helvetica is the wrong metric source.
179    // Only a widget or a free-text annotation lays out text, and only an open
180    // pop-up draws a card, so a page with neither never builds the faces: on
181    // a fresh session the build is milliseconds, and a page with no form was
182    // paying it on every cold render.
183    let needs_fonts = !list.popups.is_empty()
184        || list
185            .annots
186            .iter()
187            .any(|annot| matches!(annot.subtype, Subtype::Widget | Subtype::FreeText));
188    let fonts =
189        needs_fonts.then(|| stage(Stage::FormFonts, || ap::FormFonts::load(catalog, r, ctx)));
190    let mut generated = stage(Stage::GenerateAppearances, || {
191        ap::generate_appearances_with_text(page_dict, catalog, fonts.as_deref(), r, diags)
192    });
193    if let Some(supplied) = supplied {
194        generated.merge_over(supplied);
195    }
196    // `FORM_DoDocumentOpenAction` runs before the first page is rendered
197    // (`pdfium_test.cc:1779`), so a `/Hide` in the catalog's open action has
198    // already rewritten the flag words the visibility test below reads.
199    let hidden = stage(Stage::OpenAction, || {
200        crate::nav::hidden_by_open_action(catalog, r, limits, diags)
201    });
202    let focus = generated.focus();
203
204    // One span over the whole loop rather than one per annotation: a page with
205    // three hundred widgets would otherwise pay three hundred `Instant` pairs
206    // for a bucket that is read as a total anyway, and the per-annotation
207    // question is `--sample`'s.
208    stage(Stage::AnnotLoop, || {
209        for (slot, annot) in list.annots.iter().enumerate() {
210            let flags = hidden.flags(&annot.dict, r);
211            if !is_visible(annot.subtype, flags) {
212                continue;
213            }
214            let index = list.source_indices.get(slot).copied().unwrap_or(slot);
215            // A suppressed appearance draws nothing at all — not the file's
216            // `/AP`, not a generated one, not the invalid-state outline below.
217            // Only the widget highlight survives, because it is painted after the
218            // appearance and independently of it.
219            if matches!(generated.appearance(index), ap::Appearance::Suppressed) {
220                push_chrome(page, annot, index, focus, r, limits, diags);
221                continue;
222            }
223            // A checkbox or radio button whose *state's* appearance stream is
224            // missing is outlined instead of drawn, and the branch replaces the
225            // appearance rather than following it — see `invalid_outline`.
226            if generated.get(index).is_none()
227                && let Some(object) = invalid_outline(annot, r)
228            {
229                page.objects.push(object);
230                push_chrome(page, annot, index, focus, r, limits, diags);
231                continue;
232            }
233            // A generated appearance may also move the rectangle it draws into:
234            // a text markup annotation with a generated AP is placed at its
235            // quadrilaterals' bounding box rather than at its `/Rect`
236            // (`CPDF_Annot::RectForDrawing`).
237            let (form, placed) = if let Some(made) = generated.get(index) {
238                let mut placed = annot.clone();
239                placed.rect = generated.rect(index, annot.rect_for_drawing(true));
240                (
241                    Stream::new(ap::stream_dict(made), ByteSpan::from(made.stream.clone())),
242                    placed,
243                )
244            } else {
245                // `kNormal` in both passes, and `bFallbackToNormal` is a no-op
246                // when the mode already is normal.
247                let Some(form) = annot_ap(&annot.dict, ApMode::Normal, false, r) else {
248                    // No appearance to draw — but a widget's highlight is painted
249                    // *after* the appearance and independently of it
250                    // (`CFFL_InteractiveFormFiller::OnDraw`,
251                    // `cffl_interactiveformfiller.cpp:85-94`), so a field with no
252                    // `/AP` at all still tints. `password.in` is nothing but two
253                    // such fields.
254                    push_chrome(page, annot, index, focus, r, limits, diags);
255                    continue;
256                };
257                (form, annot.clone())
258            };
259            // Placed in *page* space: `render_page` composes its own page matrix
260            // on top, which is the `mtUser2Device` the C++ concatenates last. So
261            // an identity here is what keeps a rotated or cropped page placing
262            // annotations exactly as it places content.
263            let matrix = annot_matrix(&placed, &form.dict, 0, kurbo::Affine::IDENTITY, r);
264            if !matrix.as_coeffs().iter().all(|c| c.is_finite()) {
265                continue;
266            }
267            // A live edit's appearance is marked so the renderer can draw its text
268            // the way the oracle does — with ClearType, which no other text on the
269            // page gets. The flag rides the object because by the time anything
270            // rasterizes, this form is one entry in the page's object list.
271            let live_edit = supplied.is_some_and(|overlay| overlay.is_live_edit(index));
272            if let Some(object) = pdfrum_page::build_form_object_with(
273                &form, matrix, &resources, r, ctx, limits, diags, live_edit,
274            ) {
275                page.objects.push(object);
276            }
277            push_chrome(page, annot, index, focus, r, limits, diags);
278        }
279        if let Some(fonts) = &fonts {
280            push_open_popup(
281                page,
282                &list,
283                generated.hover(),
284                fonts,
285                &resources,
286                r,
287                ctx,
288                limits,
289                diags,
290            );
291        }
292    });
293}
294
295/// Draws the note card belonging to the annotation the pointer is inside.
296///
297/// A synthesized pop-up is appended to the list *after* every annotation the
298/// file declares, and the display walk is that list in order, so the card
299/// paints **last** — over the page's own text and over its parent, which is
300/// what makes a note legible where it overlaps the passage it annotates.
301///
302/// At most one card is ever open, because the pointer is in one place. The
303/// hover index is a raw `/Annots` index naming the *parent*: the card itself
304/// has no index to be named by, since it is not in the file.
305#[expect(
306    clippy::too_many_arguments,
307    reason = "the same six inputs plus two sinks the pass itself carries; \
308              see `overlay_with`"
309)]
310fn push_open_popup<R: Resolve>(
311    page: &mut Page,
312    list: &AnnotList,
313    hover: Option<usize>,
314    fonts: &ap::FormFonts,
315    resources: &Resources,
316    r: &R,
317    ctx: &mut BuildContext,
318    limits: &Limits,
319    diags: &mut Diagnostics,
320) {
321    let Some(hover) = hover else {
322        return;
323    };
324    // The hover names a raw `/Annots` index; the pop-up list is keyed by
325    // position in the *loaded* list, which has dropped the file's own pop-ups.
326    let Some(slot) = list.source_indices.iter().position(|&index| index == hover) else {
327        return;
328    };
329    let Some((_, popup)) = list.popups.iter().find(|(parent, _)| *parent == slot) else {
330        return;
331    };
332    // No `/DA` names a face, so the card takes the fallback the form fonts
333    // always carry — the loaded Helvetica, whose ascent and descent are the
334    // ones the wrap must be measured with.
335    let Some(font) = fonts.face(b"") else {
336        return;
337    };
338    let width = |code: u32| ap::TextFont::char_width(font, code);
339    let metrics = ap::TextFont::metrics_of(font, &width);
340    let encode = |code: u32| {
341        ap::TextFont {
342            font,
343            metrics: ap::TextFont::metrics_of(font, &width),
344        }
345        .encode(code)
346    };
347    let Some(made) = ap::popup::popup(&popup.dict, &metrics, &encode, r) else {
348        return;
349    };
350    diags.record(
351        pdfrum_common::Severity::Recovered,
352        pdfrum_common::DiagKind::AppearanceGenerated,
353        None,
354    );
355    let generated = ap::GeneratedAp {
356        stream: made.stream,
357        bbox: popup.rect,
358        matrix: kurbo::Affine::IDENTITY,
359        resources: ap::resources_dict(
360            ap::ext_gstate_dict(&popup.dict, false, r),
361            made.font_resources,
362        ),
363        rect_override: None,
364        as_override: None,
365    };
366    let form = Stream::new(
367        ap::stream_dict(&generated),
368        ByteSpan::from(generated.stream.clone()),
369    );
370    let matrix = annot_matrix(popup, &form.dict, 0, kurbo::Affine::IDENTITY, r);
371    if !matrix.as_coeffs().iter().all(|c| c.is_finite()) {
372        return;
373    }
374    if let Some(object) = build_form_object(&form, matrix, resources, r, ctx, limits, diags) {
375        page.objects.push(object);
376    }
377}
378
379/// Appends whichever of the two pieces of widget chrome this annotation
380/// earns, after its appearance is down.
381///
382/// The two are **exclusive**, and that exclusivity is the whole of this
383/// function. Whether a widget has a live form-field control behind it decides
384/// which it gets:
385///
386/// - With one, the control's own appearance is drawn and the pass then ends —
387///   whether the widget is not the focused one, or the focus box came back
388///   empty, or the focus rectangle was stroked. **A widget being edited is
389///   never tinted.**
390/// - Without one, the file's appearance is drawn and the tint goes over it.
391///
392/// A live control exists only for a widget an event has reached, and a
393/// session focuses one field at a time, so the focused annotation is the one
394/// that takes the first branch. Every other annotation on the page takes the
395/// second and is unaffected by focus existing at all.
396fn push_chrome<R: Resolve>(
397    page: &mut Page,
398    annot: &Annotation,
399    index: usize,
400    focus: Option<ap::Focus>,
401    r: &R,
402    limits: &Limits,
403    diags: &mut Diagnostics,
404) {
405    if let Some(focus) = focus.filter(|focus| focus.annot == index) {
406        if let Some(object) = focus_rect(annot, focus.box_) {
407            page.objects.push(object);
408        }
409        return;
410    }
411    if let Some(object) = highlight(annot, r, limits, diags) {
412        page.objects.push(object);
413    }
414}
415
416/// The dashed black rectangle stroked around a focused widget's focus box.
417///
418/// The path is the box's four corners walked explicitly — top-left,
419/// bottom-left, bottom-right, top-right, back to top-left — stroked in opaque
420/// black with a one-on-one-off dash: width **1.0**, phase **0**, butt caps,
421/// miter joins, all of them the stroke defaults. Nothing is filled, which is
422/// [`pdfrum_page::FillRule::None`] here, the same spelling
423/// [`invalid_outline`] uses for the same reason.
424// The oracle's spelling of that: CFX_DrawUtils::DrawFocusRect
425// (core/fxge/cfx_drawutils.cpp:16-39) strokes with a CFX_GraphStateData
426// carrying nothing but set_dash_array({1.0f}); the rest is that struct's own
427// defaults (cfx_graphstatedata.h:52-55). Its fill argb is 0, so the
428// EvenOddOptions() beside it names a rule for a fill that never happens.
429///
430/// # Which widgets have a focus box at all
431///
432/// Most have none, and the empty answer is not an edge case — it is the
433/// common one. The live control decides, and the controls disagree:
434///
435/// | control | focus rectangle | box |
436/// |---|---|---|
437/// | text field | empty | none |
438/// | combo box | empty | none |
439/// | list box, multi-select | the caret item's rectangle, clipped to the client area | [`ap::FocusBox::Rect`] |
440/// | list box, single-select, and the check box and radio button | the widget rectangle inflated by 1 | [`ap::FocusBox::Inflated`] |
441/// | push button | the widget rectangle *deflated* by the border width | [`ap::FocusBox::Rect`] |
442///
443/// So a focused text field draws no outline whatever, which is what the four
444/// `form_textfield_focused_*` goldens carry: a caret and glyphs over plain
445/// white, with neither a tint nor a dashed box. The one corpus file that does
446/// stroke one is `scrollable_widgets1`, a multi-select list box, and its
447/// dashes trace a **14-row band inside** the widget — the caret item — rather
448/// than the widget's own edges.
449///
450/// A caller that has the list control's scroll and caret state names the
451/// rectangle it computed; a caller that does not says [`ap::FocusBox::None`]
452/// and still gets the tint suppressed, which is the half of the behaviour
453/// that does not need the control.
454///
455/// # The page-space clip that is not applied here
456///
457/// The rectangle is also dropped outright when the page's `/MediaBox` does
458/// not *contain* it — a containment test rather than an intersection, so a
459/// box hanging one unit off the page edge is discarded whole rather than
460/// clipped. That test belongs to whoever computes the rectangle, because it
461/// needs the page box; this function strokes what it is given.
462#[must_use]
463fn focus_rect(annot: &Annotation, box_: ap::FocusBox) -> Option<pdfrum_page::PageObject> {
464    let rect = match box_ {
465        ap::FocusBox::None => return None,
466        ap::FocusBox::Rect(rect) => rect,
467        // `CFX_FloatRect::Inflate(1, 1)` then `Normalize()`, which is
468        // `CPWL_Wnd::GetFocusRect` over a window rectangle that is the
469        // annotation's own — `CFFL_FormField`'s window is created at
470        // `GetPDFAnnotRect` and mapped back by `PWLtoFFL`.
471        ap::FocusBox::Inflated => normalized(annot.rect).inflate(1.0, 1.0),
472    };
473    let rect = normalized(rect);
474    // `CFX_FloatRect::IsEmpty` is `right <= left || top <= bottom`, so a
475    // degenerate box strokes nothing and `OnDraw` returns at `:76-78`.
476    if rect.width() <= 0.0 || rect.height() <= 0.0 {
477        return None;
478    }
479    let mut stroke = pdfrum_page::ColorValue::default();
480    stroke.set_space(std::sync::Arc::new(pdfrum_page::ColorSpace::DeviceRgb));
481    let _ = stroke.set_components(&[0.0, 0.0, 0.0]);
482    let state = pdfrum_page::GraphicsState {
483        stroke,
484        stroke_params: pdfrum_page::StrokeParams {
485            // `CFX_GraphStateData`'s own defaults, none of which
486            // `DrawFocusRect` overrides.
487            width: 1.0,
488            dash: [1.0].into_iter().collect(),
489            dash_phase: 0.0,
490            ..pdfrum_page::StrokeParams::default()
491        },
492        ..pdfrum_page::GraphicsState::default()
493    };
494    Some(pdfrum_page::PageObject::Path(Box::new(
495        pdfrum_page::Content {
496            object: pdfrum_page::PathObject {
497                path: kurbo::Shape::to_path(&rect, 0.1),
498                matrix: kurbo::Affine::IDENTITY,
499                // Fill argb 0 beside the black stroke: nothing is filled.
500                fill_rule: pdfrum_page::FillRule::None,
501                stroke: true,
502            },
503            state,
504            marks: pdfrum_page::ContentMarks::default(),
505            content_stream: None,
506            // Annotation chrome is drawn into the page graph but is not page
507            // content: it belongs to no `/Contents` element and must never
508            // make an ordinary render count as a mutation.
509            dirty: false,
510            active: true,
511        },
512    )))
513}
514
515/// A rectangle with its corners sorted.
516fn normalized(rect: kurbo::Rect) -> kurbo::Rect {
517    kurbo::Rect::new(
518        rect.x0.min(rect.x1),
519        rect.y0.min(rect.y1),
520        rect.x0.max(rect.x1),
521        rect.y0.max(rect.y1),
522    )
523}
524
525/// The hairline grey box drawn over a checkbox or radio button whose state
526/// has no appearance stream.
527///
528/// # Two validity tests, not one
529///
530/// This is the second of two `/AP` tests that read almost the same and answer
531/// differently, and keeping them apart is the whole of this function:
532///
533/// - **Shallow** — is there an `/AP` dictionary at all? Gates
534///   *regeneration*: a widget with any `/AP` dictionary is never given a new
535///   appearance, however unusable that dictionary is.
536///   [`ap::widget::needs_appearance`] is this one.
537/// - **Deep** — gates *this outline*. For a checkbox or radio button it
538///   requires `/AP /N /<AS>` to resolve to a **stream**.
539///
540/// A radio button whose `/AP /N` lists only its on-state while `/AS` reads
541/// `Off` passes the first and fails the second: it keeps having no appearance
542/// *and* gets outlined. Porting either test alone is a measured loss, which is
543/// why they landed together.
544///
545/// Three details are behavior rather than incident:
546///
547/// - **Only checkboxes and radio buttons.** Every other field type — and every
548///   non-widget — falls to the ordinary appearance path. A push button with an
549///   unusable `/AP` draws nothing at all.
550/// - **The state is `/AS` alone.** The `/V`-and-`/Parent` fallback that
551///   [`annot_ap`] performs is not consulted here, so a widget with no `/AS`
552///   looks up the empty state name and fails this test even where `annot_ap`
553///   would have found `Off`.
554/// - **The rectangle is `/Rect`, normalized, with no border inset**, stroked
555///   at line width zero — a hairline, which this engine draws as the thinnest
556///   line the device has.
557fn invalid_outline<R: Resolve>(annot: &Annotation, r: &R) -> Option<pdfrum_page::PageObject> {
558    /// `0xAA` on every channel: the one grey this outline is stroked with.
559    const OUTLINE_GREY: f32 = 0xAA_u8 as f32 / 255.0;
560
561    if annot.subtype != Subtype::Widget {
562        return None;
563    }
564    let (limits, mut diags) = (Limits::default(), Diagnostics::default());
565    let flags = crate::form::FieldFlags::from_bits(
566        crate::form::attr::field_attr(&annot.dict, names::FF, r, &limits, &mut diags)
567            .and_then(|value| value.as_int())
568            .unwrap_or(0),
569    );
570    let field_type = crate::form::attr::field_attr(&annot.dict, names::FT, r, &limits, &mut diags)
571        .map(|value| value.to_byte_string())
572        .unwrap_or_default();
573    if !matches!(
574        crate::form::FieldKind::classify(&field_type, flags),
575        Some(crate::form::FieldKind::Check | crate::form::FieldKind::Radio)
576    ) {
577        return None;
578    }
579    if state_appearance_resolves(&annot.dict, r) {
580        return None;
581    }
582
583    let rect = normalized(annot.rect);
584    let mut stroke = pdfrum_page::ColorValue::default();
585    stroke.set_space(std::sync::Arc::new(pdfrum_page::ColorSpace::DeviceRgb));
586    let _ = stroke.set_components(&[OUTLINE_GREY, OUTLINE_GREY, OUTLINE_GREY]);
587    let state = pdfrum_page::GraphicsState {
588        stroke,
589        stroke_params: pdfrum_page::StrokeParams {
590            // `gsd.set_line_width(0.0f)` — a hairline, not a zero-area stroke.
591            width: 0.0,
592            ..pdfrum_page::StrokeParams::default()
593        },
594        ..pdfrum_page::GraphicsState::default()
595    };
596    Some(pdfrum_page::PageObject::Path(Box::new(
597        pdfrum_page::Content {
598            object: pdfrum_page::PathObject {
599                path: kurbo::Shape::to_path(&rect, 0.1),
600                matrix: kurbo::Affine::IDENTITY,
601                // `DrawPath` is handed fill argb **0** — fully transparent —
602                // beside the grey stroke, so `EvenOddOptions()` names a rule
603                // for a fill that never happens. `FillRule::None` is how this
604                // engine spells that, and spelling it `EvenOdd` paints the
605                // box solid instead of outlining it.
606                fill_rule: pdfrum_page::FillRule::None,
607                stroke: true,
608            },
609            state,
610            marks: pdfrum_page::ContentMarks::default(),
611            content_stream: None,
612            // Annotation chrome is drawn into the page graph but is not page
613            // content: it belongs to no `/Contents` element and must never
614            // make an ordinary render count as a mutation.
615            dirty: false,
616            active: true,
617        },
618    )))
619}
620
621/// Whether `/AP /N /<AS>` resolves to a stream, with `/AS` read alone.
622fn state_appearance_resolves<R: Resolve>(dict: &Dict, r: &R) -> bool {
623    let Some(sub) = dict
624        .dict(names::AP, r)
625        .and_then(|ap| ap.get(names::N, r).map(|value| value.get().clone()))
626    else {
627        return false;
628    };
629    // A `/N` that is a stream outright is valid whatever `/AS` says; the
630    // switch on field type only reaches the state lookup for a dictionary.
631    let Some(states) = sub.as_dict() else {
632        return matches!(sub, pdfrum_object::Object::Stream(_));
633    };
634    let state = dict.byte_string(names::AS, r).unwrap_or_default();
635    states.stream(&pdfrum_object::Name::new(state), r).is_some()
636}
637
638/// The form-field highlight painted over every fillable widget.
639///
640/// This is a **host** decision, not a document one, and it is why so many
641/// otherwise-correct form pages differ by a flat tint over every field: once
642/// the appearance is down, the widget's `/Rect` is filled with the host's
643/// highlight colour at the host's highlight alpha.
644///
645/// Three things about it are easy to get wrong:
646///
647/// - **The colour word is BGR.** `0xFFE4DD` is blue `0xFF`, green `0xE4`, red
648///   `0xDD` — a pale blue, not the pink the hex reads as. Over white at
649///   100/255 that is `(241, 244, 255)`, which is exactly what the goldens
650///   carry.
651/// - **It is a hard-edged integer rect.** Every edge is **truncated** to a
652///   whole device pixel, so the tint covers `[floor(left), floor(right))`
653///   with no antialiasing on any side. A `/Rect` of `[100 100 200 130]` on a
654///   200-tall page tints device rows 70..99 and columns 100..199 — 30 x 100
655///   pixels exactly.
656/// - **It is gated on the *field's* flags, not the annotation's.** The
657///   read-only test reads bit **0** of the inherited `/Ff`, where the
658///   annotation's own `ReadOnly` is bit 6 of `/F`. A push button never tints,
659///   and neither does a widget with no `/FT` to classify. A **signature**
660///   field is excluded one level higher still: it never reaches the form
661///   filler at all.
662// The host's two calls are FPDF_SetFormFieldHighlightColor(form,
663// FPDF_FORMFIELD_UNKNOWN, 0xFFE4DD) and FPDF_SetFormFieldHighlightAlpha(form,
664// 100) (pdfium_test.cc:1776-1777); CPDFSDK_Widget::DrawShadow
665// (cpdfsdk_widget.cpp:982-1006) is what paints them. The truncation is
666// ToFxRect (fx_coordinates.cpp:324-327); the read-only bit is
667// form_flags::kReadOnly (constants/form_flags.h:13); the /FT-less widget is
668// refused by IsNeedHighLight(kUnknown) and the push button by
669// IsFillingAllowed.
670fn highlight<R: Resolve>(
671    annot: &Annotation,
672    r: &R,
673    limits: &Limits,
674    diags: &mut Diagnostics,
675) -> Option<pdfrum_page::PageObject> {
676    if annot.subtype != Subtype::Widget {
677        return None;
678    }
679    let field_type = crate::form::attr::field_attr(&annot.dict, names::FT, r, limits, diags)
680        .map(|value| value.to_byte_string())
681        .unwrap_or_default();
682    let flags = crate::form::FieldFlags::from_bits(
683        crate::form::attr::field_attr(&annot.dict, names::FF, r, limits, diags)
684            .and_then(|value| value.as_int())
685            .unwrap_or(0),
686    );
687    // `IsNeedHighLight(kUnknown)` is false, so a widget whose `/FT` names no
688    // field type is not tinted at all.
689    let kind = crate::form::FieldKind::classify(&field_type, flags)?;
690    // A push button is refused by `IsFillingAllowed`; a signature widget
691    // never reaches the form filler at all, because `CPDFSDK_Widget::OnDraw`
692    // short-circuits it to `DrawAppearance` and returns
693    // (`cpdfsdk_widget.cpp:719-724`). Six corpus signature files say so, four
694    // of them byte-exact.
695    if flags.is_read_only()
696        || matches!(
697            kind,
698            crate::form::FieldKind::Button | crate::form::FieldKind::Signature
699        )
700    {
701        return None;
702    }
703    // A signature widget never reaches the form filler at all:
704    // `CPDFSDK_Widget::OnDraw` draws its appearance and *returns*
705    // (`cpdfsdk_widget.cpp:719-724`), so the highlight that every other
706    // fillable field gets is never painted over it. Six corpus signature
707    // files say so, four of them byte-exact.
708    // `CFX_FloatRect::Normalize` before `ToFxRect`: `GetRect` hands back a
709    // normalized rectangle, and a `/Rect` written corner-first would
710    // otherwise truncate to an empty one.
711    let rect = normalized(annot.rect);
712    if rect.width() <= 0.0 || rect.height() <= 0.0 {
713        return None;
714    }
715    Some(pdfrum_page::PageObject::Path(Box::new(
716        pdfrum_page::Content {
717            object: pdfrum_page::PathObject {
718                path: kurbo::Shape::to_path(&rect, 0.1),
719                matrix: kurbo::Affine::IDENTITY,
720                fill_rule: pdfrum_page::FillRule::Winding,
721                stroke: false,
722            },
723            state: highlight_state(),
724            marks: pdfrum_page::ContentMarks::default(),
725            content_stream: None,
726            // Annotation chrome is drawn into the page graph but is not page
727            // content: it belongs to no `/Contents` element and must never
728            // make an ordinary render count as a mutation.
729            dirty: false,
730            active: true,
731        },
732    )))
733}
734
735/// The graphics state the highlight rectangle fills under.
736///
737/// The colour is `0xFFE4DD` read as BGR, and the alpha is the 100/255 the
738/// host asks for. It reaches the fill as `/ca` rather than as an alpha in the
739/// colour word because that is where this engine keeps a constant alpha, and
740/// the two are the same source-over multiply — `FillRect`'s `CompositeRect`
741/// and an ordinary alpha fill differ in *antialiasing*, which the hard-edged
742/// rectangle already settles, not in arithmetic.
743fn highlight_state() -> pdfrum_page::GraphicsState {
744    /// `0xFFE4DD` as an `FX_COLORREF`: blue high, then green, then red.
745    const HIGHLIGHT_BGR: u32 = 0x00FF_E4DD;
746    /// The host's highlight alpha, 100 of 255.
747    const HIGHLIGHT_ALPHA: f32 = 100.0 / 255.0;
748
749    let channel =
750        |shift: u32| u8::try_from((HIGHLIGHT_BGR >> shift) & 0xff).map_or(0.0, f32::from) / 255.0;
751    let mut fill = pdfrum_page::ColorValue::default();
752    fill.set_space(std::sync::Arc::new(pdfrum_page::ColorSpace::DeviceRgb));
753    // Red is the low byte and blue the high one: `FX_COLORREF` is BGR, so
754    // `0xFFE4DD` is a pale blue rather than the pink it reads as.
755    let _ = fill.set_components(&[channel(0), channel(8), channel(16)]);
756    pdfrum_page::GraphicsState {
757        fill,
758        general: pdfrum_page::GeneralState {
759            fill_alpha: HIGHLIGHT_ALPHA,
760            ..pdfrum_page::GeneralState::default()
761        },
762        ..pdfrum_page::GraphicsState::default()
763    }
764}
765
766/// Whether an annotation is painted at all on a screen render.
767///
768/// The two passes disagree about `Invisible`, so the subtype picks which test
769/// applies. Neither pass reads `Print` here, because a screen render is not
770/// printing: Pass A's `Print` requirement is gated on the printing flag, and
771/// Pass B has no print check at all.
772// Where the two passes come from: Pass A is CPDFSDK_RenderPage building a
773// CPDF_AnnotList and calling DisplayAnnots(..., bShowWidget=false), whose
774// DisplayPass skips every /Widget (cpdf_annotlist.cpp:250-253). Pass B is
775// FPDF_FFLDraw, which pdfium_test calls after every bitmap render with no
776// flag guard (pdfium_test.cc:1045-1050); its own test is
777// CPDFSDK_BAAnnot::IsVisible, which is the one that reads kInvisible.
778// pdfium_test --png seeds its flags with FPDF_ANNOT unconditionally
779// (pdfium_test.cc:220), which is why annotations are in the page image at
780// all.
781fn is_visible(subtype: Subtype, flags: crate::annot::AnnotFlags) -> bool {
782    if subtype == Subtype::Popup {
783        // A pop-up never draws on this walk. The file's own pop-ups are
784        // dropped from the list before it, and a synthesized one is drawn
785        // afterwards by `push_open_popup` — which owns the open-state test
786        // this function has no way to make.
787        return false;
788    }
789    if flags.is_hidden() || flags.no_view() {
790        return false;
791    }
792    // `CPDFSDK_BAAnnot::IsVisible` adds `kInvisible`, and only widgets reach
793    // it — Pass A never tests that bit.
794    if subtype == Subtype::Widget && flags.contains(crate::annot::AnnotFlags::INVISIBLE) {
795        return false;
796    }
797    true
798}
799
800#[cfg(test)]
801mod tests {
802    use super::{focus_rect, highlight, highlight_state, invalid_outline, is_visible, push_chrome};
803    use crate::annot::{AnnotFlags, Annotation, Subtype};
804    use crate::ap;
805    use pdfrum_common::{Diagnostics, Limits};
806    use pdfrum_object::{Dict, Name, NoResolve, Object};
807    use pdfrum_page::PageObject;
808
809    fn annot(subtype: Subtype, flags: i64) -> Annotation {
810        let mut annot = Annotation::read(&Dict::new(), &NoResolve);
811        annot.flags = AnnotFlags::from_bits(flags);
812        annot.subtype = subtype;
813        annot
814    }
815
816    /// A widget over `[100 100 200 130]` with the given field type and flags.
817    fn widget(field_type: &str, ff: i64) -> Annotation {
818        let dict = Dict::from_pairs([
819            (Name::from("Subtype"), Object::Name(Name::from("Widget"))),
820            (Name::from("FT"), Object::Name(Name::from(field_type))),
821            (Name::from("Ff"), Object::Int(ff)),
822            (
823                Name::from("Rect"),
824                Object::Array(pdfrum_object::Array::of([
825                    Object::Int(100),
826                    Object::Int(100),
827                    Object::Int(200),
828                    Object::Int(130),
829                ])),
830            ),
831        ]);
832        Annotation::read(&dict, &NoResolve)
833    }
834
835    fn tinted(annot: &Annotation) -> bool {
836        let (limits, mut diags) = (Limits::default(), Diagnostics::default());
837        highlight(annot, &NoResolve, &limits, &mut diags).is_some()
838    }
839
840    /// `0xFFE4DD` is an `FX_COLORREF`, so it is **BGR**: a pale blue. Over
841    /// white at alpha 100/255 that is `(241, 244, 255)`, which is what every
842    /// form golden in the corpus carries over its fields.
843    #[test]
844    fn the_highlight_colour_is_bgr_and_composites_to_the_goldens_tint() {
845        let state = highlight_state();
846        let rgb = state.fill.to_rgb().expect("a resolved colour");
847        assert_eq!(rgb.to_bytes(), [0xDD, 0xE4, 0xFF]);
848        assert!((state.general.fill_alpha * 255.0 - 100.0).abs() < 1e-4);
849        // The truncating `AlphaMerge` upstream composites it with.
850        let alpha = 100;
851        let over_white = |c: i32| ((255 * (255 - alpha)) + c * alpha) / 255;
852        assert_eq!(
853            [over_white(0xDD), over_white(0xE4), over_white(0xFF)],
854            [241, 244, 255]
855        );
856    }
857
858    #[test]
859    fn every_fillable_field_type_is_tinted() {
860        for ft in ["Tx", "Ch"] {
861            assert!(tinted(&widget(ft, 0)), "{ft}");
862        }
863        // A check box and a radio button are both `/Btn` without bit 17.
864        assert!(tinted(&widget("Btn", 0)));
865        assert!(tinted(&widget("Btn", 1 << 15)), "radio");
866    }
867
868    #[test]
869    fn the_three_kinds_of_field_that_are_never_tinted() {
870        // A push button: `IsFillingAllowed` refuses it.
871        assert!(!tinted(&widget("Btn", 1 << 16)));
872        // A signature: `CPDFSDK_Widget::OnDraw` never reaches the form filler.
873        assert!(!tinted(&widget("Sig", 0)));
874        // A read-only field, on the *form* flag — bit 0 of `/Ff`, not the
875        // annotation's own `ReadOnly` at bit 6 of `/F`.
876        assert!(!tinted(&widget("Tx", 1)));
877        let mut not_read_only = widget("Tx", 0);
878        not_read_only.flags = AnnotFlags::from_bits(64);
879        assert!(
880            tinted(&not_read_only),
881            "the annotation's ReadOnly bit is a different flag word"
882        );
883    }
884
885    #[test]
886    fn a_widget_with_no_field_type_is_not_tinted() {
887        // `IsNeedHighLight(kUnknown)` is false, and a widget with no `/FT`
888        // classifies to nothing.
889        let bare = Dict::from_pairs([(Name::from("Subtype"), Object::Name(Name::from("Widget")))]);
890        assert!(!tinted(&Annotation::read(&bare, &NoResolve)));
891        // And nothing that is not a widget is ever tinted.
892        assert!(!tinted(&annot(Subtype::Square, 0)));
893    }
894
895    /// A widget with the given field type, flags, `/AS` and `/AP /N` states.
896    fn stateful(field_type: &str, ff: i64, as_: &str, states: &[&str]) -> Annotation {
897        let normal = Dict::from_pairs(states.iter().map(|state| {
898            (
899                Name::from(*state),
900                Object::Stream(Box::new(pdfrum_object::Stream::new(
901                    Dict::new(),
902                    pdfrum_object::ByteSpan::from(b"x".to_vec()),
903                ))),
904            )
905        }));
906        let mut annot = widget(field_type, ff);
907        let mut dict = annot.dict.clone();
908        dict.push(
909            Name::from("AP"),
910            Object::Dict(Dict::from_pairs([(Name::from("N"), Object::Dict(normal))])),
911        );
912        dict.push(Name::from("AS"), Object::Name(Name::from(as_)));
913        annot.dict = dict;
914        annot
915    }
916
917    fn outlined(annot: &Annotation) -> bool {
918        invalid_outline(annot, &NoResolve).is_some()
919    }
920
921    #[test]
922    fn a_state_with_no_stream_outlines_a_checkbox_and_a_radio() {
923        // `/AS /Off` against an `/AP /N` that lists only `Yes` — the shape
924        // every widget in `checkbox_radiobutton` has.
925        assert!(outlined(&stateful("Btn", 0, "Off", &["Yes"])), "checkbox");
926        assert!(
927            outlined(&stateful("Btn", 1 << 15, "Off", &["value1"])),
928            "radio"
929        );
930        // And a state that does resolve is drawn rather than outlined.
931        assert!(!outlined(&stateful("Btn", 0, "Yes", &["Yes", "Off"])));
932    }
933
934    #[test]
935    fn only_a_checkbox_or_a_radio_is_ever_outlined() {
936        // The switch in `IsWidgetAppearanceValid` gives every other field type
937        // the `pSub->IsStream()` arm, and `DrawAppearance`'s branch names only
938        // these two anyway.
939        for (ft, ff) in [("Tx", 0), ("Ch", 0), ("Btn", 1 << 16), ("Sig", 0)] {
940            assert!(!outlined(&stateful(ft, ff, "Off", &["Yes"])), "{ft}");
941        }
942        assert!(!outlined(&annot(Subtype::Square, 0)));
943    }
944
945    #[test]
946    fn the_state_is_read_from_as_alone() {
947        // `GetAppState` reads `/AS` and stops — no `/V` fallback, no
948        // `/Parent`. A widget with no `/AS` looks up the empty state name and
949        // finds nothing, where `annot_ap` would have fallen back to `Off`.
950        let with_state = stateful("Btn", 0, "Off", &["Off"]);
951        assert!(!outlined(&with_state), "an `Off` stream resolves");
952        let mut no_state = with_state.clone();
953        no_state.dict = Dict::from_pairs(
954            with_state
955                .dict
956                .keys()
957                .filter(|key| key.as_bytes() != b"AS")
958                .filter_map(|key| {
959                    with_state
960                        .dict
961                        .get(key, &NoResolve)
962                        .map(|value| (key.clone(), value.get().clone()))
963                })
964                .collect::<Vec<_>>(),
965        );
966        assert!(outlined(&no_state), "with no `/AS` nothing resolves");
967    }
968
969    #[test]
970    fn the_outline_is_a_hairline_grey_stroke_and_fills_nothing() {
971        let object = invalid_outline(&stateful("Btn", 0, "Off", &["Yes"]), &NoResolve)
972            .expect("an invalid checkbox");
973        let pdfrum_page::PageObject::Path(path) = object else {
974            panic!("a path")
975        };
976        assert!(path.object.stroke);
977        // `DrawPath` is handed fill argb 0, so nothing is filled — spelling
978        // the rule `EvenOdd` here would paint the box solid.
979        assert_eq!(path.object.fill_rule, pdfrum_page::FillRule::None);
980        assert!(path.state.stroke_params.width.abs() < f32::EPSILON);
981        assert_eq!(
982            path.state
983                .stroke
984                .to_rgb()
985                .expect("a resolved colour")
986                .to_bytes(),
987            [0xAA, 0xAA, 0xAA]
988        );
989    }
990
991    /// `is_visible` over an annotation's own flags, which is what every test
992    /// below means by it — the open-action override is exercised in
993    /// `nav::open_action`.
994    fn visible(subtype: Subtype, flags: i64) -> bool {
995        is_visible(subtype, AnnotFlags::from_bits(flags))
996    }
997
998    #[test]
999    fn hidden_and_noview_suppress_in_both_passes_and_print_does_not() {
1000        for subtype in [Subtype::Widget, Subtype::Square] {
1001            assert!(visible(subtype, 0), "{subtype:?}");
1002            assert!(visible(subtype, 4), "Print alone still shows");
1003            assert!(!visible(subtype, 2), "Hidden");
1004            assert!(!visible(subtype, 32), "NoView");
1005            assert!(!visible(subtype, 4 | 32), "NoView beats Print");
1006        }
1007    }
1008
1009    #[test]
1010    fn invisible_suppresses_a_widget_and_only_a_widget() {
1011        // Pass B tests `kInvisible`; Pass A does not. A square carrying the
1012        // bit still draws, and a widget carrying it does not.
1013        assert!(!visible(Subtype::Widget, 1));
1014        assert!(visible(Subtype::Square, 1));
1015    }
1016
1017    #[test]
1018    fn a_popup_is_never_painted() {
1019        assert!(!visible(Subtype::Popup, 0));
1020    }
1021
1022    /// The chrome one annotation earns, as the loop appends it.
1023    fn chrome(annot: &Annotation, index: usize, focus: Option<ap::Focus>) -> Vec<PageObject> {
1024        let (limits, mut diags) = (Limits::default(), Diagnostics::default());
1025        let mut page = pdfrum_page::Page::empty();
1026        push_chrome(
1027            &mut page, annot, index, focus, &NoResolve, &limits, &mut diags,
1028        );
1029        page.objects
1030    }
1031
1032    /// The one path object a slice of chrome holds.
1033    fn only_path(objects: &[PageObject]) -> &pdfrum_page::Content<pdfrum_page::PathObject> {
1034        match objects {
1035            [PageObject::Path(path)] => path,
1036            _ => panic!("exactly one path"),
1037        }
1038    }
1039
1040    /// `CFFL_InteractiveFormFiller::OnDraw`'s live-control branch returns
1041    /// before `DrawShadow` through all three of its exits, so the widget
1042    /// being edited carries none of the tint every other fillable field does.
1043    /// Measured on `form_textfield_focused_ltr`: the plain golden tints all
1044    /// 3000 pixels of the widget and every `--send-events` golden of the same
1045    /// file tints none of them.
1046    #[test]
1047    fn the_focused_annotation_loses_its_tint() {
1048        let widget = widget("Tx", 0);
1049        assert_eq!(chrome(&widget, 3, None).len(), 1, "unfocused: a tint");
1050        assert!(
1051            chrome(&widget, 3, Some(ap::Focus::at(3))).is_empty(),
1052            "focused with no focus box: nothing at all"
1053        );
1054    }
1055
1056    /// A focused text field draws no outline: its focus rectangle comes back
1057    /// empty and the pass ends there. All four `form_textfield_focused_*`
1058    /// goldens carry a caret and glyphs over plain white with no dashes
1059    /// anywhere.
1060    #[test]
1061    fn a_focus_box_of_none_strokes_nothing_but_still_suppresses_the_tint() {
1062        let widget = widget("Tx", 0);
1063        let focused = ap::Focus {
1064            annot: 0,
1065            box_: ap::FocusBox::None,
1066        };
1067        assert!(chrome(&widget, 0, Some(focused)).is_empty());
1068        assert_eq!(focus_rect(&widget, ap::FocusBox::None), None);
1069    }
1070
1071    /// The dashed rectangle itself: opaque black, width 1, a one-unit dash
1072    /// array, and nothing filled.
1073    #[test]
1074    fn a_focused_widget_strokes_a_dashed_black_hairline_over_its_focus_box() {
1075        let widget = widget("Tx", 0);
1076        let box_ = kurbo::Rect::new(101.0, 402.0, 186.0, 416.0);
1077        let objects = chrome(
1078            &widget,
1079            0,
1080            Some(ap::Focus {
1081                annot: 0,
1082                box_: ap::FocusBox::Rect(box_),
1083            }),
1084        );
1085        let path = only_path(&objects);
1086        assert!(path.object.stroke);
1087        // Fill argb 0 beside the stroke: `EvenOddOptions()` names a rule for
1088        // a fill that never happens.
1089        assert_eq!(path.object.fill_rule, pdfrum_page::FillRule::None);
1090        assert_eq!(
1091            path.state.stroke.to_rgb().expect("a colour").to_bytes(),
1092            [0, 0, 0]
1093        );
1094        // `CFX_GraphStateData`'s defaults, none of which `DrawFocusRect`
1095        // overrides but the dash array.
1096        assert!((path.state.stroke_params.width - 1.0).abs() < f32::EPSILON);
1097        assert_eq!(path.state.stroke_params.dash.as_slice(), [1.0]);
1098        assert!(path.state.stroke_params.dash_phase.abs() < f32::EPSILON);
1099        // And it traces exactly the box it was handed, in page space.
1100        assert_eq!(path.object.matrix, kurbo::Affine::IDENTITY);
1101        assert_eq!(kurbo::Shape::bounding_box(&path.object.path), box_);
1102        assert!(!path.dirty, "annotation chrome is not page content");
1103    }
1104
1105    /// `scrollable_widgets1`'s acceptance geometry, read off the oracle's own
1106    /// `--send-events` goldens.
1107    ///
1108    /// The file is a **multi-select list box** (`/FT /Ch`, `/Ff 2097152`)
1109    /// over `/Rect [100 400 200 430]` on a 300x600 page, so the widget covers
1110    /// device rows 170..199 and columns 100..199. Both events goldens stroke
1111    /// a dashed box over **rows 185..198 and columns 101..186** (the second,
1112    /// with a different scroll, over rows 171..184) — a 14-row band *inside*
1113    /// the widget, which is `CPWL_ListBox::GetFocusRect`'s caret item
1114    /// clipped to the client area, not the widget's own edges. Neither
1115    /// golden carries a single tinted pixel.
1116    #[test]
1117    fn the_list_box_focus_box_is_the_caret_item_not_the_widget_rect() {
1118        let mut listbox = widget("Ch", 1 << 21);
1119        listbox.rect = kurbo::Rect::new(100.0, 400.0, 200.0, 430.0);
1120        // Device row 185 on a 600-tall page is y = 415..414; the golden's
1121        // band is rows 185..198, so y = 401 up to y = 415.
1122        let caret_item = kurbo::Rect::new(101.0, 401.0, 186.0, 415.0);
1123        let objects = chrome(
1124            &listbox,
1125            0,
1126            Some(ap::Focus {
1127                annot: 0,
1128                box_: ap::FocusBox::Rect(caret_item),
1129            }),
1130        );
1131        let bounds = kurbo::Shape::bounding_box(&only_path(&objects).object.path);
1132        assert_eq!(bounds, caret_item);
1133        // 14 device rows tall and 85 columns wide, inside a widget that is 30
1134        // by 100.
1135        assert!((bounds.height() - 14.0).abs() < f64::EPSILON);
1136        assert!(bounds.width() < listbox.rect.width());
1137    }
1138
1139    /// `CPWL_Wnd::GetFocusRect` inflates the window rectangle by one unit on
1140    /// every side, which for a check box or a radio button is the
1141    /// annotation's own rectangle grown by one.
1142    #[test]
1143    fn an_inflated_focus_box_grows_the_annotation_rect_by_one_unit() {
1144        let mut check = widget("Btn", 0);
1145        check.rect = kurbo::Rect::new(100.0, 100.0, 200.0, 130.0);
1146        let objects = chrome(
1147            &check,
1148            0,
1149            Some(ap::Focus {
1150                annot: 0,
1151                box_: ap::FocusBox::Inflated,
1152            }),
1153        );
1154        assert_eq!(
1155            kurbo::Shape::bounding_box(&only_path(&objects).object.path),
1156            kurbo::Rect::new(99.0, 99.0, 201.0, 131.0)
1157        );
1158        // A `/Rect` written corner-first normalizes first, so inflating it
1159        // grows rather than collapses it.
1160        let mut backwards = check.clone();
1161        backwards.rect = kurbo::Rect::new(200.0, 130.0, 100.0, 100.0);
1162        let objects = chrome(
1163            &backwards,
1164            0,
1165            Some(ap::Focus {
1166                annot: 0,
1167                box_: ap::FocusBox::Inflated,
1168            }),
1169        );
1170        assert_eq!(
1171            kurbo::Shape::bounding_box(&only_path(&objects).object.path),
1172            kurbo::Rect::new(99.0, 99.0, 201.0, 131.0)
1173        );
1174    }
1175
1176    /// A degenerate box — zero wide or zero tall — strokes nothing, but the
1177    /// tint is still gone, because that early return is inside the
1178    /// live-control branch.
1179    #[test]
1180    fn an_empty_focus_box_strokes_nothing_and_does_not_bring_the_tint_back() {
1181        let widget = widget("Tx", 0);
1182        for degenerate in [
1183            kurbo::Rect::new(100.0, 100.0, 100.0, 130.0),
1184            kurbo::Rect::new(100.0, 100.0, 200.0, 100.0),
1185        ] {
1186            assert_eq!(focus_rect(&widget, ap::FocusBox::Rect(degenerate)), None);
1187            assert!(
1188                chrome(
1189                    &widget,
1190                    0,
1191                    Some(ap::Focus {
1192                        annot: 0,
1193                        box_: ap::FocusBox::Rect(degenerate)
1194                    })
1195                )
1196                .is_empty()
1197            );
1198        }
1199    }
1200
1201    /// Focus is one index, and every other annotation on the page draws
1202    /// exactly what it drew before focus existed — the same objects, compared
1203    /// by value.
1204    #[test]
1205    fn every_index_but_the_focused_one_is_unchanged() {
1206        let widgets = [
1207            widget("Tx", 0),
1208            widget("Ch", 0),
1209            widget("Btn", 0),
1210            // The three that were never tinted anyway.
1211            widget("Btn", 1 << 16),
1212            widget("Sig", 0),
1213            widget("Tx", 1),
1214        ];
1215        let focused = ap::Focus {
1216            annot: 1,
1217            box_: ap::FocusBox::Inflated,
1218        };
1219        for (index, annot) in widgets.iter().enumerate() {
1220            let before = chrome(annot, index, None);
1221            let after = chrome(annot, index, Some(focused));
1222            if index == focused.annot {
1223                assert_ne!(before, after, "the focused index does change");
1224                continue;
1225            }
1226            assert_eq!(before, after, "index {index} moved");
1227        }
1228    }
1229
1230    /// The `None` path is the byte-identical one: with no focus at all every
1231    /// annotation gets exactly the tint decision `highlight` alone makes,
1232    /// which is what the pass did before focus was expressible.
1233    #[test]
1234    fn no_focus_is_the_pass_as_it_was() {
1235        let (limits, mut diags) = (Limits::default(), Diagnostics::default());
1236        for annot in [
1237            widget("Tx", 0),
1238            widget("Ch", 0),
1239            widget("Btn", 0),
1240            widget("Btn", 1 << 15),
1241            widget("Btn", 1 << 16),
1242            widget("Sig", 0),
1243            widget("Tx", 1),
1244            annot(Subtype::Square, 0),
1245        ] {
1246            let expected: Vec<PageObject> = highlight(&annot, &NoResolve, &limits, &mut diags)
1247                .into_iter()
1248                .collect();
1249            for index in 0..3 {
1250                assert_eq!(chrome(&annot, index, None), expected, "index {index}");
1251            }
1252        }
1253    }
1254
1255    /// An overlay's focus survives a merge and is not bounded by its length —
1256    /// a session sized for the one appearance it produced can still name the
1257    /// annotation that holds the focus.
1258    #[test]
1259    fn focus_merges_over_and_is_not_bounded_by_the_overlay() {
1260        let mut base = ap::AnnotOverlay::with_capacity(4);
1261        assert_eq!(base.focus(), None);
1262        let mut supplied = ap::AnnotOverlay::with_capacity(1);
1263        supplied.set_focus(ap::Focus::at(9));
1264        base.merge_over(&supplied);
1265        assert_eq!(base.focus(), Some(ap::Focus::at(9)));
1266        // An overlay with nothing to say about focus leaves the base's alone.
1267        base.merge_over(&ap::AnnotOverlay::with_capacity(4));
1268        assert_eq!(base.focus(), Some(ap::Focus::at(9)));
1269    }
1270}