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())));
         }
