collective

Browse / github.com/storytold/designcraft / patch

IDML import: a page item's or a text run's hyperlink survives (#1446)

IDML import: a page item's or a text run's hyperlink survives (#1446) The second half of #1446: *"IDML import drops page-item hyperlinks"*. IDML keeps a hyperlink in three pieces — a source element, a destination element, and a `Hyperlink` that joins them by `Self` name. Only `CrossReferenceSource` was ever mapped and `Document::hyperlinks` was built as `vec![]`, so an image frame linked to a photo and a run of text linked to a page were read and thrown away — although both are native here (`hyperlink.create`) and the PDF export already writes both. ## Measured The fixture holds six `Hyperlink` elements. With the resolution stubbed back to `vec![]` (the behav

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1652, open)
Base
3ce3a24f08d03935bc8cf7cb23755aca8065558c
Size
+388 −19 in 7 files
Review
pending
Shared
2026-10-28
Id
7916e0b6323f66775edc8728

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/idml/src/import.rs crates/idml/src/tests.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/idml/src/import.rs b/crates/idml/src/import.rs--- a/crates/idml/src/import.rs+++ b/crates/idml/src/import.rs@@ -116,11 +116,14 @@ pub fn import_idml_report_with_previews(         ));     }     warnings.append(&mut im.warnings);-    let mut document = im.finish(&root).map_err(|e| match e {+    // `finish` resolves what needs the whole document (hyperlinks, #1446) and so reports its own+    // warnings; it is called after this append, so they come back with the document.+    let (mut document, finishing) = im.finish(&root).map_err(|e| match e {         // Without its spread parts there is nothing to open: say which parts were missing.         IdmlError::Invalid(msg) if !warnings.is_empty() => IdmlError::Invalid(format!("{msg} ({})", warnings.join("; "))),         e => e,     })?;+    warnings.extend(finishing);     warnings.extend(document.fix_parent_bases());     document.check().map_err(|e| IdmlError::Invalid(e.to_string()))?;     Ok(Imported { document, warnings })@@ -255,6 +258,22 @@ struct ItemCtx {     threads: Vec<(ItemId, String, String, String)>, } +/// An IDML value given either as an attribute or inside `<Properties>`, trimmed.+fn attr_or_prop(e: &El, key: &str) -> Option<String> {+    e.get(key).map(|v| v.trim().to_string()).or_else(|| e.prop(key).map(|v| v.trim().to_string())).filter(|v| !v.is_empty())+}++/// Where a hyperlink destination element points, before the pages, items and anchors it names+/// have been read (#1446). A `Hyperlink` names its destination by `Self` and the elements come in+/// any order, so both ends are resolved once the document is complete.+#[derive(Clone, Debug)]+enum PendingDest {+    /// `HyperlinkURLDestination`'s `DestinationURL`; a `mailto:` one is an email link.+    Url(String),+    /// `HyperlinkPageDestination`'s `DestinationPage`, a page's `Self`.+    Page(String),+}+ struct Importer<'r> {     read_link: &'r dyn Fn(&str) -> Option<Vec<u8>>,     /// Graphic `Self` → stand-in image for a linked file that can't be read.@@ -310,6 +329,17 @@ struct Importer<'r> {     anchor_ids: HashMap<String, u64>,     /// Cross-reference source Self → (story, index in its cross-references).     xref_srcs: HashMap<String, (StoryId, usize)>,+    /// `HyperlinkPageItemSource` Self → the page item's Self (#1446). Items are read after the+    /// hyperlinks are listed, so the names are resolved at the end, in [`Importer::finish`].+    link_item_srcs: HashMap<String, String>,+    /// `HyperlinkTextSource` Self → the story and the byte range of the text it wrapped.+    link_text_srcs: HashMap<String, (StoryId, usize, usize)>,+    /// A destination element's Self → where it points, as far as its own element says.+    link_dests: HashMap<String, PendingDest>,+    /// Destination Self → element name, for the kinds this importer does not read.+    other_dests: HashMap<String, String>,+    /// Each `Hyperlink`: its name, its source's Self and its destination's Self.+    links: Vec<(String, String, String)>,     /// Cross-reference format Self → name.     xref_format_names: HashMap<String, String>,     xref_formats: Vec<designcraft_doc::XrefFormat>,@@ -547,6 +577,11 @@ impl<'r> Importer<'r> {             footnote_options: Default::default(),             anchor_ids: HashMap::new(),             xref_srcs: HashMap::new(),+            link_item_srcs: HashMap::new(),+            link_text_srcs: HashMap::new(),+            link_dests: HashMap::new(),+            other_dests: HashMap::new(),+            links: Vec::new(),             xref_format_names: HashMap::new(),             xref_formats: designcraft_doc::xref::default_formats(),             index_topics: HashMap::new(),@@ -722,6 +757,41 @@ impl<'r> Importer<'r> {         for e in top.iter().filter(|e| e.local() == "CrossReferenceFormat") {             self.xref_format(e);         }+        // Hyperlink sources, destinations and the `Hyperlink` elements that join them (#1446).+        // Only the names are taken here: the pages, page items and anchors they name are read+        // later, so the two ends are put together in `finish`.+        for e in top {+            let me = || e.get("Self").unwrap_or("").trim().to_string();+            match e.local() {+                "HyperlinkURLDestination" => {+                    if let Some(u) = attr_or_prop(e, "DestinationURL") {+                        self.link_dests.insert(me(), PendingDest::Url(u));+                    }+                }+                "HyperlinkPageDestination" => {+                    if let Some(pg) = attr_or_prop(e, "DestinationPage") {+                        self.link_dests.insert(me(), PendingDest::Page(pg));+                    }+                }+                "HyperlinkPageItemSource" => {+                    if let Some(it) = attr_or_prop(e, "SourcePageItem") {+                        self.link_item_srcs.insert(me(), it);+                    }+                }+                "Hyperlink" => {+                    if let (Some(src), Some(dest)) = (attr_or_prop(e, "Source"), attr_or_prop(e, "Destination")) {+                        let name = e.get("Name").unwrap_or("").trim().to_string();+                        self.links.push((name, src, dest));+                    }+                }+                // A destination kind this importer does not read (an external page, say): kept+                // by name so a link dropped for it can say what it led to.+                other if other.starts_with("Hyperlink") && other.ends_with("Destination") => {+                    self.other_dests.insert(me(), other.to_string());+                }+                _ => {}+            }+        }         if let Some(ix) = top.iter().find(|e| e.local() == "Index") {             self.index_topics_of(ix, &IndexTopic::default());         }@@ -2067,11 +2137,15 @@ impl<'r> Importer<'r> {             notes: Vec::new(),             anchors: Vec::new(),             xrefs: Vec::new(),+            link_srcs: Vec::new(),             index_refs: Vec::new(),             objects: Vec::new(),         };         self.walk_story(e, &mut b, &default_para, &CharAttrs::default(), &CharFormat::default(), None);-        let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, .. } = b;+        let StoryBuilder { text, mut paras, runs, last, tables: tbls, notes, anchors, xrefs, index_refs, objects, link_srcs, .. } = b;+        for (me, start, end) in link_srcs {+            self.link_text_srcs.insert(me, (id, start, end));+        }         let mut chars: Vec<CharRun> = runs.into_iter().filter(|r| r.len > 0).collect();         if chars.is_empty() {             chars.push(CharRun { len: 0, format: last });@@ -2331,6 +2405,15 @@ impl<'r> Importer<'r> {                         let t = self.table(c);                         b.push_table(t, pf, cf);                     }+                    // The source wraps the text the link covers: its children are ordinary ranges+                    // and content, so the range is where the text stood before and after (#1446).+                    "HyperlinkTextSource" => {+                        let start = b.text.len();+                        self.walk_story(c, b, pf, pchars, cf, brk);+                        if b.text.len() > start {+                            b.link_srcs.push((c.get("Self").unwrap_or("").to_string(), start, b.text.len()));+                        }+                    }                     "HyperlinkTextDestination" | "ParagraphDestination" => {                         b.anchors.push((c.get("Self").unwrap_or("").to_string(), c.get("Name").unwrap_or("Anchor").to_string()));                         b.push(&designcraft_doc::ANCHOR_MARK.to_string(), cf);@@ -3202,7 +3285,72 @@ impl<'r> Importer<'r> {         }     } -    fn finish(mut self, root: &El) -> Result<Document> {+    /// The document's hyperlinks, put together from the sources, destinations and `Hyperlink`+    /// elements the package listed (#1446). Called once the pages, page items and text anchors+    /// they name have been read. A link whose source or destination is not in the file is+    /// dropped: a source that names no item is nothing to click, and a destination that resolves+    /// to nothing would be a link that goes nowhere.+    fn hyperlink_list(&mut self) -> Vec<designcraft_doc::Hyperlink> {+        use designcraft_doc::{HyperlinkDest as D, HyperlinkSource as S};+        let mut out: Vec<designcraft_doc::Hyperlink> = Vec::new();+        for (name, src, dest) in std::mem::take(&mut self.links) {+            // A cross-reference's source is its own kind and is joined to its anchor elsewhere.+            if self.xref_srcs.contains_key(&src) {+                continue;+            }+            let label = if name.is_empty() { src.clone() } else { name.clone() };+            let source = if let Some(item) = self.link_item_srcs.get(&src) {+                let Some(&id) = self.item_ids.get(item) else {+                    let item = item.clone();+                    self.warnings.push(format!("hyperlink {label}: its page item {item} is not in the package, so the link was dropped"));+                    continue;+                };+                S::Item { id }+            } else if let Some(&(story, start, end)) = self.link_text_srcs.get(&src) {+                S::Text { story, start, end }+            } else {+                self.warnings.push(format!("hyperlink {label}: its source {src} is not a page item or a run of text, so the link was dropped"));+                continue;+            };+            let dest = match self.link_dests.get(&dest) {+                Some(PendingDest::Url(u)) => match u.strip_prefix("mailto:") {+                    // `hyperlink.create` keeps an email address as one, so a `mailto:` URL is+                    // read back as the email link it was written from.+                    Some(address) if !address.is_empty() => D::Email(address.to_string()),+                    _ => D::Url(u.clone()),+                },+                Some(PendingDest::Page(pg)) => match self.page_index.get(pg) {+                    Some(&i) => D::Page(i),+                    None => {+                        self.warnings.push(format!("hyperlink {label}: it leads to a page that is not in the package, so the link was dropped"));+                        continue;+                    }+                },+                // A `HyperlinkTextDestination` has no entry of its own: it sits in a story and is+                // named straight by its `Self`, as a cross-reference names its anchor.+                None => match (self.anchor_ids.get(&dest), self.other_dests.get(&dest)) {+                    (Some(&id), _) => D::Anchor(id),+                    // A kind InDesign writes and this importer does not read yet: say which,+                    // rather than report it as missing from the package.+                    (None, Some(kind)) => {+                        let kind = kind.clone();+                        self.warnings.push(format!("hyperlink {label}: a {kind} is not read yet, so the link was dropped"));+                        continue;+                    }+                    (None, None) => {+                        self.warnings.push(format!("hyperlink {label}: its destination {dest} is not in the package, so the link was dropped"));+                        continue;+                    }+                },+            };+            let name = if name.is_empty() { dest.label() } else { name };+            out.push(designcraft_doc::Hyperlink { id: self.alloc(), name, source, dest });+        }+        out+    }++    /// The finished document, and the warnings raised while finishing it.+    fn finish(mut self, root: &El) -> Result<(Document, Vec<String>)> {         let title = root.get("Name").map(|n| n.trim_end_matches(".indd").to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "Untitled".into());         if self.spreads.is_empty() {             return Err(IdmlError::Invalid("document has no spreads".into()));@@ -3229,7 +3377,7 @@ impl<'r> Importer<'r> {             endnote_story: None,             sections: std::mem::take(&mut self.sections),             assets: std::mem::take(&mut self.assets),-            hyperlinks: vec![],+            hyperlinks: self.hyperlink_list(),             data_merge: Default::default(),             bookmarks: vec![],             user_words: vec![],@@ -3249,7 +3397,7 @@ impl<'r> Importer<'r> {         {             d.settings.zero_point = zp;         }-        Ok(d)+        Ok((d, std::mem::take(&mut self.warnings)))     } } @@ -3401,6 +3549,8 @@ struct StoryBuilder {     anchors: Vec<(String, String)>,     index_refs: Vec<designcraft_doc::IndexRef>,     xrefs: Vec<(String, String)>,+    /// `HyperlinkTextSource` Self and the byte range of the text it wrapped (#1446).+    link_srcs: Vec<(String, usize, usize)>,     objects: Vec<designcraft_doc::AnchoredObject>, } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs--- a/crates/idml/src/tests.rs+++ b/crates/idml/src/tests.rs@@ -1215,6 +1215,121 @@ fn cross_reference_source_nesting_keeps_formatting() {     assert_eq!(ga, gb); } +/// IDML carries a hyperlink as a source element, a destination element and a `Hyperlink` that+/// joins them. Only `CrossReferenceSource` was mapped, so an image frame linked to a photo and a+/// run of text linked to a page were dropped on import, although both are native here+/// (`hyperlink.create`) and both export to PDF (#1446).+#[test]+fn page_item_and_text_hyperlinks_survive_import() {+    // `img` is the Rectangle of the spread fixture; `p2` its second page.+    const LINKS: &str = r#"+  <HyperlinkURLDestination Self="hud1" Name="Photo" DestinationURL="https://example.com/photo.jpg" Hidden="false"/>+  <HyperlinkURLDestination Self="hud2" Name="Mail" DestinationURL="mailto:studio@example.com" Hidden="false"/>+  <HyperlinkPageDestination Self="hpd1" Name="Plate" DestinationPage="p2" ViewSetting="FitPage"/>+  <HyperlinkPageItemSource Self="hpis1" Name="img" SourcePageItem="img" Hidden="false"/>+  <HyperlinkPageItemSource Self="hpis2" Name="o" SourcePageItem="o" Hidden="false"/>+  <Hyperlink Self="h1" Name="Photo link" Source="hpis1" Visible="true" DestinationUniqueKey="1">+    <Properties><Destination type="object">hud1</Destination></Properties>+  </Hyperlink>+  <Hyperlink Self="h2" Name="Write to us" Source="hpis2" Visible="true" DestinationUniqueKey="2">+    <Properties><Destination type="object">hud2</Destination></Properties>+  </Hyperlink>+  <Hyperlink Self="h3" Name="To the plate" Source="hts1" Visible="true" DestinationUniqueKey="3">+    <Properties><Destination type="object">hpd1</Destination></Properties>+  </Hyperlink>+  <Hyperlink Self="h4" Name="Nothing" Source="hpis9" Visible="true" DestinationUniqueKey="4">+    <Properties><Destination type="object">hud1</Destination></Properties>+  </Hyperlink>+  <HyperlinkExternalPageDestination Self="hepd1" Name="Another file" DestinationPath="elsewhere.indd" DestinationPageIndex="2"/>+  <HyperlinkPageItemSource Self="hpis4" Name="g" SourcePageItem="g" Hidden="false"/>+  <Hyperlink Self="h6" Name="Out of the file" Source="hpis4" Visible="true" DestinationUniqueKey="6">+    <Properties><Destination type="object">hepd1</Destination></Properties>+  </Hyperlink>+  <HyperlinkPageItemSource Self="hpis3" Name="t1" SourcePageItem="t1" Hidden="false"/>+  <Hyperlink Self="h5" Name="To the caption" Source="hpis3" Visible="true" DestinationUniqueKey="5">+    <Properties><Destination type="object">htd1</Destination></Properties>+  </Hyperlink>+"#;+    let story = r#"<?xml version="1.0" encoding="UTF-8"?>+<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">+  <Story Self="s1">+    <ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle">+      <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">+        <Content>See </Content>+      </CharacterStyleRange>+      <HyperlinkTextSource Self="hts1" Name="To the plate" Hidden="false">+        <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">+          <Content>the plate</Content>+        </CharacterStyleRange>+      </HyperlinkTextSource>+      <CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">+        <Content> overleaf, </Content>+        <HyperlinkTextDestination Self="htd1" Name="Plate caption"/>+        <Content>here.</Content>+      </CharacterStyleRange>+    </ParagraphStyleRange>+  </Story>+</idPkg:Story>"#;+    let map = DESIGNMAP.replace("</Document>", &format!("{LINKS}</Document>"));+    let bytes = zip_files(&[+        ("designmap.xml", &map),+        ("Resources/Graphic.xml", GRAPHIC),+        ("Resources/Styles.xml", STYLES),+        ("Resources/Preferences.xml", PREFS),+        ("MasterSpreads/MasterSpread_m1.xml", MASTER),+        ("Spreads/Spread_sp1.xml", SPREAD),+        ("Stories/Story_s1.xml", story),+    ]);+    let r = import_idml_report(&bytes, &|_| None).unwrap();+    let d = r.document;+    d.check().unwrap();++    use designcraft_doc::{HyperlinkDest as D, HyperlinkSource as S};+    assert_eq!(d.hyperlinks.len(), 4, "three page items and one text run: {:?}", d.hyperlinks);++    // The image frame links to a URL. Its source is the item the IDML named, not any item.+    let photo = d.hyperlinks.iter().find(|h| h.name == "Photo link").expect("the page-item link");+    let S::Item { id } = photo.source else { panic!("a page item source: {:?}", photo.source) };+    assert_eq!(d.item(id).map(|i| i.id), Some(id), "the source item is in the document");+    assert_eq!(photo.dest, D::Url("https://example.com/photo.jpg".into()));++    // A `mailto:` URL destination is an email link, as `hyperlink.create` makes one.+    let mail = d.hyperlinks.iter().find(|h| h.name == "Write to us").expect("the mail link");+    assert_eq!(mail.dest, D::Email("studio@example.com".into()));++    // The text source covers the text it wrapped, and the page destination is that page's index.+    let plate = d.hyperlinks.iter().find(|h| h.name == "To the plate").expect("the text link");+    let S::Text { story: sid, start, end } = plate.source else { panic!("a text source: {:?}", plate.source) };+    let text = &d.story(sid).unwrap().text;+    assert_eq!(&text[start..end], "the plate", "the source is the text the element wrapped");+    assert_eq!(plate.dest, D::Page(1), "`p2` is the second page");++    // A text destination is a text anchor, which a link can point at since #1555: the page it+    // leads to is wherever that text has landed, so the link follows its target as text reflows.+    let caption = d.hyperlinks.iter().find(|h| h.name == "To the caption").expect("the anchor link");+    let D::Anchor(aid) = caption.dest else { panic!("an anchor destination: {:?}", caption.dest) };+    let (asid, apos) = d.find_anchor(aid).expect("the anchor resolves");+    assert_eq!(&d.story(asid).unwrap().text[..apos], "See the plate overleaf, ", "the anchor is where the element stood");++    // A `Hyperlink` whose source is not in the file is dropped, not guessed at, and the import+    // report says so rather than letting it go missing silently.+    assert!(!d.hyperlinks.iter().any(|h| h.name == "Nothing"), "a link with no source is not imported");+    assert!(+        r.warnings.iter().any(|w| w.contains("hyperlink Nothing") && w.contains("hpis9") && w.contains("dropped")),+        "the report names the link it dropped: {:?}",+        r.warnings+    );++    // A destination kind this importer does not read is reported as that, not as missing: a link+    // into another document is something InDesign writes and we have nowhere to put yet.+    assert!(!d.hyperlinks.iter().any(|h| h.name == "Out of the file"), "an external-page link is not imported");+    assert!(+        r.warnings.iter().any(|w| w.contains("hyperlink Out of the file") && w.contains("HyperlinkExternalPageDestination")),+        "the report says which kind it could not read: {:?}",+        r.warnings+    );+}+ #[test] fn round_trips_index_references() {     use designcraft_doc::index::IndexRange;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())));         }