Repository: fschutt/printpdf (language: rust)

Issue:
Add link annotations to other pages
Does printpdf support adding links between pages of the same, generated PDF? I've been using the library to make a new PDF template for the [Supernote Nomad](https://supernote.com/products/supernote-nomad) and realized that I'm not sure how to create hyperlinks to connect pages together.

The template idea for an eink tablet is to let you jump between days, weeks, and months like a calendar. You do this by generating a page per day/week/month and creating hyperlinks to connect between them.

I've got a link annotation working for something external like `https://example.com/`, but not sure if there's either

1. A link convention like `#page=2` that a PDF reader will understand or
2. Some other way to create internal links

It seems like the only defined action is the URI, so not sure if I can use that or do something else.

---

I think what I want is a [`GoTo` action](https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=426).

<img width="755" alt="image" src="https://github.com/user-attachments/assets/585871b4-7655-427a-b2f8-630a97adce68">



Discussion / hints:
From my understanding, there are a couple of different types of destinations, which is denoted with the `/D`. For explicit destinations, the main thing we need is the page id, which seems to be a `(u32, u16)` that we don't calculate until saving the PDF to bytes.

This means that in order to add explicit destination annotations, we have to store the internal id used by printpdf and then translate that during `save_to_bytes()` invocations into the actual page id of `(u32, u16)`, which I think we can get from the `page_id_to_obj` hashmap.

---

Skimming through the code for `save_to_bytes()`, the `Actions` instance is converted to a `lopdf::creator::Object` via the `Into<Object> for Actions` implementation. This probably needs to be changed to accept some sort of context when creating the object since we need to be able to translate an internal `PdfPageIndex` into an actual `(u32, u16)`.

In addition, the page objects are created **after** the annotations have been converted into `lopdf::creator::Object`, meaning that the annotations wouldn't have access to a mapping of internal indexes to PDF references. To remedy that, we'd need to create the page objects in advance using `doc.inner_doc.new_object_id()`. Then, instead of creating the object later via `doc.inner_doc.add_object(p)`, we'd use `doc.inner_doc.set_object(id, p)` to swap out the empty object with the actual version.

TLDR, we pre-allocate the page ids in advance so we can reference them when converting annotations into objects since they need to have a reference to the page mapping when doing explicit destinations.

Fix patch:
diff --git a/src/link_annotation.rs b/src/link_annotation.rs
index cd93ee6..66c343e 100644
--- a/src/link_annotation.rs
+++ b/src/link_annotation.rs
@@ -1,4 +1,4 @@
-use crate::Rect;
+use crate::{PdfPageIndex, Rect};
 use lopdf::{self, Dictionary, Object};
 use std::collections::HashMap;
 
@@ -28,10 +28,8 @@ impl LinkAnnotation {
             h: h.unwrap_or_default(),
         }
     }
-}
 
-impl Into<Object> for LinkAnnotation {
-    fn into(self) -> Object {
+    pub fn into_object(self, ctx: AnnotationContext) -> Object {
         let mut dict = Dictionary::new();
         dict.set("Type", Object::Name("Annot".as_bytes().to_vec()));
         dict.set("Subtype", Object::Name("Link".as_bytes().to_vec()));
@@ -45,7 +43,7 @@ impl Into<Object> for LinkAnnotation {
             ]),
         );
 
-        dict.set::<&str, Object>("A", self.a.into());
+        dict.set::<&str, Object>("A", self.a.into_object(ctx));
         dict.set::<&str, Object>("Border", self.border.into());
         dict.set::<&str, Object>("C", self.c.into());
         dict.set::<&str, Object>("H", self.h.into());
@@ -54,6 +52,11 @@ impl Into<Object> for LinkAnnotation {
     }
 }
 
+#[derive(Debug, Clone)]
+pub struct AnnotationContext<'a> {
+    pub page_id_to_obj: &'a HashMap<usize, (u32, u16)>,
+}
+
 #[derive(Debug, Clone)]
 pub enum BorderArray {
     Solid([f32; 3]),
@@ -139,28 +142,72 @@ impl Into<Object> for ColorArray {
 }
 
 #[derive(Debug, Clone)]
-pub struct Actions {
-    pub s: String,
-    pub uri: String,
+#[non_exhaustive]
+pub enum Destination {
+    /// Display `page` with coordinates `top` and `left` positioned at the upper-left corner of the
+    /// window and the contents of the page magnified by `zoom`.
+    ///
+    /// A value of `None` for any parameter indicates to leave the current value unchanged, and a
+    /// `zoom` value of 0 has the same meaning as `None`.
+    XYZ {
+        page: PdfPageIndex,
+        left: Option<f32>,
+        top: Option<f32>,
+        zoom: Option<f32>,
+    },
+}
+
+#[derive(Debug, Clone)]
+pub enum Actions {
+    GoTo(Destination),
+    URI(String),
 }
 
 impl Actions {
+    pub fn go_to(destination: Destination) -> Self {
+        Self::GoTo(destination)
+    }
+
     pub fn uri(uri: String) -> Self {
-        Self {
-            s: "URI".to_string(),
-            uri,
-        }
+        Self::URI(uri)
     }
-}
 
-impl Into<Object> for Actions {
-    fn into(self) -> Object {
+    pub fn into_object(self, ctx: AnnotationContext) -> Object {
         let mut dict = Dictionary::new();
-        dict.set("S", Object::Name(self.s.into_bytes().to_vec()));
-        dict.set(
-            "URI",
-            Object::String(self.uri.into_bytes().to_vec(), lopdf::StringFormat::Literal),
-        );
+        match self {
+            Self::GoTo(destination) => {
+                dict.set("S", Object::Name("GoTo".as_bytes().to_vec()));
+                dict.set(
+                    "D",
+                    match destination {
+                        Destination::XYZ {
+                            page,
+                            left,
+                            top,
+                            zoom,
+                        } => Object::Array(vec![
+                            Object::Reference(
+                                ctx.page_id_to_obj
+                                    .get(&page.0)
+                                    .expect("page index should be valid object")
+                                    .to_owned(),
+                            ),
+                            "XYZ".into(),
+                            left.map(Object::Real).unwrap_or(Object::Null),
+                            top.map(Object::Real).unwrap_or(Object::Null),
+                            zoom.map(Object::Real).unwrap_or(Object::Null),
+                        ]),
+                    },
+                );
+            }
+            Self::URI(uri) => {
+                dict.set("S", Object::Name("URI".as_bytes().to_vec()));
+                dict.set(
+                    "URI",
+                    Object::String(uri.into_bytes().to_vec(), lopdf::StringFormat::Literal),
+                );
+            }
+        }
         Object::Dictionary(dict)
     }
 }
diff --git a/src/pdf_document.rs b/src/pdf_document.rs
index 264622f..67c97e5 100644
--- a/src/pdf_document.rs
+++ b/src/pdf_document.rs
@@ -12,8 +12,8 @@ use lopdf;
 
 use crate::indices::*;
 use crate::{
-    BuiltinFont, DirectFontRef, Error, ExternalFont, Font, FontData, FontList, IccProfileList,
-    IndirectFontRef, Mm, PdfConformance, PdfMetadata, PdfPage, PdfPageReference,
+    AnnotationContext, BuiltinFont, DirectFontRef, Error, ExternalFont, Font, FontData, FontList,
+    IccProfileList, IndirectFontRef, Mm, PdfConformance, PdfMetadata, PdfPage, PdfPageReference,
 };
 
 /// PDF document
@@ -627,15 +627,31 @@ impl PdfDocumentReference {
             font_dict_id = Some(doc.inner_doc.add_object(Dictionary(fonts_dict)));
         }
 
+        // Mapping of a page's index to its object id.
+        //
+        // Pre-populate an object for each page, which we will replace with the
+        // actual object during the next iteration.
+        let mut page_index_to_object_id: HashMap<usize, (u32, u16)> = HashMap::new();
+        for index in doc.pages.iter().map(|page| page.index) {
+            page_index_to_object_id.insert(index, doc.inner_doc.new_object_id());
+        }
+
+        // Mapping of a page's ordered position in the doc to its object id.
         let mut page_id_to_obj: HashMap<usize, (u32, u16)> = HashMap::new();
 
         for (idx, page) in doc.pages.into_iter().enumerate() {
+            let page_index = page.index;
             let annotation_ids = page
                 .resources
                 .link_annotations
                 .clone()
                 .into_iter()
-                .map(|(_, annotation)| doc.inner_doc.add_object(annotation))
+                .map(|(_, annotation)| {
+                    doc.inner_doc
+                        .add_object(annotation.into_object(AnnotationContext {
+                            page_id_to_obj: &page_index_to_object_id,
+                        }))
+                })
                 .collect::<Vec<_>>();
 
             let mut p = LoDictionary::from_iter(vec![
@@ -695,11 +711,14 @@ impl PdfDocumentReference {
             let page_content_id = doc.inner_doc.add_object(merged_layer_stream);
 
             p.set("Contents", Reference(page_content_id));
-            let page_obj = doc.inner_doc.add_object(p);
+
+            let page_obj_id = page_index_to_object_id.get(&page_index).unwrap().to_owned();
+            doc.inner_doc.set_object(page_obj_id, p);
+
             if doc.bookmarks.contains_key(&idx) {
-                page_id_to_obj.insert(idx, page_obj);
+                page_id_to_obj.insert(idx, page_obj_id);
             }
-            page_ids.push(Reference(page_obj))
+            page_ids.push(Reference(page_obj_id))
         }
 
         if !doc.bookmarks.is_empty() {


Test patch: