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