Skip to main content

pdfrum_edit/
outline.rs

1//! The document outline (ISO 32000-1 §12.3.3) — bookmarks — on the write
2//! side.
3//!
4//! The reader hands back a flat list carrying a depth, because that is what a
5//! caller walking bookmarks wants. Writing takes the same shape: a caller
6//! describes the tree as a list of items with depths and this builds the
7//! doubly-linked `/First` `/Last` `/Next` `/Prev` `/Parent` structure a PDF
8//! actually holds, which is tedious and easy to get subtly wrong by hand.
9//!
10//! Until this existed, nothing in the writer touched `/Outlines` at all: a
11//! merge or a split dropped every bookmark in the document silently.
12
13use pdfrum_object::{Array, Dict, Name, ObjRef, Object, PdfString, Resolve, encode_text};
14
15use crate::names;
16
17use crate::{annot::AnnotGoToView, doc::EditDoc, error::Error};
18
19/// An outline write either applies or names why it could not.
20type Result<T> = core::result::Result<T, Error>;
21
22/// Where a bookmark sends the reader.
23#[derive(Debug, Clone, PartialEq)]
24pub enum BookmarkTarget {
25    /// A page in this document, with a view — written as `/Dest`.
26    Page {
27        /// The page's object reference.
28        page: ObjRef,
29        /// How to display it.
30        view: AnnotGoToView,
31    },
32    /// A destination the document names in `/Names /Dests` — written as a
33    /// `/Dest` naming the string, which the reader resolves through the name
34    /// tree.
35    Named(String),
36    /// Nothing: a heading that groups its children without jumping anywhere.
37    None,
38}
39
40/// One bookmark, as a caller describes it.
41///
42/// `depth` is what the reader's [`Bookmark::depth`](pdfrum_doc::nav::Bookmark)
43/// answers: zero is a top-level item, and an item deeper than the one before
44/// it is that item's child. A depth that jumps by more than one is clamped to
45/// one deeper, since there is no honest tree that shape describes.
46#[derive(Debug, Clone, PartialEq)]
47pub struct BookmarkSpec {
48    /// The visible text, written as `/Title`.
49    pub title: String,
50    /// How deep the item sits; zero is top level.
51    pub depth: usize,
52    /// Where it points.
53    pub target: BookmarkTarget,
54    /// `/C`, the item's colour as three components in `[0, 1]`.
55    pub color: Option<(f32, f32, f32)>,
56    /// `/F` style bits: 1 italic, 2 bold.
57    pub style: Option<i64>,
58    /// Whether the item shows its children when the outline opens.
59    ///
60    /// A closed item writes a negative `/Count`, which is how a reader tells
61    /// "collapsed" from "has no children".
62    pub open: bool,
63}
64
65impl BookmarkSpec {
66    /// A top-level bookmark pointing nowhere.
67    #[must_use]
68    pub fn new(title: impl Into<String>) -> Self {
69        Self {
70            title: title.into(),
71            depth: 0,
72            target: BookmarkTarget::None,
73            color: None,
74            style: None,
75            open: true,
76        }
77    }
78
79    /// Sets how deep the item sits; zero is top level.
80    #[must_use]
81    pub fn depth(mut self, depth: usize) -> Self {
82        self.depth = depth;
83        self
84    }
85
86    /// Points the item at a page.
87    #[must_use]
88    pub fn page(mut self, page: ObjRef, view: AnnotGoToView) -> Self {
89        self.target = BookmarkTarget::Page { page, view };
90        self
91    }
92
93    /// Points the item at a name the document already carries.
94    #[must_use]
95    pub fn named(mut self, name: impl Into<String>) -> Self {
96        self.target = BookmarkTarget::Named(name.into());
97        self
98    }
99
100    /// Sets `/C`, the item's colour.
101    #[must_use]
102    pub fn color(mut self, rgb: (f32, f32, f32)) -> Self {
103        self.color = Some(rgb);
104        self
105    }
106
107    /// Sets `/F`: 1 italic, 2 bold.
108    #[must_use]
109    pub fn style(mut self, style: i64) -> Self {
110        self.style = Some(style);
111        self
112    }
113
114    /// Whether the item's children show when the outline opens.
115    #[must_use]
116    pub fn open(mut self, open: bool) -> Self {
117        self.open = open;
118        self
119    }
120}
121
122/// Replaces the document's outline with `items`.
123///
124/// The list is a pre-order walk: each item's `depth` places it under the
125/// nearest earlier item one level shallower, exactly as
126/// [`Document::outline`](pdfrum_doc) reports it. An empty list removes the
127/// outline.
128///
129/// `/Count` is written the way a reader expects: a **positive** count of
130/// visible descendants on an open item, the same count **negated** on a
131/// closed one, and no entry at all on a leaf. The catalog's `/Outlines` gets
132/// the total of its open descendants.
133///
134/// # Errors
135///
136/// [`Error::NoDestinationCatalog`](crate::Error::NoDestinationCatalog) when the document has no catalog to hold
137/// the outline.
138///
139/// ```
140/// use std::sync::Arc;
141/// use pdfrum_edit::{BookmarkSpec, EditDoc, SaveOptions, save, set_outline};
142/// use pdfrum_parser::{LoadOptions, load};
143///
144/// let bytes: Arc<[u8]> = Arc::from(&include_bytes!("../tests/files/hello.pdf")[..]);
145/// let doc = load(bytes, &LoadOptions::default())?;
146/// let mut edit = EditDoc::new(&doc);
147/// set_outline(
148///     &mut edit,
149///     &[
150///         BookmarkSpec::new("Chapter 1"),
151///         BookmarkSpec::new("Section 1.1").depth(1),
152///         BookmarkSpec::new("Chapter 2"),
153///     ],
154/// )?;
155/// # Ok::<(), Box<dyn std::error::Error>>(())
156/// ```
157pub fn set_outline(dest: &mut EditDoc<'_>, items: &[BookmarkSpec]) -> Result<()> {
158    let Some(root) = dest.base().trailer().reference(names::ROOT) else {
159        return Err(Error::NoDestinationCatalog);
160    };
161    let Some(mut catalog) = dest
162        .fetch(root)
163        .ok()
164        .and_then(|object| object.as_dict().cloned())
165    else {
166        return Err(Error::NoDestinationCatalog);
167    };
168
169    if items.is_empty() {
170        catalog.remove(names::OUTLINES);
171        dest.replace(root, Object::Dict(catalog));
172        return Ok(());
173    }
174
175    // Reserve every object first: an item's `/Parent`, `/Next` and `/Prev`
176    // name objects that do not exist yet, so the references have to be in
177    // hand before any dictionary can be filled in.
178    let refs: Vec<ObjRef> = items
179        .iter()
180        .map(|_| dest.add(Object::Dict(Dict::new())))
181        .collect();
182    let outlines_ref = dest.add(Object::Dict(Dict::new()));
183
184    let tree = Tree::of(items);
185    // `refs` and `tree.nodes` are both built from `items`, so every index
186    // below is in range — `get` rather than `[]` so the code says so rather
187    // than relying on the reader to check.
188    let reference_of = |index: usize| refs.get(index).copied();
189    for (index, item) in items.iter().enumerate() {
190        let (Some(node), Some(self_ref)) = (tree.nodes.get(index), reference_of(index)) else {
191            continue;
192        };
193        let dict = item_dict(item, node, index, &tree, items, outlines_ref, reference_of);
194        dest.replace(self_ref, Object::Dict(dict));
195    }
196
197    let mut outlines = Dict::new();
198    outlines.insert(names::TYPE.clone(), Object::Name(Name::from("Outlines")));
199    if let (Some(first), Some(last)) = (
200        tree.roots.first().copied().and_then(reference_of),
201        tree.roots.last().copied().and_then(reference_of),
202    ) {
203        outlines.insert(names::FIRST.clone(), Object::Ref(first));
204        outlines.insert(names::LAST.clone(), Object::Ref(last));
205    }
206    outlines.insert(names::COUNT.clone(), Object::Int(tree.visible_total(items)));
207    dest.replace(outlines_ref, Object::Dict(outlines));
208
209    catalog.insert(names::OUTLINES.clone(), Object::Ref(outlines_ref));
210    dest.replace(root, Object::Dict(catalog));
211    Ok(())
212}
213
214/// One item's place in the tree, by index into the caller's list.
215#[derive(Default)]
216struct Node {
217    parent: Option<usize>,
218    prev: Option<usize>,
219    next: Option<usize>,
220    first: Option<usize>,
221    last: Option<usize>,
222    children: Vec<usize>,
223}
224
225/// The links a flat depth-tagged list implies.
226struct Tree {
227    nodes: Vec<Node>,
228    roots: Vec<usize>,
229}
230
231impl Tree {
232    /// Derives parent, sibling and child links from the items' depths.
233    ///
234    /// A depth that jumps by more than one is treated as one deeper than the
235    /// item before it: there is no tree in which a child is three levels below
236    /// its parent, and guessing an intermediate is worse than flattening.
237    fn of(items: &[BookmarkSpec]) -> Self {
238        let mut nodes: Vec<Node> = (0..items.len()).map(|_| Node::default()).collect();
239        let mut roots = Vec::new();
240        // The item most recently seen at each depth, so a new item can find
241        // its parent and its previous sibling in one pass.
242        let mut at_depth: Vec<usize> = Vec::new();
243
244        for (index, item) in items.iter().enumerate() {
245            let depth = item.depth.min(at_depth.len());
246            at_depth.truncate(depth);
247
248            // `link` records a sibling pair in both directions; every index
249            // here came from this same walk, so a miss is impossible and
250            // silently skipping one is the safe reading of `get_mut`.
251            let link = |prev: usize, next: usize, nodes: &mut Vec<Node>| {
252                if let Some(node) = nodes.get_mut(next) {
253                    node.prev = Some(prev);
254                }
255                if let Some(node) = nodes.get_mut(prev) {
256                    node.next = Some(next);
257                }
258            };
259
260            if depth == 0 {
261                if let Some(&prev) = roots.last() {
262                    link(prev, index, &mut nodes);
263                }
264                roots.push(index);
265            } else if let Some(&parent) = at_depth.get(depth - 1) {
266                if let Some(node) = nodes.get_mut(index) {
267                    node.parent = Some(parent);
268                }
269                let last_child = nodes.get(parent).and_then(|p| p.children.last().copied());
270                if let Some(prev) = last_child {
271                    link(prev, index, &mut nodes);
272                }
273                if let Some(node) = nodes.get_mut(parent) {
274                    node.children.push(index);
275                }
276            }
277            at_depth.push(index);
278        }
279
280        for node in &mut nodes {
281            node.first = node.children.first().copied();
282            node.last = node.children.last().copied();
283        }
284        Self { nodes, roots }
285    }
286
287    /// How many descendants of `index` a reader would show — children, plus
288    /// the descendants of each open child.
289    fn visible_descendants(&self, index: usize, items: &[BookmarkSpec]) -> i64 {
290        let mut total = 0;
291        let Some(node) = self.nodes.get(index) else {
292            return 0;
293        };
294        for &child in &node.children {
295            total += 1;
296            if items.get(child).is_some_and(|item| item.open) {
297                total += self.visible_descendants(child, items);
298            }
299        }
300        total
301    }
302
303    /// The same count for the outline as a whole.
304    fn visible_total(&self, items: &[BookmarkSpec]) -> i64 {
305        let mut total = 0;
306        for &root in &self.roots {
307            total += 1;
308            if items.get(root).is_some_and(|item| item.open) {
309                total += self.visible_descendants(root, items);
310            }
311        }
312        total
313    }
314}
315
316/// One outline item's dictionary: its title, its links to the items around
317/// it, and whatever presentation the caller asked for.
318fn item_dict(
319    item: &BookmarkSpec,
320    node: &Node,
321    index: usize,
322    tree: &Tree,
323    items: &[BookmarkSpec],
324    outlines_ref: ObjRef,
325    reference_of: impl Fn(usize) -> Option<ObjRef>,
326) -> Dict {
327    let mut dict = Dict::new();
328    dict.insert(
329        names::TITLE.clone(),
330        Object::Str(PdfString::literal(encode_text(&item.title))),
331    );
332    dict.insert(
333        names::PARENT.clone(),
334        Object::Ref(node.parent.and_then(&reference_of).unwrap_or(outlines_ref)),
335    );
336    if let Some(prev) = node.prev.and_then(&reference_of) {
337        dict.insert(names::PREV.clone(), Object::Ref(prev));
338    }
339    if let Some(next) = node.next.and_then(&reference_of) {
340        dict.insert(names::NEXT.clone(), Object::Ref(next));
341    }
342    if let (Some(first), Some(last)) = (
343        node.first.and_then(&reference_of),
344        node.last.and_then(&reference_of),
345    ) {
346        dict.insert(names::FIRST.clone(), Object::Ref(first));
347        dict.insert(names::LAST.clone(), Object::Ref(last));
348        // A leaf writes no `/Count` at all; only an item with children states
349        // one, negated when it is closed.
350        let visible = tree.visible_descendants(index, items);
351        dict.insert(
352            names::COUNT.clone(),
353            Object::Int(if item.open { visible } else { -visible }),
354        );
355    }
356    match &item.target {
357        BookmarkTarget::Page { page, view } => {
358            dict.insert(
359                names::DEST.clone(),
360                Object::Array(crate::annot::goto_dest_array(*page, *view)),
361            );
362        }
363        BookmarkTarget::Named(name) => {
364            dict.insert(
365                names::DEST.clone(),
366                Object::Str(PdfString::literal(name.as_bytes())),
367            );
368        }
369        BookmarkTarget::None => {}
370    }
371    if let Some((r, g, b)) = item.color {
372        dict.insert(
373            names::C.clone(),
374            Object::Array(Array::of([
375                Object::Real(r.clamp(0.0, 1.0)),
376                Object::Real(g.clamp(0.0, 1.0)),
377                Object::Real(b.clamp(0.0, 1.0)),
378            ])),
379        );
380    }
381    if let Some(style) = item.style {
382        dict.insert(names::F.clone(), Object::Int(style));
383    }
384    dict
385}