Skip to main content

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}