oxideav_pdf/outline.rs
1//! Round-25: PDF outline (bookmark) tree + Link annotation specs.
2//!
3//! Writer-side scaffolding for ISO 32000-1 §12.3.3 (Document Outline)
4//! and §12.5.6.5 (Link Annotations). The reader-side parsers live in
5//! [`crate::reader::outline`] and [`crate::reader::link`]. Together
6//! they round-trip the bookmark tree + the per-page link annotation
7//! list a typical "PDF report with hyperlinks" carries.
8//!
9//! Both spec sections share a common notion: an *explicit destination*
10//! is `[ page-ref /Mode … ]` per ISO 32000-1 §12.3.2.2 Table 151. The
11//! [`OutlineDestination`] enum below covers the subset that doesn't
12//! depend on viewer-side magnification preferences (so `/XYZ`, `/Fit`,
13//! `/FitH`, `/FitR`) — the most common in practice and the ones a
14//! reader-side round-trip can observe directly. The remaining `/FitV`,
15//! `/FitB`, `/FitBH`, `/FitBV` shapes serialise / deserialise too via
16//! the same lowering machinery.
17//!
18//! No types from outside this crate are needed — the writer functions
19//! accept page indices (0-based, matching the order of
20//! [`oxideav_scene::Scene::pages`]) and resolve them to the writer's
21//! per-page object ids internally.
22
23/// One node in the writer-side outline (bookmark) tree.
24///
25/// Mirrors ISO 32000-1 Table 153 (Outline item dictionary) — but only
26/// the entries the writer needs at construction time:
27///
28/// * `title` — the user-visible text (`/Title`).
29/// * `destination` — what the conforming reader navigates to when the
30/// bookmark is activated (`/Dest`).
31/// * `children` — nested outline items, recursively. The writer is
32/// responsible for materialising the spec's doubly-linked
33/// `/First` / `/Last` / `/Next` / `/Prev` shape from this tree.
34/// * `open` — the spec's "open / closed" sign on `/Count`. When
35/// `true`, descendants are visible at document open; when `false`,
36/// they're hidden behind the disclosure triangle. Defaults to
37/// `false` so trees stay compact in the conforming reader's UI.
38///
39/// The optional appearance entries `/C` (text colour) and `/F` (style
40/// flags — bold / italic) are deferred to a follow-up round.
41#[derive(Clone, Debug)]
42pub struct OutlineSpec {
43 pub title: String,
44 pub destination: OutlineDestination,
45 pub children: Vec<OutlineSpec>,
46 pub open: bool,
47}
48
49impl OutlineSpec {
50 /// Convenience constructor — a leaf bookmark with the default
51 /// (`Fit`-page) destination on the requested 0-based page index.
52 pub fn page(title: impl Into<String>, page_index: usize) -> Self {
53 Self {
54 title: title.into(),
55 destination: OutlineDestination::Fit { page_index },
56 children: Vec::new(),
57 open: false,
58 }
59 }
60
61 /// Builder-style child-attach. Returns `self` for chaining.
62 pub fn with_child(mut self, child: OutlineSpec) -> Self {
63 self.children.push(child);
64 self
65 }
66}
67
68/// One PDF *explicit destination* (ISO 32000-1 §12.3.2.2 Table 151).
69///
70/// Every variant carries the 0-based page index in the
71/// [`oxideav_scene::Scene::pages`] vector — the writer rewrites it to
72/// the matching `<n> 0 R` page reference at emission time.
73///
74/// The four `Fit*` magnification variants whose syntax doesn't depend
75/// on geometry (`Fit`, `FitB`) are unparameterised; the four that do
76/// take their geometry parameters in default user-space units (no
77/// transform pre-applied) per the spec.
78#[derive(Clone, Debug, PartialEq)]
79pub enum OutlineDestination {
80 /// `[ page /XYZ left top zoom ]` — top-left corner at `(left,
81 /// top)`, page magnified by `zoom`. `None` means "retain current
82 /// viewer setting" per Table 151. A zoom of `Some(0.0)` is
83 /// equivalent to `None` per the spec.
84 Xyz {
85 page_index: usize,
86 left: Option<f32>,
87 top: Option<f32>,
88 zoom: Option<f32>,
89 },
90 /// `[ page /Fit ]` — entire page in window.
91 Fit { page_index: usize },
92 /// `[ page /FitH top ]` — fit page width, vertical at `top`.
93 FitH { page_index: usize, top: Option<f32> },
94 /// `[ page /FitV left ]` — fit page height, horizontal at `left`.
95 FitV {
96 page_index: usize,
97 left: Option<f32>,
98 },
99 /// `[ page /FitR left bottom right top ]` — fit a specific rect.
100 FitR {
101 page_index: usize,
102 left: f32,
103 bottom: f32,
104 right: f32,
105 top: f32,
106 },
107 /// `[ page /FitB ]` — fit page bounding box (PDF 1.1).
108 FitB { page_index: usize },
109 /// `[ page /FitBH top ]` — fit page bbox width, vertical at `top`.
110 FitBH { page_index: usize, top: Option<f32> },
111 /// `[ page /FitBV left ]` — fit page bbox height, horizontal at
112 /// `left`.
113 FitBV {
114 page_index: usize,
115 left: Option<f32>,
116 },
117}
118
119impl OutlineDestination {
120 /// The 0-based page index this destination refers to.
121 pub fn page_index(&self) -> usize {
122 match *self {
123 Self::Xyz { page_index, .. }
124 | Self::Fit { page_index }
125 | Self::FitH { page_index, .. }
126 | Self::FitV { page_index, .. }
127 | Self::FitR { page_index, .. }
128 | Self::FitB { page_index }
129 | Self::FitBH { page_index, .. }
130 | Self::FitBV { page_index, .. } => page_index,
131 }
132 }
133}
134
135/// One Link annotation spec (ISO 32000-1 §12.5.6.5 Table 173).
136///
137/// The annotation's clickable area is a single rectangle in default
138/// user-space coordinates (no `QuadPoints` support for this round —
139/// `QuadPoints` lands when the next round wires text annotations).
140/// The action is either an internal go-to destination (`/Dest`, by
141/// far the more common form for "link to another page in the same
142/// document") or an external URI (`/A << /S /URI /URI (...) >>`).
143#[derive(Clone, Debug)]
144pub struct LinkAnnotationSpec {
145 /// 0-based index of the page this annotation lives on.
146 pub source_page_index: usize,
147 /// `/Rect [ llx lly urx ury ]` — clickable bounding rectangle, in
148 /// default user space (no transform applied). PDF coordinates,
149 /// origin bottom-left.
150 pub rect: [f32; 4],
151 /// Where the link goes. `Internal` lowers to `/Dest`; `Uri`
152 /// lowers to `/A << /S /URI /URI (...) >>`.
153 pub target: LinkTarget,
154}
155
156/// Where a [`LinkAnnotationSpec`] points.
157#[derive(Clone, Debug)]
158pub enum LinkTarget {
159 /// In-document jump — go-to destination per §12.3.2.
160 Internal(OutlineDestination),
161 /// External URI per §12.6.4.7 (URI Action). Stored as a raw byte
162 /// string; the writer literal-string-encodes it (so `(`/`)` /
163 /// `\` escape correctly).
164 Uri(String),
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn outline_page_helper_builds_fit_leaf() {
173 let leaf = OutlineSpec::page("Chapter 1", 3);
174 assert_eq!(leaf.title, "Chapter 1");
175 assert_eq!(leaf.destination, OutlineDestination::Fit { page_index: 3 });
176 assert!(leaf.children.is_empty());
177 assert!(!leaf.open);
178 }
179
180 #[test]
181 fn outline_with_child_chains() {
182 let tree = OutlineSpec::page("Root", 0)
183 .with_child(OutlineSpec::page("Sub A", 1))
184 .with_child(OutlineSpec::page("Sub B", 2));
185 assert_eq!(tree.children.len(), 2);
186 assert_eq!(tree.children[0].title, "Sub A");
187 }
188
189 #[test]
190 fn destination_page_index_threads_through_every_variant() {
191 assert_eq!(OutlineDestination::Fit { page_index: 7 }.page_index(), 7);
192 assert_eq!(
193 OutlineDestination::Xyz {
194 page_index: 4,
195 left: Some(0.0),
196 top: Some(800.0),
197 zoom: None
198 }
199 .page_index(),
200 4
201 );
202 assert_eq!(
203 OutlineDestination::FitR {
204 page_index: 2,
205 left: 0.0,
206 bottom: 0.0,
207 right: 100.0,
208 top: 100.0,
209 }
210 .page_index(),
211 2
212 );
213 }
214}