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