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