Browse / github.com/storytold/designcraft / patch
IDML export: a document's hyperlinks are written out (#1446)
IDML export: a document's hyperlinks are written out (#1446) The export direction, completing #1446's hyperlink work. #1652 reads page-item and text hyperlinks *in*; export wrote none of them *out*, so a link made here with `hyperlink.create` — or one just imported from an InDesign file — was lost the moment the document went out as IDML. ## Measured `round_trips_page_item_and_text_hyperlinks` builds a document with one link of each kind (a frame to a URL, a frame to an email address, a frame to a text anchor, a run of text to a page), exports it, and imports it back. | | links that came back | |---|---| | with the writing skipped (the behaviour befo
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- Hamza0101 (imported from pull request #1659, open)
- Base
3ce3a24f08d03935bc8cf7cb23755aca8065558c- Size
- +633 −20 in 8 files
- Review
- pending
- Shared
- 2026-10-28
- Id
4c0731f7cb86d8315deeca46
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/export.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");+ }+}@@ -142,6 +142,10 @@ struct Ex<'a> { threads: HashMap<u64, (Option<u64>, Option<u64>)>, /// Cross-reference sources written so far: (Self, target anchor). xref_sources: Vec<(String, u64)>,+ /// `HyperlinkTextSource`s written into the stories: (Self, index in `Document::hyperlinks`).+ /// A link whose text crosses a paragraph gets one source per paragraph, since the element+ /// nests inside a `ParagraphStyleRange` (#1446).+ link_text_sources: Vec<(String, usize)>, /// Index topic path → first serialized sort key; See / See also cross-references. index_topics: BTreeMap<Vec<String>, String>, index_xrefs: Vec<(Vec<String>, &'static str, String)>,@@ -157,6 +161,7 @@ impl<'a> Ex<'a> { fonts: BTreeMap::new(), threads: HashMap::new(), xref_sources: Vec::new(),+ link_text_sources: Vec::new(), index_topics: BTreeMap::new(), index_xrefs: Vec::new(), };@@ -438,6 +443,65 @@ impl<'a> Ex<'a> { for (name, _) in stories { root.push(El::new("idPkg:Story").attr("src", name)); }+ // The document's own hyperlinks (#1446): a destination element, a source element for a+ // page item (a text source is already in its story) and a `Hyperlink` joining them.+ // Stories are written before this, so `link_text_sources` is complete.+ let text_sources: Vec<(String, usize)> = std::mem::take(&mut self.link_text_sources);+ for (i, h) in d.hyperlinks.iter().enumerate() {+ use designcraft_doc::{HyperlinkDest as De, HyperlinkSource as So};+ // A text destination is already written where its anchor sits.+ let dest = match &h.dest {+ De::Anchor(a) => Some(anchor_self(*a)),+ De::Url(u) => Some(self.url_dest_el(&mut root, u, &h.name)),+ De::Email(e) => Some(self.url_dest_el(&mut root, &format!("mailto:{e}"), &h.name)),+ De::Page(p) => d.page(*p).map(|pg| {+ let me = format!("HyperlinkPageDestination/p{}", pg.id.0);+ root.push(+ El::new("HyperlinkPageDestination")+ .attr("Self", &me)+ .attr("Name", &h.name)+ .attr("DestinationPage", uid(pg.id.0))+ .attr("ViewSetting", "FitPage")+ .attr("Hidden", "false"),+ );+ me+ }),+ };+ let Some(dest) = dest else { continue };+ // One `Hyperlink` per source element: a link whose text crosses a paragraph has one+ // source per paragraph, so each piece stays clickable.+ let sources: Vec<String> = match &h.source {+ So::Item { id } if d.item(*id).is_some() => {+ let me = format!("HyperlinkPageItemSource/i{}", id.0);+ root.push(+ El::new("HyperlinkPageItemSource")+ .attr("Self", &me)+ .attr("Name", &h.name)+ .attr("SourcePageItem", uid(id.0))+ .attr("Hidden", "false"),+ );+ vec![me]+ }+ So::Item { .. } => vec![],+ So::Text { .. } => text_sources.iter().filter(|(_, k)| *k == i).map(|(me, _)| me.clone()).collect(),+ };+ for src in sources {+ let mut el = El::new("Hyperlink")+ .attr("Self", self.fresh())+ .attr("Name", &h.name)+ .attr("Source", src)+ .attr("Visible", "true")+ .attr("Highlight", "None")+ .attr("Width", "Thin")+ .attr("BorderStyle", "Solid")+ .attr("Hidden", "false")+ .attr("DestinationUniqueKey", h.id & 0xFFFF_FFFF);+ let mut props = El::new("Properties");+ props.push(p("Destination", "object", &dest));+ el.push(props);+ root.push(el);+ }+ } for (src, target) in self.xref_sources.clone() { let mut h = El::new("Hyperlink") .attr("Self", self.fresh())@@ -2323,7 +2387,37 @@ impl<'a> Ex<'a> { let f = s.char_format_at(r.start).clone(); segs.push((r.start..r.start, f)); }+ // A hyperlink on a run of text is written as a `HyperlinkTextSource` around the+ // ranges it covers, so the runs are cut at its edges first (#1446).+ let links: Vec<(usize, std::ops::Range<usize>)> = self+ .d+ .hyperlinks+ .iter()+ .enumerate()+ .filter_map(|(i, h)| match &h.source {+ designcraft_doc::HyperlinkSource::Text { story, start, end } if *story == s.id => {+ let (a, b) = ((*start).max(r.start), (*end).min(r.end));+ (a < b).then_some((i, a..b))+ }+ _ => None,+ })+ .collect();+ let mut cuts: Vec<usize> = links.iter().flat_map(|(_, l)| [l.start, l.end]).collect();+ cuts.sort_unstable();+ cuts.dedup();+ let segs = segs.into_iter().fold(Vec::new(), |mut out: Vec<(std::ops::Range<usize>, CharFormat)>, (rr, f)| {+ let mut at = rr.start;+ for c in cuts.iter().copied().filter(|c| rr.start < *c && *c < rr.end) {+ out.push((at..c, f.clone()));+ at = c;+ }+ out.push((at..rr.end, f));+ out+ }); let nseg = segs.len();+ // Each segment's ranges, with the link they belong to; wrapped once the paragraph is+ // built so that consecutive ranges of one link share a single source element.+ let mut psr_csrs: Vec<(Option<usize>, El)> = Vec::new(); for (k, (rr, f)) in segs.into_iter().enumerate() { let seg_start = rr.start; let text = &s.text[rr];@@ -2580,8 +2674,35 @@ impl<'a> Ex<'a> { }, } }+ let link = links.iter().find(|(_, l)| l.contains(&seg_start) || (l.start == seg_start && l.start == l.end)).map(|(i, _)| *i); for c in csrs {- psr.push(c);+ psr_csrs.push((link, c));+ }+ }+ // Consecutive ranges of one link go inside one `HyperlinkTextSource`.+ let mut groups: Vec<(Option<usize>, Vec<El>)> = Vec::new();+ for (link, c) in psr_csrs {+ match groups.last_mut() {+ Some((l, v)) if *l == link => v.push(c),+ _ => groups.push((link, vec![c])),+ }+ }+ for (link, cs) in groups {+ match link.and_then(|i| self.d.hyperlinks.get(i).map(|h| (i, h.name.clone()))) {+ None => {+ for c in cs {+ psr.push(c);+ }+ }+ Some((i, name)) => {+ let me = self.fresh();+ let mut src = El::new("HyperlinkTextSource").attr("Self", &me).attr("Name", name).attr("Hidden", "false");+ for c in cs {+ src.push(c);+ }+ psr.push(src);+ self.link_text_sources.push((me, i));+ } } } paras.push(psr);@@ -3089,6 +3210,28 @@ const STROKE_STYLES: &[&str] = &[ "Solid", ]; +impl Ex<'_> {+ /// A `HyperlinkURLDestination` for `url`, written once however many links lead to it.+ fn url_dest_el(&mut self, root: &mut El, url: &str, name: &str) -> String {+ let me = format!("HyperlinkURLDestination/u{:x}", fnv(url));+ if !root.children.iter().any(|n| matches!(n, Node::El(e) if e.name == "HyperlinkURLDestination" && e.get("Self") == Some(me.as_str()))) {+ root.push(+ El::new("HyperlinkURLDestination")+ .attr("Self", &me)+ .attr("Name", if name.is_empty() { url } else { name })+ .attr("DestinationURL", url)+ .attr("Hidden", "false"),+ );+ }+ me+ }+}++/// A short stable name for a string, so one URL gives one destination element.+fn fnv(s: &str) -> u64 {+ s.bytes().fold(0xcbf2_9ce4_8422_2325u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x100_0000_01b3))+}+ fn anchor_self(id: u64) -> String { format!("HyperlinkTextDestination/a{id}") }@@ -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,222 @@ 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+ );+}++/// The other direction of #1446: a document's own hyperlinks are written to IDML and read back.+/// Export wrote none of them, so a link made here (or imported from an InDesign file) was lost+/// the moment the document went out as IDML.+#[test]+fn round_trips_page_item_and_text_hyperlinks() {+ use designcraft_doc::{Hyperlink, HyperlinkDest as D, HyperlinkSource as S};++ let mut d = Document::new(&NewDocument { pages: 2, ..NewDocument::default() });+ let lid = d.default_layer();+ let (_, sid) =+ d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "See the plate overleaf.", ParaFormat::default()).unwrap();+ let photo = designcraft_doc::ItemId(d.alloc());+ let frame = designcraft_doc::Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(40.0, 420.0, 200.0, 500.0)));+ d.insert_item(SpreadRef::Doc(0), frame, None).unwrap();+ let caption = d.paragraph_anchor(sid, 0).unwrap();++ // One of each kind a link can have: a frame to a URL, a frame to an email address, a frame to+ // a text anchor, and a run of text to a page.+ let at = d.story(sid).unwrap().text.find("the plate").unwrap();+ for (name, source, dest) in [+ ("Photo", S::Item { id: photo }, D::Url("https://example.com/photo.jpg".into())),+ ("Studio", S::Item { id: photo }, D::Email("studio@example.com".into())),+ ("Caption", S::Item { id: photo }, D::Anchor(caption)),+ ("Plate", S::Text { story: sid, start: at, end: at + "the plate".len() }, D::Page(1)),+ ] {+ let id = d.alloc();+ d.hyperlinks.push(Hyperlink { id, name: name.into(), source, dest });+ }+ d.check().unwrap();++ let idml = export_idml(&d);+ let back = import_idml(&idml).unwrap();+ back.check().unwrap();++ let named = |n: &str| back.hyperlinks.iter().find(|h| h.name == n).unwrap_or_else(|| panic!("{n} came back: {:?}", back.hyperlinks));+ assert_eq!(back.hyperlinks.len(), 4, "every link came back: {:?}", back.hyperlinks);+ assert_eq!(named("Photo").dest, D::Url("https://example.com/photo.jpg".into()));+ assert_eq!(named("Studio").dest, D::Email("studio@example.com".into()), "an email link is not written as a bare URL");+ assert_eq!(named("Plate").dest, D::Page(1), "the page destination is the same page");++ // The frame links point at the frame, which has a new id on the way through.+ let S::Item { id } = named("Photo").source else { panic!("a page item source: {:?}", named("Photo").source) };+ assert!(back.item(id).is_some(), "the source frame is in the document");+ assert_eq!(back.item(id).map(|i| i.shape), Some(Shape::Rectangle), "and it is the frame the link was on");++ // The text link covers the same words, and the anchor link resolves to the same paragraph.+ let S::Text { story, start, end } = named("Plate").source else { panic!("a text source: {:?}", named("Plate").source) };+ assert_eq!(&back.story(story).unwrap().text[start..end], "the plate");+ let D::Anchor(a) = named("Caption").dest else { panic!("an anchor destination: {:?}", named("Caption").dest) };+ assert_eq!(back.find_anchor(a).map(|(_, pos)| pos), Some(0), "the anchor is the paragraph it was on");++ // One destination element per URL, however many links lead to it.+ let text = zip_text(&idml, "designmap.xml");+ assert_eq!(text.matches("<HyperlinkURLDestination").count(), 2, "one for the photo and one for the mailto: {text}");+ assert_eq!(text.matches("mailto:studio@example.com").count(), 1);+}++/// A `HyperlinkTextSource` nests inside a `ParagraphStyleRange`, so a link whose text crosses a+/// paragraph cannot be one element: it is written as one source per paragraph, and each piece+/// comes back as a link of its own. Pinned so the behaviour is a decision and not a surprise, and+/// so it is clear what has to change if IDML ever lets one source span paragraphs (#1446).+#[test]+fn a_hyperlink_across_a_paragraph_is_written_as_one_source_per_paragraph() {+ use designcraft_doc::{Hyperlink, HyperlinkDest as D, HyperlinkSource as S};++ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let (_, sid) =+ d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "first line\nsecond line", ParaFormat::default()).unwrap();+ let text = d.story(sid).unwrap().text.clone();+ let (start, end) = (text.find("line").unwrap(), text.find("second").unwrap() + "second".len());+ let id = d.alloc();+ d.hyperlinks.push(Hyperlink {+ id,+ name: "Across".into(),+ source: S::Text { story: sid, start, end },+ dest: D::Url("https://example.com/".into()),+ });+ d.check().unwrap();++ let idml = export_idml(&d);+ assert_eq!(zip_text(&idml, "Stories/").matches("<HyperlinkTextSource").count(), 2, "one source in each paragraph");++ let back = import_idml(&idml).unwrap();+ back.check().unwrap();+ let across: Vec<_> = back.hyperlinks.iter().filter(|h| h.name == "Across").collect();+ assert_eq!(across.len(), 2, "each piece is a link of its own: {:?}", back.hyperlinks);+ let piece = |h: &Hyperlink| {+ let S::Text { story, start, end } = h.source else { panic!("a text source: {:?}", h.source) };+ back.story(story).unwrap().text[start..end].to_string()+ };+ let mut pieces: Vec<String> = across.iter().map(|h| piece(h)).collect();+ pieces.sort();+ // The first piece keeps the paragraph break, because the link's range covered it: the break+ // is the last character of its paragraph, so the source that wraps that paragraph's text+ // wraps the break with it. Between them the pieces cover exactly what the link covered.+ assert_eq!(pieces, vec!["line\n".to_string(), "second".to_string()], "between them they cover the text the link was on");+ assert_eq!(pieces.concat(), d.story(sid).unwrap().text[start..end], "and nothing of it is lost or added");+ assert!(across.iter().all(|h| h.dest == D::Url("https://example.com/".into())), "both pieces lead where the link led");+}+ #[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()))); }