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
@@ -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)]@@ -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 }@@ -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();@@ -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");+ }+}@@ -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>, } @@ -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;@@ -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()))); }