pdfrum_doc/ap/mod.rs
1//! Appearance-stream generation: turning an annotation's dictionary into the
2//! content stream a viewer draws.
3//!
4//! # The overlay
5//!
6//! Generating an appearance is conventionally a **mutation of the
7//! document**. A sticky note's `/Rect` is replaced with a 20×20 box; an ink
8//! annotation's is inflated; every annotation touched gains an `/AP /N`
9//! pointing at a new stream and a marker key saying so. Everything that reads
10//! the file afterwards sees the mutated state, which is why a dump reports
11//! 20×20 rectangles for sticky notes whose files say otherwise.
12//!
13//! Parsed objects here are values and the parser's store is immutable, so
14//! [`generate_appearances`] returns an [`AnnotOverlay`] instead: one entry per
15//! `/Annots` index recording the stream it produced and the dictionary edits
16//! it implies. Readers consult the overlay before the dictionary.
17//!
18//! # Which annotations get one
19//!
20//! Ten subtypes have a generator, and a widget annotation with no `/AP`
21//! dictionary gets its chrome from [`widget`] besides — plus, when the caller
22//! has fonts to set text with, the field body [`field_body`] lays out.
23//! Generation is refused
24//! outright when the
25//! annotation is hidden, or when `/AP /N` already reads as a dictionary —
26//! and a **stream** answers as its own dictionary, so the ordinary "it
27//! already has an appearance" case is covered by the same test. Only a
28//! missing `/AP`, a missing `/N`, or a scalar `/N` leaves the door open.
29
30mod border;
31mod da;
32pub(crate) mod emit;
33pub mod field_body;
34pub(crate) mod fmt;
35pub mod font_map;
36pub mod freetext;
37mod markup;
38pub(crate) mod popup;
39mod shapes;
40pub mod widget;
41
42use kurbo::{Affine, Rect};
43use pdfrum_common::{DiagKind, Diagnostics, Severity};
44use pdfrum_object::{Array, Dict, Name, Object, Resolve, names as obj_names};
45
46use crate::annot::{Subtype, appearance, quad};
47use crate::names;
48use crate::vt;
49
50/// One generated appearance and the dictionary edits it implies.
51///
52/// ```
53/// use pdfrum_common::Diagnostics;
54/// use pdfrum_doc::ap::generate_appearances;
55/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
56///
57/// let square = Dict::from_pairs([
58/// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
59/// (
60/// Name::from("Rect"),
61/// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
62/// ),
63/// ]);
64/// let page = Dict::from_pairs([(
65/// Name::from("Annots"),
66/// Object::Array(Array::of([Object::Dict(square)])),
67/// )]);
68///
69/// let mut diags = Diagnostics::default();
70/// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
71/// let generated = overlay.get(0).expect("a square has a generator");
72///
73/// // The matrix these generators produce is always the identity.
74/// assert_eq!(generated.matrix, kurbo::Affine::IDENTITY);
75/// ```
76#[derive(Debug, Clone, PartialEq)]
77pub struct GeneratedAp {
78 /// The content-stream bytes.
79 pub stream: Vec<u8>,
80 /// The form `XObject`'s bounding box.
81 pub bbox: Rect,
82 /// Its matrix, which these generators always leave as the identity.
83 pub matrix: Affine,
84 /// Its resource dictionary.
85 pub resources: Dict,
86 /// A rewritten `/Rect`, when generation moved one.
87 pub rect_override: Option<Rect>,
88 /// A copied-down `/AS`, for the `/NeedAppearances` widget path.
89 pub as_override: Option<Name>,
90}
91
92/// What an overlay says about one annotation.
93///
94/// Three states, not two, and the third is why this is an enum rather than an
95/// `Option`. "Nothing was generated" and "this annotation draws nothing" are
96/// different instructions: the first falls through to whatever `/AP` the file
97/// carries, the second **suppresses** it. A field whose appearance has been
98/// cleared — focus left it and it went back to drawing nothing — needs the
99/// second, and expressing it as the absence of an entry would make it
100/// indistinguishable from the first.
101///
102/// ```
103/// use pdfrum_doc::{AnnotOverlay, ap::Appearance};
104///
105/// let mut overlay = AnnotOverlay::with_capacity(2);
106/// assert_eq!(overlay.appearance(0), &Appearance::Untouched);
107///
108/// // Suppressed is not the same as untouched: it says "draw nothing",
109/// // even for an annotation the file gave an `/AP`.
110/// overlay.set_appearance(0, Appearance::Suppressed);
111/// assert!(overlay.get(0).is_none());
112/// ```
113#[derive(Debug, Clone, Default, PartialEq)]
114pub enum Appearance {
115 /// Nothing to say. The file's own `/AP` is used, if it has one.
116 #[default]
117 Untouched,
118 /// Draw this instead of the file's `/AP`.
119 Generated(GeneratedAp),
120 /// Draw nothing at all, even if the file carries an `/AP`.
121 ///
122 /// Nothing sets this yet. It exists so a cleared appearance has a
123 /// spelling that is not "absent", which is what keeps the merge below
124 /// able to express one later without changing shape.
125 Suppressed,
126}
127
128/// The shape a focused widget's focus rectangle takes.
129///
130/// A widget being edited has a live control behind it, and what that control
131/// answers when asked for a focus rectangle depends on which control it is —
132/// three answers, not one, and two of the three are *no rectangle at all*:
133///
134/// - A **text field** and a **combo box** — editable or not — answer an empty
135/// rectangle outright, so nothing is stroked over them. This is the common
136/// case and it is why the focused text-field goldens carry a caret and
137/// glyphs but no outline. Editability does **not** enter into it: a caller
138/// that inflates a read-only combo strokes a box that must not be drawn.
139/// - A **check box**, a **radio button** and a **single-select list box**
140/// answer their window rectangle inflated by one unit on every side, which
141/// is [`FocusBox::Inflated`]. A list box falls through to that answer when
142/// it is not multi-select.
143/// - A **multi-select list box** answers the rectangle of the item its caret
144/// sits on, clipped to the client area — a rectangle only the list control's
145/// own scroll and caret state can name, so a caller that has it supplies it
146/// as [`FocusBox::Rect`].
147///
148/// `annot_render`'s own table says the same thing; the two are kept in step
149/// deliberately, because this is the one a `pdfrum-form` caller reads.
150///
151/// [`FocusBox::None`] is the empty answer and the default: a focused entry
152/// that names it is still *focused* — it draws no tint — and simply strokes
153/// nothing.
154///
155/// ```
156/// use pdfrum_doc::{FocusBox, geom};
157///
158/// // A text field and an editable combo box stroke nothing.
159/// assert_eq!(FocusBox::default(), FocusBox::None);
160/// // A multi-select list box names the rectangle only it can compute.
161/// let explicit = FocusBox::Rect(geom::rect(0.0, 0.0, 100.0, 20.0));
162/// assert_ne!(explicit, FocusBox::Inflated);
163/// ```
164#[derive(Debug, Clone, Copy, Default, PartialEq)]
165pub enum FocusBox {
166 /// No rectangle: nothing is stroked. A text field and an editable combo
167 /// box always answer this.
168 #[default]
169 None,
170 /// The annotation's own rectangle, inflated by one unit on every side.
171 Inflated,
172 /// An explicit rectangle in page space, already in its final position.
173 Rect(Rect),
174}
175
176/// Which annotation on the page holds the keyboard focus, and what its focus
177/// rectangle is.
178///
179/// Both halves are needed and they are independent. The *index* alone decides
180/// the tint: a widget with a live control is never tinted, focused or not, and
181/// the focused one is the only widget a live control reaches in a
182/// single-focus session. The *box* decides whether anything is stroked in its
183/// place, which most field types answer with nothing.
184///
185/// ```
186/// use pdfrum_doc::{AnnotOverlay, Focus, FocusBox, geom};
187///
188/// let mut overlay = AnnotOverlay::with_capacity(2);
189/// overlay.set_focus(Focus {
190/// annot: 1,
191/// box_: FocusBox::Rect(geom::rect(0.0, 0.0, 100.0, 20.0)),
192/// });
193/// assert_eq!(overlay.focus().map(|f| f.annot), Some(1));
194/// ```
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct Focus {
197 /// The raw `/Annots` index of the focused annotation — the same key space
198 /// [`AnnotOverlay::set`] uses.
199 pub annot: usize,
200 /// The rectangle to stroke, in page space.
201 pub box_: FocusBox,
202}
203
204impl Focus {
205 /// Focus on one annotation with no rectangle to stroke — the answer a
206 /// text field and an editable combo box give.
207 ///
208 /// ```
209 /// use pdfrum_doc::{Focus, FocusBox};
210 ///
211 /// let focus = Focus::at(2);
212 /// assert_eq!(focus.annot, 2);
213 /// // No rectangle to stroke, which is what a text field answers.
214 /// assert_eq!(focus.box_, FocusBox::None);
215 /// ```
216 #[must_use]
217 pub fn at(annot: usize) -> Focus {
218 Focus {
219 annot,
220 box_: FocusBox::None,
221 }
222 }
223}
224
225/// Per-annotation generated appearances, keyed by `/Annots` index.
226///
227/// Besides the per-annotation entries the overlay carries at most one
228/// [`Focus`], because a session focuses one field at a time. It travels here
229/// rather than as another parameter on the annotation pass for two reasons:
230/// it is set by the same session that sets the appearances, from the same
231/// index space, and adding it here left every existing caller compiling
232/// unchanged.
233///
234/// ```
235/// use pdfrum_common::Diagnostics;
236/// use pdfrum_doc::ap::generate_appearances;
237/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
238///
239/// let square = Dict::from_pairs([
240/// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
241/// (
242/// Name::from("Rect"),
243/// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
244/// ),
245/// ]);
246/// let page = Dict::from_pairs([(
247/// Name::from("Annots"),
248/// Object::Array(Array::of([Object::Dict(square)])),
249/// )]);
250///
251/// // Every reader in this crate takes the overlay and consults it
252/// // before the raw dictionary.
253/// let mut diags = Diagnostics::default();
254/// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
255/// assert!(overlay.get(0).is_some());
256/// ```
257#[derive(Debug, Clone, Default, PartialEq)]
258pub struct AnnotOverlay {
259 entries: Vec<Appearance>,
260 focus: Option<Focus>,
261 hover: Option<usize>,
262 live_edit: Option<usize>,
263}
264
265impl AnnotOverlay {
266 /// An overlay with room for `count` annotations and nothing generated.
267 ///
268 /// ```
269 /// use pdfrum_doc::{AnnotOverlay, ap::Appearance};
270 ///
271 /// let overlay = AnnotOverlay::with_capacity(3);
272 /// assert_eq!(overlay.len(), 3);
273 /// assert_eq!(overlay.appearance(0), &Appearance::Untouched);
274 /// ```
275 #[must_use]
276 pub fn with_capacity(count: usize) -> AnnotOverlay {
277 AnnotOverlay {
278 entries: vec![Appearance::Untouched; count],
279 focus: None,
280 hover: None,
281 live_edit: None,
282 }
283 }
284
285 /// Records which annotation holds the focus, and what to stroke over it.
286 ///
287 /// The index is a raw `/Annots` index. It is **not** bounded by the
288 /// overlay's length: an overlay sized for the appearances it carries can
289 /// still name a focused annotation past its end, and the annotation pass
290 /// keys on the index rather than on an entry.
291 ///
292 /// ```
293 /// use pdfrum_doc::{AnnotOverlay, Focus};
294 ///
295 /// let mut overlay = AnnotOverlay::with_capacity(2);
296 /// // The index is a raw `/Annots` index and is not bounded by the length.
297 /// overlay.set_focus(Focus::at(7));
298 /// assert_eq!(overlay.focus().map(|f| f.annot), Some(7));
299 /// ```
300 pub fn set_focus(&mut self, focus: Focus) {
301 self.focus = Some(focus);
302 }
303
304 /// Which annotation holds the focus, if any.
305 ///
306 /// ```
307 /// use pdfrum_doc::{AnnotOverlay, Focus};
308 ///
309 /// let mut overlay = AnnotOverlay::with_capacity(2);
310 /// assert!(overlay.focus().is_none());
311 /// overlay.set_focus(Focus::at(1));
312 /// assert_eq!(overlay.focus().map(|f| f.annot), Some(1));
313 /// ```
314 #[must_use]
315 pub fn focus(&self) -> Option<Focus> {
316 self.focus
317 }
318
319 /// Records which annotation the pointer is inside.
320 ///
321 /// A raw `/Annots` index, like [`Self::set_focus`]'s, and equally
322 /// unbounded by the overlay's length. Hover is a separate fact from focus
323 /// and the two move independently: a pointer resting on an annotation
324 /// leaves the keyboard focus wherever it was, and the annotation under the
325 /// pointer need not be focusable at all — a highlight is the case that
326 /// matters, since it is *only* reachable this way.
327 ///
328 /// What it decides is whether that annotation's synthesized pop-up note is
329 /// **open**. A note card is drawn only while the pointer is inside its
330 /// parent, and nothing a file can say opens one, so this is the whole of
331 /// the signal.
332 ///
333 /// ```
334 /// use pdfrum_doc::AnnotOverlay;
335 ///
336 /// let mut overlay = AnnotOverlay::with_capacity(4);
337 /// overlay.set_hover(1);
338 /// assert_eq!(overlay.hover(), Some(1));
339 /// // Hover and focus move independently.
340 /// assert!(overlay.focus().is_none());
341 /// ```
342 pub fn set_hover(&mut self, annot: usize) {
343 self.hover = Some(annot);
344 }
345
346 /// Which annotation the pointer is inside, if any.
347 ///
348 /// ```
349 /// use pdfrum_doc::AnnotOverlay;
350 ///
351 /// let mut overlay = AnnotOverlay::with_capacity(4);
352 /// assert!(overlay.hover().is_none());
353 /// overlay.set_hover(0);
354 /// assert_eq!(overlay.hover(), Some(0));
355 /// ```
356 #[must_use]
357 pub fn hover(&self) -> Option<usize> {
358 self.hover
359 }
360
361 /// Records that one annotation's supplied appearance is a **live edit's**
362 /// — the field the session is currently typing in.
363 ///
364 /// A raw `/Annots` index, like [`Self::set_focus`]'s and equally unbounded
365 /// by the overlay's length. At most one annotation can be under live edit,
366 /// because a session focuses one field at a time; a second call replaces
367 /// the first rather than accumulating.
368 ///
369 /// It is a separate signal from focus, and the two are **not**
370 /// interchangeable. A field can hold the focus without being edited — it
371 /// was tabbed to and nothing has been typed — in which case the session
372 /// generates no appearance for it and there is nothing to mark. What this
373 /// records is that the appearance carried at this index came from an
374 /// editor, which is what makes the oracle draw its text with `ClearType`.
375 ///
376 /// ```
377 /// use pdfrum_doc::AnnotOverlay;
378 ///
379 /// let mut overlay = AnnotOverlay::with_capacity(4);
380 /// overlay.set_live_edit(1);
381 /// // A second call replaces the first: one field is edited at a time.
382 /// overlay.set_live_edit(2);
383 /// assert_eq!(overlay.live_edit(), Some(2));
384 /// ```
385 pub fn set_live_edit(&mut self, annot: usize) {
386 self.live_edit = Some(annot);
387 }
388
389 /// Which annotation's appearance is a live edit's, if any.
390 ///
391 /// ```
392 /// use pdfrum_doc::AnnotOverlay;
393 ///
394 /// let mut overlay = AnnotOverlay::with_capacity(4);
395 /// assert!(overlay.live_edit().is_none());
396 /// overlay.set_live_edit(3);
397 /// assert_eq!(overlay.live_edit(), Some(3));
398 /// ```
399 #[must_use]
400 pub fn live_edit(&self) -> Option<usize> {
401 self.live_edit
402 }
403
404 /// Whether the appearance at one `/Annots` index came from a live edit.
405 ///
406 /// ```
407 /// use pdfrum_doc::AnnotOverlay;
408 ///
409 /// let mut overlay = AnnotOverlay::with_capacity(4);
410 /// overlay.set_live_edit(1);
411 /// assert!(overlay.is_live_edit(1));
412 /// assert!(!overlay.is_live_edit(0));
413 /// ```
414 #[must_use]
415 pub fn is_live_edit(&self, index: usize) -> bool {
416 self.live_edit == Some(index)
417 }
418
419 /// Records a generated appearance at one `/Annots` index.
420 ///
421 /// ```
422 /// use pdfrum_common::Diagnostics;
423 /// use pdfrum_doc::ap::generate_appearances;
424 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
425 ///
426 /// let square = Dict::from_pairs([
427 /// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
428 /// (
429 /// Name::from("Rect"),
430 /// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
431 /// ),
432 /// (
433 /// Name::from("IC"),
434 /// Object::Array(Array::of([1, 0, 0].map(Object::from))),
435 /// ),
436 /// ]);
437 /// let page = Dict::from_pairs([(
438 /// Name::from("Annots"),
439 /// Object::Array(Array::of([Object::Dict(square)])),
440 /// )]);
441 ///
442 /// let mut diags = Diagnostics::default();
443 /// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
444 ///
445 /// // The walk sets index 0; a caller can set any index the same way.
446 /// let generated = overlay.get(0).expect("a square has a generator").clone();
447 /// let mut mine = pdfrum_doc::AnnotOverlay::with_capacity(2);
448 /// mine.set(1, generated);
449 /// assert!(mine.get(1).is_some());
450 /// ```
451 pub fn set(&mut self, index: usize, generated: GeneratedAp) {
452 self.set_appearance(index, Appearance::Generated(generated));
453 }
454
455 /// Records any of the three states at one `/Annots` index.
456 ///
457 /// ```
458 /// use pdfrum_doc::{AnnotOverlay, ap::Appearance};
459 ///
460 /// let mut overlay = AnnotOverlay::with_capacity(2);
461 /// overlay.set_appearance(0, Appearance::Suppressed);
462 /// assert_eq!(overlay.appearance(0), &Appearance::Suppressed);
463 /// // A suppressed entry has no stream to draw.
464 /// assert!(overlay.get(0).is_none());
465 /// ```
466 pub fn set_appearance(&mut self, index: usize, appearance: Appearance) {
467 if let Some(slot) = self.entries.get_mut(index) {
468 *slot = appearance;
469 }
470 }
471
472 /// What was generated at one `/Annots` index, if anything.
473 ///
474 /// A suppressed entry answers [`None`], the same as an untouched one —
475 /// callers that only want a stream to draw need not distinguish them.
476 /// [`AnnotOverlay::appearance`] is what tells them apart.
477 ///
478 /// ```
479 /// use pdfrum_common::Diagnostics;
480 /// use pdfrum_doc::ap::generate_appearances;
481 /// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
482 ///
483 /// let square = Dict::from_pairs([
484 /// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
485 /// (
486 /// Name::from("Rect"),
487 /// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
488 /// ),
489 /// (
490 /// Name::from("IC"),
491 /// Object::Array(Array::of([1, 0, 0].map(Object::from))),
492 /// ),
493 /// ]);
494 /// let page = Dict::from_pairs([(
495 /// Name::from("Annots"),
496 /// Object::Array(Array::of([Object::Dict(square)])),
497 /// )]);
498 ///
499 /// let mut diags = Diagnostics::default();
500 /// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
501 ///
502 /// assert!(overlay.get(0).is_some());
503 /// // Past the end is `None`, not a panic.
504 /// assert!(overlay.get(9).is_none());
505 /// ```
506 #[must_use]
507 pub fn get(&self, index: usize) -> Option<&GeneratedAp> {
508 match self.appearance(index) {
509 Appearance::Generated(generated) => Some(generated),
510 Appearance::Untouched | Appearance::Suppressed => None,
511 }
512 }
513
514 /// The full state at one `/Annots` index, suppression included.
515 ///
516 /// An index past the overlay's end reads as [`Appearance::Untouched`],
517 /// which is what makes a short overlay safe to consult for any index.
518 ///
519 /// ```
520 /// use pdfrum_doc::{AnnotOverlay, ap::Appearance};
521 ///
522 /// let overlay = AnnotOverlay::with_capacity(1);
523 /// // An index past the end reads as untouched, so a short overlay is
524 /// // safe to consult for any index.
525 /// assert_eq!(overlay.appearance(99), &Appearance::Untouched);
526 /// ```
527 #[must_use]
528 pub fn appearance(&self, index: usize) -> &Appearance {
529 self.entries.get(index).unwrap_or(&Appearance::Untouched)
530 }
531
532 /// Lays `other`'s entries over this one's.
533 ///
534 /// Every entry `other` has anything to say about — generated **or**
535 /// suppressed — replaces this overlay's, and its [`Appearance::Untouched`]
536 /// entries leave this one's alone. So a caller-supplied overlay wins
537 /// wherever it speaks and defers everywhere else, which is the merge a
538 /// live edit needs: the session has an opinion about the one field being
539 /// edited and none about the rest of the page.
540 ///
541 /// Indices are raw `/Annots` indices in both overlays. An entry of
542 /// `other` past this overlay's end is dropped, because there is no
543 /// annotation for it to apply to.
544 ///
545 /// `other`'s [`Focus`] and its hover each replace this overlay's when it
546 /// has one, and leave it alone when it does not — the same "wins wherever
547 /// it speaks" rule the entries follow. Unlike an entry, either one past
548 /// this overlay's end survives: both name an annotation, not a slot.
549 ///
550 /// ```
551 /// use pdfrum_doc::{AnnotOverlay, ap::Appearance};
552 ///
553 /// let mut page = AnnotOverlay::with_capacity(2);
554 /// page.set_appearance(0, Appearance::Suppressed);
555 ///
556 /// // The session speaks about index 1 only.
557 /// let mut session = AnnotOverlay::with_capacity(2);
558 /// session.set_appearance(1, Appearance::Suppressed);
559 /// page.merge_over(&session);
560 ///
561 /// assert_eq!(page.appearance(0), &Appearance::Suppressed);
562 /// assert_eq!(page.appearance(1), &Appearance::Suppressed);
563 /// ```
564 pub fn merge_over(&mut self, other: &AnnotOverlay) {
565 for (index, entry) in other.entries.iter().enumerate() {
566 if matches!(entry, Appearance::Untouched) {
567 continue;
568 }
569 self.set_appearance(index, entry.clone());
570 }
571 if let Some(focus) = other.focus {
572 self.focus = Some(focus);
573 }
574 if let Some(hover) = other.hover {
575 self.hover = Some(hover);
576 }
577 if let Some(live_edit) = other.live_edit {
578 self.live_edit = Some(live_edit);
579 }
580 }
581
582 /// The rectangle an annotation should be read as having.
583 ///
584 /// ```
585 /// use pdfrum_doc::{AnnotOverlay, geom};
586 ///
587 /// let overlay = AnnotOverlay::with_capacity(1);
588 /// let raw = geom::rect(0.0, 0.0, 100.0, 50.0);
589 /// // Nothing generated: the annotation keeps the rectangle it declared.
590 /// assert_eq!(overlay.rect(0, raw), raw);
591 /// ```
592 #[must_use]
593 pub fn rect(&self, index: usize, raw: Rect) -> Rect {
594 self.get(index)
595 .and_then(|generated| generated.rect_override)
596 .unwrap_or(raw)
597 }
598
599 /// How many annotations the overlay covers.
600 ///
601 /// ```
602 /// use pdfrum_doc::AnnotOverlay;
603 ///
604 /// assert_eq!(AnnotOverlay::with_capacity(3).len(), 3);
605 /// ```
606 #[must_use]
607 pub fn len(&self) -> usize {
608 self.entries.len()
609 }
610
611 /// Whether the overlay covers no annotations at all.
612 ///
613 /// ```
614 /// use pdfrum_doc::AnnotOverlay;
615 ///
616 /// assert!(AnnotOverlay::with_capacity(0).is_empty());
617 /// assert!(!AnnotOverlay::with_capacity(1).is_empty());
618 /// ```
619 #[must_use]
620 pub fn is_empty(&self) -> bool {
621 self.entries.is_empty()
622 }
623}
624
625/// The font a text-bearing generator sets its text with.
626///
627/// Threaded in rather than loaded here, because loading one needs a font
628/// cache the caller already owns, and because the layout engine is a pure
629/// function of these numbers — which is what lets it be tested against a stub.
630///
631/// ```
632/// use pdfrum_doc::ap::FormFonts;
633/// use pdfrum_object::{Dict, NoResolve};
634///
635/// // A catalog with no `/AcroForm` still yields the stock fallback face.
636/// let mut ctx = pdfrum_page::BuildContext::new();
637/// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
638/// use pdfrum_doc::ap::TextFont;
639///
640/// let font = fonts.face(b"Helv").expect("the fallback face");
641/// let width = |code: u32| TextFont::char_width(font, code);
642/// let text = fonts.text_font(b"Helv", &width).expect("a face to set text with");
643///
644/// // The ascent the layout engine stacks lines by.
645/// assert!(text.metrics.ascent > 0);
646/// ```
647pub struct TextFont<'a> {
648 /// The loaded font.
649 pub font: &'a pdfrum_font::Font,
650 /// Metrics derived from it, for the layout engine.
651 pub metrics: vt::Metrics<'a>,
652}
653
654impl std::fmt::Debug for TextFont<'_> {
655 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
656 f.debug_struct("TextFont")
657 .field("metrics", &self.metrics)
658 .finish_non_exhaustive()
659 }
660}
661
662/// The character code a code point the face cannot map is written as.
663///
664/// A simple font's codes are one byte and `Font::append_char` truncates to
665/// one, so the code the stream ends up carrying is the code point's **low
666/// byte** — and the width has to be looked up under that same byte or the
667/// layout advances by a glyph the stream does not name. A composite font
668/// keeps the whole value, because its CMap decides the width itself.
669fn unmapped_code(font: &pdfrum_font::Font, code: u32) -> pdfrum_font::CharCode {
670 match font {
671 pdfrum_font::Font::Type0(_) => pdfrum_font::CharCode(code),
672 pdfrum_font::Font::Simple(_) | pdfrum_font::Font::Type3(_) => {
673 pdfrum_font::CharCode(code & 0xff)
674 }
675 }
676}
677
678impl TextFont<'_> {
679 /// How one code point is written into a content stream.
680 ///
681 /// A `Symbol` or `ZapfDingbats` font takes the code point's **low byte**
682 /// verbatim, relying on the font's built-in encoding: there is no
683 /// named-glyph table and no `/Encoding` consultation anywhere in this
684 /// path. Anything else goes through the reverse `ToUnicode` mapping.
685 ///
686 /// **A code point the font cannot represent is still written**, as its own
687 /// value taken for a character code. The glyph that draws is whatever that
688 /// code happens to name in the chosen face and is usually wrong — but the
689 /// text object exists, occupies the layout, and is what a reader sees.
690 /// Dropping the character instead loses the object entirely, which on
691 /// `bug_725389` — three Hebrew characters in a `/DA` naming Times-Roman —
692 /// is the difference between six text objects and three.
693 ///
694 /// ```
695 /// use pdfrum_doc::ap::FormFonts;
696 /// use pdfrum_object::{Dict, NoResolve};
697 ///
698 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
699 /// let mut ctx = pdfrum_page::BuildContext::new();
700 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
701 /// use pdfrum_doc::ap::TextFont;
702 ///
703 /// let font = fonts.face(b"Helv").expect("the fallback face");
704 /// let width = |code: u32| TextFont::char_width(font, code);
705 /// let text = fonts.text_font(b"Helv", &width).expect("a face");
706 ///
707 /// // `A` writes as one byte in a simple font.
708 /// assert_eq!(text.encode(u32::from('A')), b"A");
709 /// ```
710 // The oracle reaches the same place by a longer road: CPDF_BAFontMap
711 // first looks for a second face that knows the character, and only
712 // CPWL_EditImpl::GetPDFWordString's fallthrough appends the raw value
713 // when none does. On a hermetic font set no second face is found, so the
714 // fallthrough is the whole of the observable behaviour — which is why no
715 // N-slot map is built here for a result it would not change.
716 #[must_use]
717 pub fn encode(&self, code: u32) -> Vec<u8> {
718 let name = self.font.base_font_name();
719 if name == b"Symbol" || name == b"ZapfDingbats" {
720 return vec![u8::try_from(code & 0xff).unwrap_or(0)];
721 }
722 let mut out = Vec::new();
723 let mapped = char::from_u32(code)
724 .and_then(|ch| self.font.char_code_from_unicode(ch))
725 .unwrap_or_else(|| unmapped_code(self.font, code));
726 self.font.append_char(&mut out, mapped);
727 out
728 }
729
730 /// One code point's width, in thousandths of an em.
731 ///
732 /// The width is the one the face gives whatever [`Self::encode`] wrote, so
733 /// an unrepresentable code point measures the glyph its raw value names
734 /// rather than nothing — the two have to agree or the layout advances past
735 /// characters the stream still contains, and the line comes out the wrong
736 /// length.
737 ///
738 /// A free function rather than a method because [`Self::metrics_of`] wants
739 /// it as a `&dyn Fn` borrowed for the same lifetime as the font, which a
740 /// closure over `self` cannot supply before `self` exists.
741 ///
742 /// ```
743 /// use pdfrum_doc::ap::FormFonts;
744 /// use pdfrum_object::{Dict, NoResolve};
745 ///
746 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
747 /// let mut ctx = pdfrum_page::BuildContext::new();
748 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
749 /// use pdfrum_doc::ap::TextFont;
750 ///
751 /// let font = fonts.face(b"Helv").expect("the fallback face");
752 /// // Thousandths of an em, for whatever `encode` wrote.
753 /// assert!(TextFont::char_width(font, u32::from('A')) > 0);
754 /// ```
755 #[must_use]
756 pub fn char_width(font: &pdfrum_font::Font, code: u32) -> i32 {
757 let charcode = char::from_u32(code)
758 .and_then(|ch| font.char_code_from_unicode(ch))
759 .unwrap_or_else(|| unmapped_code(font, code));
760 #[allow(clippy::cast_possible_truncation)]
761 {
762 font.char_width(charcode) as i32
763 }
764 }
765
766 /// The layout metrics a loaded font supplies.
767 ///
768 /// ```
769 /// use pdfrum_doc::ap::FormFonts;
770 /// use pdfrum_object::{Dict, NoResolve};
771 ///
772 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
773 /// let mut ctx = pdfrum_page::BuildContext::new();
774 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
775 /// use pdfrum_doc::ap::TextFont;
776 ///
777 /// let font = fonts.face(b"Helv").expect("the fallback face");
778 /// let width = |code: u32| TextFont::char_width(font, code);
779 /// let metrics = TextFont::metrics_of(font, &width);
780 /// assert!(metrics.ascent > metrics.descent);
781 /// ```
782 #[must_use]
783 pub fn metrics_of<'a>(
784 font: &'a pdfrum_font::Font,
785 width: &'a dyn Fn(u32) -> i32,
786 ) -> vt::Metrics<'a> {
787 vt::Metrics {
788 width,
789 ascent: font.type_ascent(),
790 descent: font.type_descent(),
791 }
792 }
793}
794
795/// One second face: the resource name it is filed under, the dictionary the
796/// appearance's `/Resources /Font` carries, and the loaded face itself.
797///
798/// A borrow of what [`FormFonts`] already holds. The three travel together
799/// because a generator needs all three to write one character — the alias for
800/// the `Tf`, the face for the width, and the dictionary so the name resolves
801/// when the stream is drawn.
802///
803/// ```
804/// use pdfrum_doc::ap::FormFonts;
805/// use pdfrum_object::{Dict, NoResolve};
806///
807/// // A catalog with no `/AcroForm` still yields the stock fallback face.
808/// let mut ctx = pdfrum_page::BuildContext::new();
809/// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
810/// use pdfrum_font::Charset;
811///
812/// // A second face for the characters the `/DA` font cannot write.
813/// if let Some(substitute) = fonts.substitute(Charset::ShiftJis) {
814/// // The alias is the `Tf` name and the key in the appearance's
815/// // own `/Resources /Font`.
816/// assert!(!substitute.alias.as_bytes().is_empty());
817/// }
818/// ```
819#[derive(Debug, Clone, Copy)]
820pub struct Substitute<'a> {
821 /// The `Tf` name, and the key in the appearance's font resources.
822 pub alias: &'a Name,
823 /// The font dictionary that key maps to.
824 pub dict: &'a Dict,
825 /// The loaded face, for widths and for the codes it can write.
826 pub font: &'a pdfrum_font::Font,
827}
828
829/// The faces a form's default resources name, loaded once for a page.
830///
831/// # Why the fonts are loaded rather than substituted for
832///
833/// A generator wants *metrics*, and it was tempting to hand every generator
834/// one stock Helvetica on the reasoning that a non-embedded `/DA` font
835/// substitutes to that face anyway. The metrics do not agree with that
836/// reasoning, and the disagreement is visible: an ascent and descent taken
837/// from the base-14 metric tables are 718 and −219, while the ones taken from
838/// the **substituted face** — the size the layout engine actually stacks lines
839/// by — are the face's own, and for the hermetic corpus's metric-compatible
840/// Helvetica that is 905 and −211. On a list box the difference is the row
841/// pitch: 11.24 units per row against 13.39, which is two extra rows in a
842/// thirty-unit box.
843///
844/// So the font a widget's `/DA` names is loaded from the form's `/DR /Font`,
845/// through the same loader and the same substitution options every other font
846/// on the page goes through. A name the resources do not carry gets a stock
847/// Helvetica, which is what the fallback is actually for.
848///
849/// ```
850/// use pdfrum_doc::ap::FormFonts;
851/// use pdfrum_object::{Dict, NoResolve};
852///
853/// // A catalog with no `/AcroForm` still yields the stock fallback face.
854/// let mut ctx = pdfrum_page::BuildContext::new();
855/// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
856///
857/// // A name the resources do not carry falls back rather than failing.
858/// assert!(fonts.face(b"NoSuchFace").is_some());
859/// ```
860pub struct FormFonts {
861 /// Resource name and the face loaded under it, in `/DR /Font` order with
862 /// the fallback last.
863 entries: Vec<(Name, pdfrum_font::Font)>,
864 /// The faces added for characters no declared font's charset covers, one
865 /// per charset, keyed by the alias they are filed under.
866 ///
867 /// These are not in `/DR`, and no `/DA` names one: they are added when a
868 /// field is asked to write a character its own font cannot, and they go
869 /// into the **appearance stream's** own `/Resources /Font` rather than the
870 /// form's. See [`font_map`].
871 substitutes: Vec<(Name, Dict, pdfrum_font::Font)>,
872}
873
874impl std::fmt::Debug for FormFonts {
875 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
876 f.debug_struct("FormFonts")
877 .field(
878 "names",
879 &self.entries.iter().map(|(n, _)| n).collect::<Vec<_>>(),
880 )
881 .finish_non_exhaustive()
882 }
883}
884
885impl FormFonts {
886 /// Loads every font a document's interactive form declares, plus the
887 /// fallback a name outside them resolves to.
888 ///
889 /// The fallback is loaded unconditionally and stored under an empty name,
890 /// so a `/DA` naming nothing — or naming a font the resources lack — still
891 /// has a face to measure with. That is the same substitution a viewer
892 /// performs; it is only the *metric source* that this fixes.
893 ///
894 /// # Memoized on the context
895 ///
896 /// The faces are a pure function of the form's `/DR /Font`, and building
897 /// them is expensive — the `/DR` walk constructs every font the form
898 /// declares, encoding tables and substitution ladder included. The
899 /// annotation overlay asks for them **once per page per render**, so the
900 /// result is cached in the [`BuildContext`](pdfrum_page::BuildContext)
901 /// beside the rest of the per-document font state. A caller threading one
902 /// context through many renders of one document pays for this once.
903 ///
904 /// Nothing about *what* is built changed when the cache was added, which
905 /// is what makes the appearance streams identical: the fallback still
906 /// goes through the same loader, and the second faces are still loaded
907 /// here rather than where a field discovers it needs one. Only the number
908 /// of times moved.
909 ///
910 /// # What the key names, and why it is not the `/AcroForm`
911 ///
912 /// Building the faces reads exactly one thing out of the document — the
913 /// `/AcroForm`'s `/DR /Font` dictionary — and takes everything else from
914 /// dictionaries written in this crate. So the *faces* are a function of
915 /// that dictionary alone, and keying on the `/AcroForm` instead threw
916 /// away every form written as a direct dictionary, which has no reference
917 /// to name it by.
918 ///
919 /// A direct `/AcroForm` is not the rarity it reads as. An empty
920 /// `<</Fields[]>>` is what a producer writes when it declares a form and
921 /// then puts no fields in it, and six of this corpus's 44 documents carry
922 /// one — none of them a form document. Every one of those was rebuilding
923 /// the fallback face and the substitute face once per page per render, for
924 /// a form with no fields, and on four of them it was the single largest
925 /// line in the render.
926 ///
927 /// ```
928 /// use pdfrum_doc::ap::FormFonts;
929 /// use pdfrum_object::{Dict, NoResolve};
930 ///
931 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
932 /// let mut ctx = pdfrum_page::BuildContext::new();
933 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
934 ///
935 /// // Loaded once per document and shared: a second load hits the cache.
936 /// let again = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
937 /// assert!(std::sync::Arc::ptr_eq(&fonts, &again));
938 /// ```
939 #[must_use]
940 pub fn load<R: Resolve>(
941 catalog: &Dict,
942 r: &R,
943 ctx: &mut pdfrum_page::BuildContext,
944 ) -> std::sync::Arc<FormFonts> {
945 ctx.form_fonts(FormFonts::key(catalog, r), |ctx| {
946 FormFonts::build(catalog, r, ctx)
947 })
948 }
949
950 /// The cache slot this catalog's faces belong in.
951 ///
952 /// Split out from [`Self::load`] so the four cases can be asserted
953 /// directly; the walk is `/AcroForm` then `/DR` then `/Font`, resolving
954 /// references at every step except the last, whose *spelling* is the
955 /// answer.
956 fn key<R: Resolve>(catalog: &Dict, r: &R) -> pdfrum_page::FormFontsKey {
957 match catalog.raw(names::ACRO_FORM) {
958 // A real form: the reference names it, as it names every other
959 // per-document cache on the context.
960 Some(Object::Ref(reference)) => return pdfrum_page::FormFontsKey::Form(*reference),
961 Some(_) => {}
962 // No form at all, so no `/DR /Font`: the constants alone.
963 None => return pdfrum_page::FormFontsKey::None,
964 }
965 // A direct `/AcroForm`. Its faces are its `/DR /Font`'s, so that is
966 // what has to be identified — the same walk `build` makes, stopping
967 // one step earlier and reading the spelling rather than the value.
968 let fonts = catalog
969 .dict(names::ACRO_FORM, r)
970 .and_then(|form| form.dict(names::DR, r))
971 .and_then(|resources| resources.raw(names::FONT).cloned());
972 match fonts {
973 // Written as a reference, which is the ordinary spelling even
974 // inside a direct form: as good an identity as the form's own.
975 Some(Object::Ref(reference)) => pdfrum_page::FormFontsKey::DirectResources(reference),
976 // Written out in full. Nothing to key on, so it is rebuilt.
977 Some(_) => pdfrum_page::FormFontsKey::Direct,
978 // No default resources: the constants alone, exactly as a
979 // document with no form at all.
980 None => pdfrum_page::FormFontsKey::None,
981 }
982 }
983
984 /// [`Self::load`] without the cache: the faces, built now.
985 #[must_use]
986 fn build<R: Resolve>(catalog: &Dict, r: &R, ctx: &mut pdfrum_page::BuildContext) -> FormFonts {
987 let (limits, mut diags) = (
988 pdfrum_common::Limits::default(),
989 pdfrum_common::Diagnostics::default(),
990 );
991 let mut load = |dict: &Dict| {
992 pdfrum_font::load_with_options(
993 dict,
994 r,
995 &ctx.fonts,
996 &ctx.substitution,
997 &limits,
998 &mut diags,
999 )
1000 };
1001
1002 let mut entries = Vec::new();
1003 let fonts = catalog
1004 .dict(names::ACRO_FORM, r)
1005 .and_then(|form| form.dict(names::DR, r))
1006 .and_then(|resources| resources.dict(names::FONT, r));
1007 if let Some(fonts) = fonts {
1008 for key in fonts.keys() {
1009 let Some(dict) = fonts.dict(key, r) else {
1010 continue;
1011 };
1012 if let Some(font) = load(&dict) {
1013 entries.push((key.clone(), font));
1014 }
1015 }
1016 }
1017 // The fallback goes through the **same loader**, not the stock-metrics
1018 // constructor: the point of this type is that the ascent and descent
1019 // come from the face that is actually substituted, and a font built
1020 // from the base-14 tables would answer 718 and −219 where the face
1021 // answers its own. A field with no `/DR` at all is exactly where that
1022 // shows, because there is nothing else for it to measure with.
1023 entries.extend(load(&freetext::fallback_font()).map(|font| (Name::new(Vec::new()), font)));
1024
1025 // The second faces, loaded here rather than where a field discovers it
1026 // needs one: loading needs the page's font cache, and the generators
1027 // are pure functions of the faces they are handed. There is one per
1028 // charset the font map can add for, which is one — see [`font_map`].
1029 let mut substitutes = Vec::new();
1030 for charset in font_map::SUBSTITUTABLE_CHARSETS {
1031 let Some(dict) = font_map::substitute_font_dict(*charset) else {
1032 continue;
1033 };
1034 if let Some(font) = load(&dict) {
1035 substitutes.push((Name::new(font_map::substitute_alias(*charset)), dict, font));
1036 }
1037 }
1038 FormFonts {
1039 entries,
1040 substitutes,
1041 }
1042 }
1043
1044 /// The second face a character of `charset` is written in, with the alias
1045 /// it is filed under and the dictionary that goes into the appearance's
1046 /// own resources.
1047 ///
1048 /// Answers nothing for a charset with no encoding table, and for one whose
1049 /// face would not load — in both cases the caller leaves the character to
1050 /// the `/DA` font, which is the behaviour that predates this.
1051 ///
1052 /// ```
1053 /// use pdfrum_doc::ap::FormFonts;
1054 /// use pdfrum_object::{Dict, NoResolve};
1055 ///
1056 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
1057 /// let mut ctx = pdfrum_page::BuildContext::new();
1058 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
1059 /// use pdfrum_font::Charset;
1060 ///
1061 /// // Nothing for a charset with no encoding table, or whose face will
1062 /// // not load: the caller then leaves the character to the `/DA` font.
1063 /// let _ = fonts.substitute(Charset::ShiftJis);
1064 /// ```
1065 #[must_use]
1066 pub fn substitute(&self, charset: pdfrum_font::Charset) -> Option<Substitute<'_>> {
1067 let alias = font_map::substitute_alias(charset);
1068 self.substitutes
1069 .iter()
1070 .find(|(name, _, _)| name.as_bytes() == alias)
1071 .map(|(name, dict, font)| Substitute {
1072 alias: name,
1073 dict,
1074 font,
1075 })
1076 }
1077
1078 /// The face filed under one resource name, or the fallback.
1079 ///
1080 /// Answers nothing only if the fallback itself is missing, which
1081 /// [`Self::load`] makes impossible — the caller then generates chrome
1082 /// alone rather than being told a face exists that does not.
1083 ///
1084 /// ```
1085 /// use pdfrum_doc::ap::FormFonts;
1086 /// use pdfrum_object::{Dict, NoResolve};
1087 ///
1088 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
1089 /// let mut ctx = pdfrum_page::BuildContext::new();
1090 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
1091 ///
1092 /// assert!(fonts.face(b"Helv").is_some());
1093 /// // Answers the fallback rather than nothing for an unknown name.
1094 /// assert!(fonts.face(b"NoSuchFace").is_some());
1095 /// ```
1096 #[must_use]
1097 pub fn face(&self, name: &[u8]) -> Option<&pdfrum_font::Font> {
1098 self.entries
1099 .iter()
1100 .find(|(key, _)| key.as_bytes() == name)
1101 .or_else(|| self.entries.last())
1102 .map(|(_, font)| font)
1103 }
1104
1105 /// A [`TextFont`] over one resource name, with `width` borrowed for the
1106 /// same lifetime.
1107 ///
1108 /// The width closure cannot live inside the returned value — it has to be
1109 /// borrowed for the font's lifetime, which a closure over `self` cannot
1110 /// supply before `self` exists — so the caller keeps it and passes it in,
1111 /// the same shape [`TextFont::metrics_of`] already has.
1112 ///
1113 /// ```
1114 /// use pdfrum_doc::ap::FormFonts;
1115 /// use pdfrum_object::{Dict, NoResolve};
1116 ///
1117 /// // A catalog with no `/AcroForm` still yields the stock fallback face.
1118 /// let mut ctx = pdfrum_page::BuildContext::new();
1119 /// let fonts = FormFonts::load(&Dict::default(), &NoResolve, &mut ctx);
1120 /// use pdfrum_doc::ap::TextFont;
1121 ///
1122 /// // The width closure is borrowed for the font's lifetime, so the
1123 /// // caller keeps it and passes it in.
1124 /// let font = fonts.face(b"Helv").expect("the fallback face");
1125 /// let width = |code: u32| TextFont::char_width(font, code);
1126 /// assert!(fonts.text_font(b"Helv", &width).is_some());
1127 /// ```
1128 #[must_use]
1129 pub fn text_font<'a>(
1130 &'a self,
1131 name: &[u8],
1132 width: &'a dyn Fn(u32) -> i32,
1133 ) -> Option<TextFont<'a>> {
1134 let font = self.face(name)?;
1135 Some(TextFont {
1136 metrics: TextFont::metrics_of(font, width),
1137 font,
1138 })
1139 }
1140}
1141
1142/// Generates appearances for every annotation on a page that wants one.
1143///
1144/// The walk mirrors what a viewer does when it opens a page, because that
1145/// ordering is what the `--annot` contract describes: pop-ups written into
1146/// the file are skipped, everything else is offered to its generator, and the
1147/// results are keyed by position in `/Annots`.
1148///
1149/// The text-bearing generators are skipped here; [`generate_appearances_with_text`]
1150/// is the walk that enables them.
1151///
1152/// ```
1153/// use pdfrum_common::Diagnostics;
1154/// use pdfrum_doc::ap::generate_appearances;
1155/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
1156///
1157/// let square = Dict::from_pairs([
1158/// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
1159/// (
1160/// Name::from("Rect"),
1161/// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
1162/// ),
1163/// (
1164/// Name::from("IC"),
1165/// Object::Array(Array::of([1, 0, 0].map(Object::from))),
1166/// ),
1167/// ]);
1168/// let page = Dict::from_pairs([(
1169/// Name::from("Annots"),
1170/// Object::Array(Array::of([Object::Dict(square)])),
1171/// )]);
1172///
1173/// let mut diags = Diagnostics::default();
1174/// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
1175///
1176/// // One entry per `/Annots` index; the square got a stream.
1177/// assert_eq!(overlay.len(), 1);
1178/// assert!(!overlay.get(0).expect("generated").stream.is_empty());
1179/// ```
1180#[must_use]
1181pub fn generate_appearances<R: Resolve>(
1182 page: &Dict,
1183 r: &R,
1184 diags: &mut Diagnostics,
1185) -> AnnotOverlay {
1186 let Some(annots) = page.array(obj_names::ANNOTS, r) else {
1187 return AnnotOverlay::default();
1188 };
1189 let mut overlay = AnnotOverlay::with_capacity(annots.len());
1190 for index in 0..annots.len() {
1191 let Some(dict) = annots.dict_at(index, r) else {
1192 continue;
1193 };
1194 if crate::annot::is_popup(&dict, r) {
1195 continue;
1196 }
1197 if let Some(generated) = generate_one(&dict, r, diags) {
1198 overlay.set(index, generated);
1199 } else if let Some(generated) = widget::generate(&dict, r) {
1200 // A widget with no appearance dictionary gets its chrome built
1201 // when the page opens, whatever the form says about regenerating
1202 // appearances. See `widget` for how far that goes.
1203 diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
1204 overlay.set(index, generated);
1205 }
1206 }
1207 overlay
1208}
1209
1210/// The same walk, with the text-bearing generators enabled.
1211///
1212/// The generators only produce an appearance when a font is in hand, so a
1213/// caller without one gets the same result as [`generate_appearances`].
1214///
1215/// Each annotation is measured with the face **its own** `/DA` names, looked
1216/// up in the form's default resources — not with one page-wide font. A page
1217/// whose fields name two different faces stacks their lines by two different
1218/// ascents, which is what a viewer does.
1219///
1220/// ```
1221/// use pdfrum_common::Diagnostics;
1222/// use pdfrum_doc::ap::generate_appearances;
1223/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
1224///
1225/// let square = Dict::from_pairs([
1226/// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
1227/// (
1228/// Name::from("Rect"),
1229/// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
1230/// ),
1231/// (
1232/// Name::from("IC"),
1233/// Object::Array(Array::of([1, 0, 0].map(Object::from))),
1234/// ),
1235/// ]);
1236/// let page = Dict::from_pairs([(
1237/// Name::from("Annots"),
1238/// Object::Array(Array::of([Object::Dict(square)])),
1239/// )]);
1240///
1241/// let mut diags = Diagnostics::default();
1242/// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
1243/// use pdfrum_doc::ap::generate_appearances_with_text;
1244///
1245/// let catalog = Dict::default();
1246/// // With no fonts in hand the text-bearing generators stay off, so the
1247/// // result matches the plain walk.
1248/// let mut diags = Diagnostics::default();
1249/// let with_text =
1250/// generate_appearances_with_text(&page, &catalog, None, &NoResolve, &mut diags);
1251/// assert_eq!(with_text.get(0), overlay.get(0));
1252/// ```
1253#[must_use]
1254pub fn generate_appearances_with_text<R: Resolve>(
1255 page: &Dict,
1256 catalog: &Dict,
1257 fonts: Option<&FormFonts>,
1258 r: &R,
1259 diags: &mut Diagnostics,
1260) -> AnnotOverlay {
1261 let Some(annots) = page.array(obj_names::ANNOTS, r) else {
1262 return AnnotOverlay::default();
1263 };
1264 let mut overlay = AnnotOverlay::with_capacity(annots.len());
1265 for index in 0..annots.len() {
1266 let Some(dict) = annots.dict_at(index, r) else {
1267 continue;
1268 };
1269 if crate::annot::is_popup(&dict, r) {
1270 continue;
1271 }
1272 // The width closure has to outlive the `TextFont` that borrows it, so
1273 // it is built here rather than inside the lookup.
1274 let named = fonts.and_then(|fonts| fonts.face(&font_name_of(&dict, catalog, r)));
1275 // A second face, for the characters this one's charset does not cover.
1276 // The **widths** have to know about it as well as the bytes: a run set
1277 // in two faces advances by two faces' metrics, and measuring it all
1278 // with the first gives a line the wrong length wherever the second one
1279 // writes. So the substitute enters through the width closure the
1280 // layout is built from, not only through the encoder.
1281 let da_charset = named.map_or(pdfrum_font::Charset::Ansi, font_map::font_charset);
1282 let substitute = fonts.and_then(|fonts| {
1283 font_map::SUBSTITUTABLE_CHARSETS
1284 .iter()
1285 .find(|charset| **charset != da_charset)
1286 .and_then(|charset| fonts.substitute(*charset))
1287 });
1288 let width = named.map(|font| {
1289 move |code: u32| match substitute {
1290 Some(sub) if !font_map::da_font_writes(font, da_charset, code) => {
1291 font_map::substitute_width(sub.font, code)
1292 }
1293 _ => TextFont::char_width(font, code),
1294 }
1295 });
1296 let text_font = named.zip(width.as_ref()).map(|(font, width)| TextFont {
1297 metrics: TextFont::metrics_of(font, width),
1298 font,
1299 });
1300 let generated = generate_one(&dict, r, diags)
1301 .or_else(|| generate_text_bearing(&dict, catalog, text_font.as_ref(), r, diags))
1302 .or_else(|| {
1303 // A widget's own body needs the same font the free-text
1304 // generator wanted, so a caller with one gets the field's
1305 // value laid out and a caller without one gets the chrome
1306 // alone.
1307 match text_font.as_ref() {
1308 Some(font) => widget::generate_with_text(&dict, catalog, font, substitute, r),
1309 None => widget::generate(&dict, r),
1310 }
1311 .inspect(|_| {
1312 diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
1313 })
1314 });
1315 if let Some(generated) = generated {
1316 overlay.set(index, generated);
1317 }
1318 }
1319 overlay
1320}
1321
1322/// The `/DR /Font` resource name one annotation's default appearance names.
1323///
1324/// Falls back to the form's own `/DA`, then to nothing — and nothing resolves
1325/// to [`FormFonts`]'s fallback face rather than declining.
1326fn font_name_of<R: Resolve>(dict: &Dict, catalog: &Dict, r: &R) -> Vec<u8> {
1327 let form = catalog.dict(names::ACRO_FORM, r).unwrap_or_default();
1328 freetext::default_appearance(dict, &form, r)
1329 .map(|appearance| appearance.font_name)
1330 .unwrap_or_default()
1331}
1332
1333/// The free-text generator, when its preconditions and a font allow.
1334fn generate_text_bearing<R: Resolve>(
1335 dict: &Dict,
1336 catalog: &Dict,
1337 text_font: Option<&TextFont<'_>>,
1338 r: &R,
1339 diags: &mut Diagnostics,
1340) -> Option<GeneratedAp> {
1341 if !should_generate(dict, r) {
1342 return None;
1343 }
1344 let subtype = Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
1345 if subtype != Subtype::FreeText {
1346 return None;
1347 }
1348 let font = text_font?;
1349 let generated = freetext::free_text(
1350 dict,
1351 catalog,
1352 r,
1353 &font.metrics,
1354 &|code| font.encode(code),
1355 diags,
1356 )?;
1357 diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
1358 Some(GeneratedAp {
1359 stream: generated.stream,
1360 bbox: dict.rect(obj_names::RECT, r),
1361 matrix: Affine::IDENTITY,
1362 resources: resources_dict(
1363 ext_gstate_dict(dict, false, r),
1364 generated.font_resources.clone(),
1365 ),
1366 rect_override: None,
1367 as_override: None,
1368 })
1369}
1370
1371/// Generates one annotation's appearance, if it should have one.
1372#[must_use]
1373pub(crate) fn generate_one<R: Resolve>(
1374 dict: &Dict,
1375 r: &R,
1376 diags: &mut Diagnostics,
1377) -> Option<GeneratedAp> {
1378 if !should_generate(dict, r) {
1379 return None;
1380 }
1381 let subtype = Subtype::from_bytes(&dict.byte_string(obj_names::SUBTYPE, r).unwrap_or_default());
1382 let generated = match subtype {
1383 Subtype::Circle => markup::circle(dict, r),
1384 Subtype::Highlight => markup::highlight(dict, r),
1385 Subtype::Ink => markup::ink(dict, r, diags)?,
1386 Subtype::Square => markup::square(dict, r),
1387 Subtype::Squiggly => markup::squiggly(dict, r),
1388 Subtype::StrikeOut => markup::strike_out(dict, r),
1389 Subtype::Text => markup::text(dict, r),
1390 Subtype::Underline => markup::underline(dict, r),
1391 // Everything else has no generator. The two text-bearing subtypes —
1392 // free text and pop-ups — do have one upstream, but it needs the
1393 // layout engine and is built on top of this dispatch rather than
1394 // inside it, so they answer the same way here.
1395 _ => return None,
1396 };
1397 diags.record(Severity::Recovered, DiagKind::AppearanceGenerated, None);
1398
1399 // The bounding box is the annotation's rectangle **as it stands after
1400 // this generator ran** — so a sticky note's is the 20×20 box it just
1401 // produced, not the one the file declared.
1402 let rect = generated
1403 .rect_override
1404 .unwrap_or_else(|| dict.rect(obj_names::RECT, r));
1405 let bbox = if generated.is_text_markup {
1406 quad::bounding_rect_from_quad_points(dict.array(names::QUAD_POINTS, r).as_ref())
1407 } else {
1408 rect
1409 };
1410 Some(GeneratedAp {
1411 stream: generated.stream,
1412 bbox,
1413 matrix: Affine::IDENTITY,
1414 resources: resources_dict(
1415 ext_gstate_dict(dict, generated.blend_multiply, r),
1416 generated.font_resources.clone(),
1417 ),
1418 rect_override: generated.rect_override,
1419 as_override: None,
1420 })
1421}
1422
1423/// Whether an annotation is eligible for a generated appearance.
1424///
1425/// Two gates. A **dictionary-valued** `/AP /N` suppresses generation, and a
1426/// stream answers as its own dictionary — so the common "it already has an
1427/// appearance" case and the multi-state checkbox case are the same test. And
1428/// a hidden annotation never generates.
1429#[must_use]
1430pub(crate) fn should_generate<R: Resolve>(dict: &Dict, r: &R) -> bool {
1431 if appearance::has_appearance(dict, r) {
1432 return false;
1433 }
1434 let flags = crate::annot::AnnotFlags::from_bits(dict.int(names::F, r).unwrap_or(0));
1435 !flags.is_hidden()
1436}
1437
1438/// The graphics-state dictionary a generated appearance names.
1439///
1440/// Both alphas take the annotation's `/CA` when the key is present, whatever
1441/// its type reads as, and one otherwise. Only the highlight generator asks
1442/// for a blend mode other than normal.
1443#[must_use]
1444pub(crate) fn ext_gstate_dict<R: Resolve>(dict: &Dict, multiply: bool, r: &R) -> Dict {
1445 let opacity = if dict.contains_key(names::CA) {
1446 dict.number(names::CA, r).unwrap_or(0.0)
1447 } else {
1448 1.0
1449 };
1450 let blend = if multiply {
1451 names::MULTIPLY
1452 } else {
1453 obj_names::NORMAL
1454 };
1455 let state = Dict::from_pairs([
1456 (
1457 obj_names::TYPE.clone(),
1458 Object::Name(names::EXT_G_STATE.clone()),
1459 ),
1460 (names::CA.clone(), Object::Real(opacity)),
1461 (names::CA_LOWER.clone(), Object::Real(opacity)),
1462 (names::AIS.clone(), Object::Bool(false)),
1463 (names::BM.clone(), Object::Name(blend.clone())),
1464 ]);
1465 Dict::from_pairs([(names::GS.clone(), Object::Dict(state))])
1466}
1467
1468/// The appearance stream's `/Resources`, omitting either half when absent.
1469#[must_use]
1470pub(crate) fn resources_dict(ext_gstate: Dict, font: Option<Dict>) -> Dict {
1471 let mut resources = Dict::new();
1472 resources.push(names::EXT_G_STATE.clone(), Object::Dict(ext_gstate));
1473 if let Some(font) = font {
1474 resources.push(names::FONT.clone(), Object::Dict(font));
1475 }
1476 resources
1477}
1478
1479/// The stream dictionary a generated appearance is stored under.
1480///
1481/// ```
1482/// use pdfrum_common::Diagnostics;
1483/// use pdfrum_doc::ap::generate_appearances;
1484/// use pdfrum_object::{Array, Dict, Name, NoResolve, Object};
1485///
1486/// let square = Dict::from_pairs([
1487/// (Name::from("Subtype"), Object::Name(Name::from("Square"))),
1488/// (
1489/// Name::from("Rect"),
1490/// Object::Array(Array::of([0, 0, 100, 50].map(Object::from))),
1491/// ),
1492/// (
1493/// Name::from("IC"),
1494/// Object::Array(Array::of([1, 0, 0].map(Object::from))),
1495/// ),
1496/// ]);
1497/// let page = Dict::from_pairs([(
1498/// Name::from("Annots"),
1499/// Object::Array(Array::of([Object::Dict(square)])),
1500/// )]);
1501///
1502/// let mut diags = Diagnostics::default();
1503/// let overlay = generate_appearances(&page, &NoResolve, &mut diags);
1504/// use pdfrum_doc::ap::stream_dict;
1505/// use pdfrum_object::names;
1506///
1507/// let dict = stream_dict(overlay.get(0).expect("generated"));
1508/// assert_eq!(dict.name(names::SUBTYPE).map(|n| n.as_bytes().to_vec()),
1509/// Some(b"Form".to_vec()));
1510/// ```
1511#[must_use]
1512pub fn stream_dict(generated: &GeneratedAp) -> Dict {
1513 Dict::from_pairs([
1514 (names::FORM_TYPE.clone(), Object::Int(1)),
1515 (
1516 obj_names::TYPE.clone(),
1517 Object::Name(names::XOBJECT.clone()),
1518 ),
1519 (
1520 obj_names::SUBTYPE.clone(),
1521 Object::Name(names::FORM.clone()),
1522 ),
1523 (
1524 names::MATRIX.clone(),
1525 Object::Array(matrix_array(generated.matrix)),
1526 ),
1527 (
1528 names::BBOX.clone(),
1529 Object::Array(rect_array(generated.bbox)),
1530 ),
1531 (
1532 names::RESOURCES.clone(),
1533 Object::Dict(generated.resources.clone()),
1534 ),
1535 (
1536 names::LENGTH.clone(),
1537 Object::Int(i64::try_from(generated.stream.len()).unwrap_or(0)),
1538 ),
1539 ])
1540}
1541
1542/// A transform as its six numbers.
1543///
1544/// Narrowed to single precision because that is what a PDF real is; the
1545/// transforms these generators write are all exactly representable anyway.
1546#[allow(clippy::cast_possible_truncation)]
1547fn matrix_array(matrix: Affine) -> Array {
1548 Array::of(
1549 matrix
1550 .as_coeffs()
1551 .into_iter()
1552 .map(|value| Object::Real(value as f32)),
1553 )
1554}
1555
1556/// A rectangle as its four corner numbers, in PDF's ordering.
1557fn rect_array(rect: Rect) -> Array {
1558 use crate::geom;
1559 Array::of(
1560 [
1561 geom::left(rect),
1562 geom::bottom(rect),
1563 geom::right(rect),
1564 geom::top(rect),
1565 ]
1566 .map(Object::Real),
1567 )
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::{
1573 AnnotOverlay, Appearance, GeneratedAp, ext_gstate_dict, generate_appearances, generate_one,
1574 should_generate,
1575 };
1576 use crate::geom;
1577 use pdfrum_common::Diagnostics;
1578 use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Resolve, Stream};
1579
1580 fn dict(pairs: &[(&str, Object)]) -> Dict {
1581 Dict::from_pairs(
1582 pairs
1583 .iter()
1584 .map(|(k, v)| (Name::from(*k), v.clone()))
1585 .collect::<Vec<_>>(),
1586 )
1587 }
1588
1589 fn numbers(values: &[f32]) -> Object {
1590 Object::Array(Array::of(values.iter().copied().map(Object::from)))
1591 }
1592
1593 /// A map-backed [`Resolve`], for the key tests: what a font dictionary
1594 /// *resolves to* is irrelevant to the slot it is filed under, but the
1595 /// walk to it goes through `/AcroForm` and `/DR`, so the references on
1596 /// the way have to lead somewhere.
1597 struct Store(std::collections::HashMap<u32, std::sync::Arc<Object>>);
1598
1599 impl Store {
1600 fn of(pairs: impl IntoIterator<Item = (u32, Object)>) -> Store {
1601 Store(
1602 pairs
1603 .into_iter()
1604 .map(|(num, obj)| (num, std::sync::Arc::new(obj)))
1605 .collect(),
1606 )
1607 }
1608 }
1609
1610 impl Resolve for Store {
1611 fn fetch(
1612 &self,
1613 r: pdfrum_object::ObjRef,
1614 ) -> Result<std::sync::Arc<Object>, pdfrum_object::Error> {
1615 self.0
1616 .get(&r.num)
1617 .map(std::sync::Arc::clone)
1618 .ok_or(pdfrum_object::Error::UnresolvedRef(r))
1619 }
1620 }
1621
1622 fn reference(num: u32) -> Object {
1623 Object::Ref(pdfrum_object::ObjRef::new(num, 0))
1624 }
1625
1626 fn sticky_note() -> Dict {
1627 dict(&[
1628 ("Subtype", Object::Name(Name::from("Text"))),
1629 ("Rect", numbers(&[10.0, 20.0, 200.0, 300.0])),
1630 ])
1631 }
1632
1633 #[test]
1634 fn an_annotation_with_an_appearance_stream_generates_nothing() {
1635 let with_ap = dict(&[
1636 ("Subtype", Object::Name(Name::from("Text"))),
1637 (
1638 "AP",
1639 Object::Dict(dict(&[(
1640 "N",
1641 Object::Stream(Box::new(Stream::new(
1642 Dict::new(),
1643 ByteSpan::from(b"x".to_vec()),
1644 ))),
1645 )])),
1646 ),
1647 ]);
1648 assert!(!should_generate(&with_ap, &NoResolve));
1649 assert!(should_generate(&sticky_note(), &NoResolve));
1650 }
1651
1652 #[test]
1653 fn a_hidden_annotation_never_generates() {
1654 let mut hidden = sticky_note();
1655 hidden.push(Name::from("F"), Object::Int(2));
1656 assert!(!should_generate(&hidden, &NoResolve));
1657 }
1658
1659 #[test]
1660 fn a_character_the_face_cannot_map_is_still_written() {
1661 // `bug_725389` shows three Hebrew characters through a `/DA` naming
1662 // Times-Roman, which has no glyph for any of them. Dropping them loses
1663 // the text objects entirely — six become three — where the oracle
1664 // writes the raw code point as a character code and draws whatever it
1665 // names. Wrong glyph, right object count, right layout.
1666 let font = pdfrum_font::Font::load_standard(
1667 pdfrum_font::StandardFont::Times,
1668 &pdfrum_font::FontCache::new(),
1669 );
1670 let width = |code: u32| super::TextFont::char_width(&font, code);
1671 let text = super::TextFont {
1672 metrics: super::TextFont::metrics_of(&font, &width),
1673 font: &font,
1674 };
1675 // Hebrew bet, which no standard Latin face encodes.
1676 assert_eq!(text.encode(0x05D1), vec![0xD1]);
1677 // And a character it does encode still round-trips through the
1678 // `ToUnicode` mapping rather than through the fallthrough.
1679 assert_eq!(text.encode(u32::from('A')), vec![b'A']);
1680 // The width follows whatever `encode` wrote, so the layout advances by
1681 // the same glyph the stream names.
1682 assert_eq!(
1683 super::TextFont::char_width(&font, 0x05D1),
1684 super::TextFont::char_width(&font, 0xD1)
1685 );
1686 }
1687
1688 #[test]
1689 fn a_sticky_notes_rectangle_override_reaches_the_overlay() {
1690 let page = dict(&[(
1691 "Annots",
1692 Object::Array(Array::of([Object::Dict(sticky_note())])),
1693 )]);
1694 let mut diags = Diagnostics::default();
1695 let overlay = generate_appearances(&page, &NoResolve, &mut diags);
1696 assert_eq!(overlay.len(), 1);
1697 let raw = geom::rect(10.0, 20.0, 200.0, 300.0);
1698 assert_eq!(overlay.rect(0, raw), geom::rect(10.0, 20.0, 30.0, 40.0));
1699 // The bounding box follows the rewritten rectangle, not the file's.
1700 assert_eq!(
1701 overlay.get(0).map(|generated| generated.bbox),
1702 Some(geom::rect(10.0, 20.0, 30.0, 40.0))
1703 );
1704 }
1705
1706 #[test]
1707 fn a_pop_up_written_into_the_file_is_skipped_by_the_walk() {
1708 let page = dict(&[(
1709 "Annots",
1710 Object::Array(Array::of([Object::Dict(dict(&[(
1711 "Subtype",
1712 Object::Name(Name::from("Popup")),
1713 )]))])),
1714 )]);
1715 let mut diags = Diagnostics::default();
1716 let overlay = generate_appearances(&page, &NoResolve, &mut diags);
1717 // The slot still exists — indices stay aligned with `/Annots` — but
1718 // nothing was generated into it.
1719 assert_eq!(overlay.len(), 1);
1720 assert!(overlay.get(0).is_none());
1721 }
1722
1723 #[test]
1724 fn a_subtype_with_no_generator_produces_nothing() {
1725 let stamp = dict(&[("Subtype", Object::Name(Name::from("Stamp")))]);
1726 let mut diags = Diagnostics::default();
1727 assert!(generate_one(&stamp, &NoResolve, &mut diags).is_none());
1728 }
1729
1730 #[test]
1731 fn a_text_markup_bounding_box_comes_from_the_quadrilaterals() {
1732 let highlight = dict(&[
1733 ("Subtype", Object::Name(Name::from("Highlight"))),
1734 ("Rect", numbers(&[0.0, 0.0, 5.0, 5.0])),
1735 (
1736 "QuadPoints",
1737 numbers(&[10.0, 20.0, 30.0, 20.0, 10.0, 10.0, 30.0, 10.0]),
1738 ),
1739 ]);
1740 let mut diags = Diagnostics::default();
1741 let got = generate_one(&highlight, &NoResolve, &mut diags).expect("generates");
1742 assert_eq!(got.bbox, geom::rect(10.0, 10.0, 30.0, 20.0));
1743 }
1744
1745 #[test]
1746 fn the_graphics_state_takes_its_alpha_from_the_opacity_key() {
1747 let opaque = ext_gstate_dict(&Dict::new(), false, &NoResolve);
1748 let state = opaque
1749 .dict(&Name::from("GS"), &NoResolve)
1750 .expect("one entry");
1751 assert_eq!(state.number(&Name::from("CA"), &NoResolve), Some(1.0));
1752 assert_eq!(
1753 state.name(&Name::from("BM")).map(Name::as_bytes),
1754 Some(&b"Normal"[..])
1755 );
1756
1757 let half = dict(&[("CA", Object::from(0.5_f32))]);
1758 let state = ext_gstate_dict(&half, true, &NoResolve)
1759 .dict(&Name::from("GS"), &NoResolve)
1760 .expect("one entry");
1761 assert_eq!(state.number(&Name::from("ca"), &NoResolve), Some(0.5));
1762 assert_eq!(
1763 state.name(&Name::from("BM")).map(Name::as_bytes),
1764 Some(&b"Multiply"[..])
1765 );
1766 }
1767
1768 /// A generated appearance, distinguishable by its stream.
1769 fn made(stream: &str) -> GeneratedAp {
1770 GeneratedAp {
1771 stream: stream.as_bytes().to_vec(),
1772 bbox: geom::rect(0.0, 0.0, 1.0, 1.0),
1773 matrix: kurbo::Affine::IDENTITY,
1774 resources: Dict::default(),
1775 rect_override: None,
1776 as_override: None,
1777 }
1778 }
1779
1780 /// The merge's whole contract in one test: the supplied overlay wins
1781 /// where it speaks, defers where it does not, and can say "draw nothing"
1782 /// as a value rather than as an absence.
1783 #[test]
1784 fn a_supplied_overlay_wins_only_where_it_has_something_to_say() {
1785 let mut base = AnnotOverlay::with_capacity(4);
1786 base.set(0, made("base zero"));
1787 base.set(1, made("base one"));
1788 base.set(2, made("base two"));
1789
1790 let mut supplied = AnnotOverlay::with_capacity(4);
1791 supplied.set(1, made("live one"));
1792 supplied.set_appearance(2, Appearance::Suppressed);
1793 // Index 0 and 3 are untouched and must not disturb the base.
1794
1795 base.merge_over(&supplied);
1796
1797 assert_eq!(
1798 base.get(0).map(|g| g.stream.clone()),
1799 Some(b"base zero".to_vec()),
1800 "an untouched entry leaves the generated one alone"
1801 );
1802 assert_eq!(
1803 base.get(1).map(|g| g.stream.clone()),
1804 Some(b"live one".to_vec()),
1805 "a supplied entry replaces the generated one"
1806 );
1807 assert_eq!(
1808 base.appearance(2),
1809 &Appearance::Suppressed,
1810 "suppression survives the merge as a value"
1811 );
1812 assert_eq!(base.get(2), None, "a suppressed entry has no stream");
1813 assert_eq!(base.appearance(3), &Appearance::Untouched);
1814 }
1815
1816 /// Suppression and absence read the same to `get` and differently to
1817 /// `appearance` — which is the distinction the enum exists to carry.
1818 #[test]
1819 fn suppressed_and_untouched_differ_only_where_it_matters() {
1820 let mut overlay = AnnotOverlay::with_capacity(2);
1821 overlay.set_appearance(0, Appearance::Suppressed);
1822 assert_eq!(overlay.get(0), None);
1823 assert_eq!(overlay.get(1), None);
1824 assert_ne!(overlay.appearance(0), overlay.appearance(1));
1825 // An index past the end is untouched rather than a panic, so a short
1826 // overlay is safe to consult for any annotation.
1827 assert_eq!(overlay.appearance(99), &Appearance::Untouched);
1828 }
1829
1830 /// An entry past the end of the overlay being merged into is dropped:
1831 /// there is no annotation for it to apply to.
1832 #[test]
1833 fn a_supplied_entry_past_the_end_is_dropped() {
1834 let mut base = AnnotOverlay::with_capacity(1);
1835 let mut supplied = AnnotOverlay::with_capacity(5);
1836 supplied.set(4, made("nowhere"));
1837 base.merge_over(&supplied);
1838 assert_eq!(base.len(), 1);
1839 assert_eq!(base.get(4), None);
1840 }
1841
1842 #[test]
1843 fn only_the_named_annotation_is_a_live_edit() {
1844 let mut overlay = AnnotOverlay::with_capacity(3);
1845 overlay.set(0, made("a"));
1846 overlay.set(1, made("b"));
1847 assert_eq!(overlay.live_edit(), None);
1848 assert!(!overlay.is_live_edit(0));
1849 overlay.set_live_edit(1);
1850 assert_eq!(overlay.live_edit(), Some(1));
1851 assert!(overlay.is_live_edit(1));
1852 // Every other annotation's appearance is an ordinary one, which is
1853 // what keeps ClearType off the rest of the page.
1854 assert!(!overlay.is_live_edit(0));
1855 assert!(!overlay.is_live_edit(2));
1856 }
1857
1858 #[test]
1859 fn a_session_editing_a_second_field_replaces_the_first() {
1860 // One field is edited at a time, so this is a replacement rather than
1861 // a set: a stale mark would draw a field's committed text with
1862 // ClearType long after the editor left it.
1863 let mut overlay = AnnotOverlay::with_capacity(3);
1864 overlay.set_live_edit(0);
1865 overlay.set_live_edit(2);
1866 assert_eq!(overlay.live_edit(), Some(2));
1867 assert!(!overlay.is_live_edit(0));
1868 }
1869
1870 #[test]
1871 fn merging_carries_the_live_edit_mark_over() {
1872 // The mark has to survive `merge_over` or it would be lost exactly
1873 // where it matters — the supplied overlay is the session's, and the
1874 // base is what the annotation pass generated.
1875 let mut base = AnnotOverlay::with_capacity(2);
1876 let mut supplied = AnnotOverlay::with_capacity(2);
1877 supplied.set(1, made("edited"));
1878 supplied.set_live_edit(1);
1879 base.merge_over(&supplied);
1880 assert!(base.is_live_edit(1));
1881 // And a merge that says nothing about it leaves the mark alone.
1882 let untouched = AnnotOverlay::with_capacity(2);
1883 base.merge_over(&untouched);
1884 assert!(base.is_live_edit(1));
1885 }
1886
1887 #[test]
1888 fn the_form_faces_are_built_once_per_document_and_shared() {
1889 // `FormFonts::load` builds `/DR` fonts, the fallback, and the
1890 // synthesized second faces once per document; the annotation overlay
1891 // calls it once per page per render.
1892 let catalog = dict(&[("AcroForm", Object::Ref(pdfrum_object::ObjRef::new(7, 0)))]);
1893 let mut ctx = pdfrum_page::BuildContext::new();
1894 let first = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
1895 let second = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
1896 assert!(
1897 std::sync::Arc::ptr_eq(&first, &second),
1898 "a second load of one document's form must hit the cache"
1899 );
1900 }
1901
1902 #[test]
1903 fn a_catalog_with_no_form_shares_one_set_of_faces() {
1904 // The case that made this a whole-corpus regression rather than a
1905 // forms one: a document with an annotation but no `/AcroForm` paid
1906 // the fallback load and the substitute synthesis on every render.
1907 // With no form there is nothing document-specific to build, so one
1908 // slot serves every such document a context is threaded through.
1909 let mut ctx = pdfrum_page::BuildContext::new();
1910 let first = super::FormFonts::load(&dict(&[]), &NoResolve, &mut ctx);
1911 let second = super::FormFonts::load(
1912 &dict(&[("Type", Object::Name(Name::from("Catalog")))]),
1913 &NoResolve,
1914 &mut ctx,
1915 );
1916 assert!(std::sync::Arc::ptr_eq(&first, &second));
1917 }
1918
1919 #[test]
1920 fn two_documents_do_not_share_one_contexts_form_faces() {
1921 // A `BuildContext` may legitimately be threaded through two
1922 // documents, so the cache keys on the `/AcroForm` reference the way
1923 // every other cache on it keys on the reference that named its value.
1924 let mut ctx = pdfrum_page::BuildContext::new();
1925 let one = super::FormFonts::load(
1926 &dict(&[("AcroForm", Object::Ref(pdfrum_object::ObjRef::new(7, 0)))]),
1927 &NoResolve,
1928 &mut ctx,
1929 );
1930 let two = super::FormFonts::load(
1931 &dict(&[("AcroForm", Object::Ref(pdfrum_object::ObjRef::new(8, 0)))]),
1932 &NoResolve,
1933 &mut ctx,
1934 );
1935 assert!(!std::sync::Arc::ptr_eq(&one, &two));
1936 }
1937
1938 /// A `/DR /Font` written out in full, which is the one spelling with no
1939 /// reference anywhere for the key to name.
1940 fn wholly_direct_form() -> Dict {
1941 dict(&[(
1942 "DR",
1943 Object::Dict(dict(&[(
1944 "Font",
1945 Object::Dict(dict(&[("Helv", Object::Dict(dict(&[])))])),
1946 )])),
1947 )])
1948 }
1949
1950 #[test]
1951 fn a_form_whose_fonts_are_written_out_in_full_is_not_cached() {
1952 // The one case that stays uncached: a direct `/AcroForm` whose
1953 // `/DR /Font` is itself direct has no reference at either level, and
1954 // its content *is* document-specific, so it re-derives rather than
1955 // risking one document's faces standing in for another's.
1956 let catalog = dict(&[("AcroForm", Object::Dict(wholly_direct_form()))]);
1957 let mut ctx = pdfrum_page::BuildContext::new();
1958 let first = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
1959 let second = super::FormFonts::load(&catalog, &NoResolve, &mut ctx);
1960 assert!(!std::sync::Arc::ptr_eq(&first, &second));
1961 }
1962
1963 #[test]
1964 fn a_direct_form_declaring_no_fonts_is_the_no_form_case() {
1965 // What the corpus actually carries. `<</Fields[]>>` written straight
1966 // into the catalog is what a producer emits when it declares a form
1967 // and puts no fields in it, and six of the 44 benchmark documents
1968 // have one — none of them a form document. It names no `/DR /Font`,
1969 // so its faces are the fallback and the substitutes, built from
1970 // dictionaries this crate writes: the same value a catalog with no
1971 // `/AcroForm` at all gets, and therefore the same slot.
1972 let mut ctx = pdfrum_page::BuildContext::new();
1973 let empty_form = super::FormFonts::load(
1974 &dict(&[(
1975 "AcroForm",
1976 Object::Dict(dict(&[("Fields", Object::Array(Array::default()))])),
1977 )]),
1978 &NoResolve,
1979 &mut ctx,
1980 );
1981 let no_form = super::FormFonts::load(&dict(&[]), &NoResolve, &mut ctx);
1982 assert!(
1983 std::sync::Arc::ptr_eq(&empty_form, &no_form),
1984 "a form with no default resources builds nothing a form-less \
1985 catalog does not"
1986 );
1987 }
1988
1989 #[test]
1990 fn a_direct_form_is_keyed_on_the_font_dictionary_it_names() {
1991 // The ordinary spelling of an unusual case: the `/AcroForm` is
1992 // direct but its `/DR /Font` is a reference, which is as good an
1993 // identity as the form's own reference would have been. Two catalogs
1994 // naming *different* font dictionaries must not share, and one
1995 // catalog asked twice must.
1996 let form = |num: u32| {
1997 dict(&[(
1998 "AcroForm",
1999 Object::Dict(dict(&[(
2000 "DR",
2001 Object::Dict(dict(&[("Font", reference(num))])),
2002 )])),
2003 )])
2004 };
2005 let store = Store::of([(7, Object::Dict(dict(&[]))), (8, Object::Dict(dict(&[])))]);
2006 let mut ctx = pdfrum_page::BuildContext::new();
2007 let first = super::FormFonts::load(&form(7), &store, &mut ctx);
2008 let again = super::FormFonts::load(&form(7), &store, &mut ctx);
2009 let other = super::FormFonts::load(&form(8), &store, &mut ctx);
2010 assert!(
2011 std::sync::Arc::ptr_eq(&first, &again),
2012 "one font dictionary asked twice must hit the cache"
2013 );
2014 assert!(
2015 !std::sync::Arc::ptr_eq(&first, &other),
2016 "two font dictionaries must not share a slot"
2017 );
2018 }
2019
2020 #[test]
2021 fn the_key_reads_the_font_dictionarys_spelling_and_not_its_value() {
2022 // The four cases, asserted directly rather than through the ptr
2023 // identity the three tests above compare. This is the function's
2024 // whole contract: which of the four slots a catalog lands in.
2025 use pdfrum_page::FormFontsKey;
2026 let store = Store::of([(7, Object::Dict(dict(&[])))]);
2027 let key = |catalog: &Dict| super::FormFonts::key(catalog, &store);
2028
2029 assert_eq!(key(&dict(&[])), FormFontsKey::None);
2030 assert_eq!(
2031 key(&dict(&[("AcroForm", reference(7))])),
2032 FormFontsKey::Form(pdfrum_object::ObjRef::new(7, 0))
2033 );
2034 assert_eq!(
2035 key(&dict(&[(
2036 "AcroForm",
2037 Object::Dict(dict(&[("Fields", Object::Array(Array::default()))]))
2038 )])),
2039 FormFontsKey::None,
2040 "a direct form with no `/DR /Font` depends on nothing"
2041 );
2042 assert_eq!(
2043 key(&dict(&[(
2044 "AcroForm",
2045 Object::Dict(dict(&[(
2046 "DR",
2047 Object::Dict(dict(&[("Font", reference(7))]))
2048 )]))
2049 )])),
2050 FormFontsKey::DirectResources(pdfrum_object::ObjRef::new(7, 0))
2051 );
2052 assert_eq!(
2053 key(&dict(&[("AcroForm", Object::Dict(wholly_direct_form()))])),
2054 FormFontsKey::Direct
2055 );
2056 }
2057
2058 #[test]
2059 fn an_overlay_that_marks_no_live_edit_leaves_every_index_ordinary() {
2060 // The default, and the whole corpus outside the form-events rows.
2061 let mut overlay = AnnotOverlay::with_capacity(4);
2062 overlay.set(2, made("generated"));
2063 assert_eq!(overlay.live_edit(), None);
2064 for index in 0..6 {
2065 assert!(!overlay.is_live_edit(index), "index {index}");
2066 }
2067 }
2068}