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(¬_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}