collective

Browse / github.com/storytold/designcraft / patch

A hyperlink can point at a text anchor (#1446)

A hyperlink can point at a text anchor (#1446) Groundwork for the IDML half of #1446, and useful on its own. A hyperlink could point at a URL, an email address or **a page number**. A page number is the wrong thing to store for "this thumbnail links to that photo": the photo moves and the link stays behind. InDesign stores the destination itself, and so do IDML files — a `HyperlinkPageItemSource` pointing at a `HyperlinkTextDestination` is exactly how the report in #1446 links its photo thumbnails to the full-size pictures. `HyperlinkDest::Anchor(id)` is a text anchor: the same destination a cross-reference already targets, resolved the

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1555, open)
Base
3ce3a24f08d03935bc8cf7cb23755aca8065558c
Size
+118 −14 in 5 files
Review
pending
Shared
2026-10-28
Id
9d8f45753d575f7be36f883b

How it went

No other operator has reported using it yet.

Diff

raw · crates/doc/src/lib.rs crates/engine/src/cmd/datamerge/fill.rs crates/engine/src/cmd/datamerge/tests.rs crates/engine/src/cmd/interactive.rs crates/pdf/src/links.rs

diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs--- a/crates/doc/src/lib.rs+++ b/crates/doc/src/lib.rs@@ -714,6 +714,21 @@ pub enum HyperlinkDest {     Email(String),     /// Absolute page index.     Page(usize),+    /// A text anchor ([`TextAnchor::id`]), as a cross-reference targets one. The page is+    /// whichever page the anchor's text has landed on when the document is exported, so the link+    /// follows its destination as the text reflows (#1446).+    Anchor(u64),+}++impl HyperlinkDest {+    /// The name a hyperlink is given when none is supplied.+    pub fn label(&self) -> String {+        match self {+            HyperlinkDest::Url(u) | HyperlinkDest::Email(u) => u.clone(),+            HyperlinkDest::Page(p) => format!("Page {}", p + 1),+            HyperlinkDest::Anchor(a) => format!("Anchor {a}"),+        }+    } }  #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs--- a/crates/engine/src/cmd/datamerge/fill.rs+++ b/crates/engine/src/cmd/datamerge/fill.rs@@ -318,10 +318,7 @@ fn create_link(doc: &mut designcraft_doc::Document, anchor: PlaceholderAnchor, d         }     };     let id = doc.alloc();-    let name = match &dest {-        HyperlinkDest::Url(u) | HyperlinkDest::Email(u) => u.clone(),-        HyperlinkDest::Page(p) => format!("Page {}", p + 1),-    };+    let name = dest.label();     doc.hyperlinks.push(Hyperlink { id, name, source, dest });     true }diff --git a/crates/engine/src/cmd/datamerge/tests.rs b/crates/engine/src/cmd/datamerge/tests.rs--- a/crates/engine/src/cmd/datamerge/tests.rs+++ b/crates/engine/src/cmd/datamerge/tests.rs@@ -679,6 +679,7 @@ fn role_overrides_column_kind_qr_and_hyperlink() {             HyperlinkDest::Email(v) => format!("email:{v}"),             HyperlinkDest::Url(v) => format!("url:{v}"),             HyperlinkDest::Page(n) => format!("page:{n}"),+            HyperlinkDest::Anchor(a) => format!("anchor:{a}"),         });     }     kinds.sort();diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs--- a/crates/engine/src/cmd/interactive.rs+++ b/crates/engine/src/cmd/interactive.rs@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {             "New Hyperlink…",             ["Type", "Hyperlinks & Cross-References"],             None,-            "{url? | email? | page? (1-based), name?, ids?} — from the selected text, or the selected frames",+            "{url? | email? | page? (1-based) | anchor? (a text anchor's id or name; the link follows it as the text reflows), name?, ids?} — from the selected text, or the selected frames",             has_doc,             create         ),@@ -25,9 +25,9 @@ pub fn specs() -> Vec<CommandSpec> {             })         }),         cmd!(query "hyperlink.list", "Hyperlinks", [], None, "{}", has_doc, |s, _| Ok(serde_json::to_value(&s.doc()?.doc.hyperlinks).unwrap_or_default())),-        cmd!("hyperlink.edit", "Hyperlink Options…", [], None, "{id, name?, url? | email? | page? (1-based)}", has_doc, |s, p| {+        cmd!("hyperlink.edit", "Hyperlink Options…", [], None, "{id, name?, url? | email? | page? (1-based) | anchor?}", has_doc, |s, p| {             let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad("hyperlink.edit", "missing id"))?;-            let dest = dest_param(p);+            let dest = dest_param(&s.doc()?.doc, p);             let name = str_param(p, "name").map(str::to_string);             s.edit(|d, _| {                 let h = d.hyperlinks.iter_mut().find(|h| h.id == id).ok_or_else(|| bad("hyperlink.edit", format!("no hyperlink {id}")))?;@@ -90,19 +90,33 @@ pub fn specs() -> Vec<CommandSpec> {     ] } -fn dest_param(p: &Value) -> Option<HyperlinkDest> {+/// `url`, `email`, `page` (1-based) or `anchor` (a text anchor's id, or its name).+fn dest_param(d: &designcraft_doc::Document, p: &Value) -> Option<HyperlinkDest> {     if let Some(u) = str_param(p, "url") {         Some(HyperlinkDest::Url(u.to_string()))     } else if let Some(e) = str_param(p, "email") {         Some(HyperlinkDest::Email(e.to_string()))+    } else if let Some(a) = p.get("anchor").filter(|a| !a.is_null()) {+        let by_name = |name: &str| d.stories.values().flat_map(|st| st.anchors.iter()).find(|x| x.name == name).map(|x| x.id);+        a.as_u64().or_else(|| a.as_str().and_then(by_name)).map(HyperlinkDest::Anchor)     } else {         p.get("page").and_then(Value::as_u64).map(|pg| HyperlinkDest::Page((pg as usize).saturating_sub(1)))     } } +/// The anchor's own name, for a link to one that was not given a name.+fn dest_label(d: &designcraft_doc::Document, dest: &HyperlinkDest) -> String {+    if let HyperlinkDest::Anchor(a) = dest+        && let Some(x) = d.stories.values().flat_map(|st| st.anchors.iter()).find(|x| x.id == *a)+    {+        return x.name.clone();+    }+    dest.label()+}+ fn create(s: &mut Session, p: &Value) -> Result<Value> {-    let dest = dest_param(p).ok_or_else(|| bad("hyperlink.create", "give `url`, `email` or `page`"))?;     let st = s.doc()?;+    let dest = dest_param(&st.doc, p).ok_or_else(|| bad("hyperlink.create", "give `url`, `email`, `page` or `anchor`"))?;     let sources: Vec<HyperlinkSource> = if let Some(ids) = super::ids_param(p, "ids") {         ids.into_iter().map(|id| HyperlinkSource::Item { id }).collect()     } else if let Some(t) = st.selection.text.filter(|t| !t.is_caret()) {@@ -118,11 +132,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {         let mut ids = vec![];         for src in sources {             let id = d.alloc();-            let name = name.clone().unwrap_or_else(|| match &dest {-                HyperlinkDest::Url(u) => u.clone(),-                HyperlinkDest::Email(e) => e.clone(),-                HyperlinkDest::Page(p) => format!("Page {}", p + 1),-            });+            let name = name.clone().unwrap_or_else(|| dest_label(d, &dest));             d.hyperlinks.push(Hyperlink { id, name, source: src, dest: dest.clone() });             ids.push(id);         }@@ -156,3 +166,79 @@ mod panel_tests {         assert_eq!(s.execute("bookmark.list", &json!({})).unwrap()[0]["name"], "Back");     } }++#[cfg(test)]+mod anchor_dest_tests {+    use serde_json::json;++    use crate::Session;++    /// The page object numbers of a PDF, in order, and the page each link annotation points at+    /// (as an index into that list).+    fn link_pages(s: &mut Session) -> Vec<usize> {+        let r = s.execute("file.exportPdf", &json!({})).unwrap();+        let bytes = super::super::file::base64_decode(r["base64"].as_str().unwrap());+        let pdf = String::from_utf8_lossy(&bytes).into_owned();+        let kids: Vec<&str> = pdf+            .split("/Kids[")+            .nth(1)+            .and_then(|k| k.split(']').next())+            .map(|k| k.split(" 0 R").map(str::trim).filter(|s| !s.is_empty()).collect())+            .unwrap_or_default();+        let object =+            |n: &str| -> String { pdf.split(&format!("\n{n} 0 obj\n")).nth(1).and_then(|o| o.split("\nendobj").next()).unwrap_or("").to_string() };+        let mut out = Vec::new();+        for part in pdf.split("/Subtype/Link").skip(1) {+            let Some(dest) = part.split("/Dest ").nth(1).and_then(|d| d.split(" 0 R").next()) else { continue };+            // `[<page> 0 R/XYZ …]`+            let page = object(dest.trim());+            let Some(page) = page.trim_start_matches('[').split(" 0 R").next() else { continue };+            if let Some(i) = kids.iter().position(|k| *k == page.trim()) {+                out.push(i);+            }+        }+        out+    }++    /// A frame can link to a text anchor, as a cross-reference refers to one, and the link lands+    /// on whichever page the anchor's text is on when the PDF is written — IDML files link photo+    /// thumbnails to their full-size pictures that way (#1446).+    #[test]+    fn a_frame_can_link_to_a_text_anchor_and_follows_it() {+        let mut s = Session::new();+        s.execute("file.new", &json!({"width": 300, "height": 300, "pages": 3, "facingPages": false, "margins": 0})).unwrap();+        // The destination: an anchor in text on page 3.+        let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0;+        let dest =+            s.execute("frame.create", &json!({"spread": sp, "rect": [20, 20, 280, 100], "content": "text", "text": "Photo 1 full size"})).unwrap();+        s.execute("text.select", &json!({"story": dest["story"].clone(), "anchor": 0, "focus": 7})).unwrap();+        let anchor = s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();+        // The source: a thumbnail frame on page 1, linked by the anchor's name.+        let thumb = s.execute("frame.create", &json!({"rect": [20, 20, 120, 80], "content": "unassigned"})).unwrap();+        s.execute("hyperlink.create", &json!({"anchor": "Photo 1", "ids": [thumb["id"].clone()]})).unwrap();+        let links = s.execute("hyperlink.list", &json!({})).unwrap();+        assert_eq!(links[0]["dest"], json!({"kind": "anchor", "value": anchor["id"].clone()}), "{links}");+        // Named after the anchor, since no name was given.+        assert_eq!(links[0]["name"], "Photo 1");+        assert_eq!(link_pages(&mut s), vec![2], "the link lands on the anchor's page (the third)");++        // A page inserted in front moves the anchor's text to page 4, and the link follows it+        // without being edited.+        s.execute("layout.pages.insert", &json!({"count": 1, "after": 0})).unwrap();+        assert_eq!(s.execute("anchor.list", &json!({})).unwrap()[0]["page"], "4");+        assert_eq!(link_pages(&mut s), vec![3], "the link follows the text");++        // An anchor's id works as well as its name, and an unknown name is refused.+        s.execute("hyperlink.delete", &json!({"id": links[0]["id"].clone()})).unwrap();+        s.execute("hyperlink.create", &json!({"anchor": anchor["id"].clone(), "ids": [thumb["id"].clone()], "name": "Thumbnail"})).unwrap();+        assert_eq!(s.execute("hyperlink.list", &json!({})).unwrap()[0]["name"], "Thumbnail");+        assert_eq!(link_pages(&mut s), vec![3]);+        assert!(s.execute("hyperlink.create", &json!({"anchor": "No Such Anchor", "ids": [thumb["id"].clone()]})).is_err());++        // A link to a deleted anchor is not exported, rather than pointing at the wrong page.+        s.execute("text.select", &json!({"story": dest["story"].clone(), "anchor": 0, "focus": 8})).unwrap();+        s.execute("text.delete", &json!({})).unwrap();+        assert!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().is_empty());+        assert_eq!(link_pages(&mut s), Vec::<usize>::new(), "no link rather than a wrong one");+    }+}diff --git a/crates/pdf/src/links.rs b/crates/pdf/src/links.rs--- a/crates/pdf/src/links.rs+++ b/crates/pdf/src/links.rs@@ -73,6 +73,11 @@ pub(crate) fn annotations(doc: &Document, cache: &designcraft_compose::Cache, sh                     Some(i) => Target::Destination(XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0)).into()),                     None => continue,                 },+                // The page the anchor's text has landed on, as a cross-reference resolves it.+                HyperlinkDest::Anchor(a) => match cache.xref_index(doc).page_index(*a).and_then(|p| sheet_of_page(doc, sheets, p)) {+                    Some(i) => Target::Destination(XyzDestination::new(i, krilla::geom::Point::from_xy(0.0, 0.0)).into()),+                    None => continue,+                },             };             out.push(Annotation::new_link(LinkAnnotation::new(rect, target), Some(h.name.clone())));         }