//! Type › Hyperlinks & Cross-References: text anchors, cross-references and their formats. use designcraft_doc::{CrossRef, StoryId, TextSel, XrefFormat}; use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, str_param}; use crate::{Result, Session}; pub fn specs() -> Vec { vec![ cmd!( "xref.insert", "Insert Cross-Reference…", ["Type", "Hyperlinks & Cross-References"], None, "{anchor? | paragraph?: text to find (first paragraph containing it) | story?: with para? | para?: 0-based paragraph of `story`, format?: name (default Full Paragraph & Page Number)} — at the insertion point; the text updates itself as the destination moves", in_story_text, insert ), cmd!( "anchor.create", "New Hyperlink Destination…", ["Type", "Hyperlinks & Cross-References"], None, "{name} — a text anchor at the insertion point", in_story_text, create_anchor ), cmd!(query "xref.list", "Cross-References", [], None, "{} → [{story, pos, target, format, text}]", has_doc, list), cmd!(query "anchor.list", "Text Anchors", [], None, "{} → [{id, name, story, pos, page}]", has_doc, list_anchors), cmd!(query "xref.formats", "Cross-Reference Formats", [], None, "{} → [{name, definition}]", has_doc, |s, _| { Ok(serde_json::to_value(&s.doc()?.doc.xref_formats).unwrap_or_default()) }), cmd!( "xref.defineFormat", "Define Cross-Reference Format…", ["Type", "Hyperlinks & Cross-References"], None, "{name, definition} — building blocks: , , , , , , , ; creates or replaces", has_doc, define_format ), cmd!("xref.setFormat", "Set Cross-Reference Format", [], None, "{story, index, format}", has_doc, set_format), ] } fn in_story_text(s: &Session) -> std::result::Result<(), String> { super::has_text(s)?; match s.active().and_then(|d| d.selection.text) { Some(t) if t.cell.is_some() => Err("cross-references go in story text (not table cells or footnotes yet)".into()), _ => Ok(()), } } /// The destination anchor from params, creating a paragraph anchor when needed. fn destination(d: &mut designcraft_doc::Document, p: &Value) -> Result { if let Some(a) = p.get("anchor").and_then(Value::as_u64) { return d.find_anchor(a).map(|_| a).ok_or_else(|| bad("xref.insert", format!("no anchor {a}"))); } let (sid, pos) = if let Some(text) = str_param(p, "paragraph") { let hit = d.stories.values().find_map(|st| st.text.find(text).map(|i| (st.id, i))); hit.ok_or_else(|| bad("xref.insert", format!("no paragraph contains `{text}`")))? } else if let (Some(sid), Some(pi)) = (p.get("story").and_then(Value::as_u64), p.get("para").and_then(Value::as_u64)) { let st = d.story(StoryId(sid)).ok_or_else(|| bad("xref.insert", "no such story"))?; let r = st.para_ranges().get(pi as usize).cloned().ok_or_else(|| bad("xref.insert", "no such paragraph"))?; (StoryId(sid), r.start) } else { return Err(bad("xref.insert", "give `anchor`, `paragraph` or `story` + `para`")); }; d.paragraph_anchor(sid, pos).ok_or_else(|| bad("xref.insert", "could not anchor the paragraph")) } fn insert(s: &mut Session, p: &Value) -> Result { let p = p.clone(); s.edit(|d, sel| { let format = str_param(&p, "format").unwrap_or("Full Paragraph & Page Number").to_string(); if d.xref_format(&format).is_none() { return Err(bad("xref.insert", format!("no cross-reference format `{format}`"))); } let t = sel.text.ok_or_else(|| bad("xref.insert", "no insertion point"))?; // A new paragraph anchor before the caret in the same story shifts the caret. let len0 = d.story(t.story).map_or(0, |st| st.len()); let target = destination(d, &p)?; let mut t2 = t; if let Some((asid, apos)) = d.find_anchor(target) && asid == t.story && apos <= t.range().start { let grew = d.story(t.story).map_or(0, |st| st.len()) - len0; t2.anchor += grew; t2.focus += grew; } let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let r = t2.range(); let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); st.insert_xref(r.start, CrossRef { target, format }); let pos = r.start + designcraft_doc::XREF_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t2 }); Ok(json!({"target": target, "pos": r.start})) }) } fn create_anchor(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string(); s.edit(|d, sel| { let t = sel.text.ok_or_else(|| bad("anchor.create", "no insertion point"))?; let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?; let at = t.range().start.min(st.len()); let id = st.insert_anchor(at, &name); let pos = t.focus + designcraft_doc::ANCHOR_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); Ok(json!({"id": id})) }) } fn list(s: &mut Session, _: &Value) -> Result { let d = s.doc()?.doc.clone(); let index = s.cache.xref_index(&d); let mut out = Vec::new(); for st in d.stories.values() { for ((pos, _), x) in st.text.match_indices(designcraft_doc::XREF_MARK).zip(&st.xrefs) { let text = designcraft_compose::xref::xref_text(&d, x, Some(&index)); out.push(json!({"story": st.id.0, "pos": pos, "target": x.target, "format": x.format, "text": text})); } } Ok(Value::Array(out)) } fn list_anchors(s: &mut Session, _: &Value) -> Result { let d = s.doc()?.doc.clone(); let index = s.cache.xref_index(&d); let mut out = Vec::new(); for st in d.stories.values() { for a in &st.anchors { out.push(json!({"id": a.id, "name": a.name, "story": st.id.0, "pos": st.anchor_pos(a.id), "page": index.page(a.id)})); } } Ok(Value::Array(out)) } fn define_format(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("xref.defineFormat", "missing name"))?.to_string(); let definition = str_param(p, "definition").ok_or_else(|| bad("xref.defineFormat", "missing definition"))?.to_string(); s.edit(|d, _| { match d.xref_formats.iter_mut().find(|f| f.name == name) { Some(f) => f.definition = definition.clone(), None => d.xref_formats.push(XrefFormat { name: name.clone(), definition: definition.clone() }), } Ok(Value::Null) }) } fn set_format(s: &mut Session, p: &Value) -> Result { let sid = p.get("story").and_then(Value::as_u64).map(StoryId).ok_or_else(|| bad("xref.setFormat", "missing story"))?; let k = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("xref.setFormat", "missing index"))? as usize; let format = str_param(p, "format").ok_or_else(|| bad("xref.setFormat", "missing format"))?.to_string(); s.edit(|d, _| { if d.xref_format(&format).is_none() { return Err(bad("xref.setFormat", format!("no cross-reference format `{format}`"))); } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let x = st.xrefs.get_mut(k).ok_or_else(|| bad("xref.setFormat", "no such cross-reference"))?; std::sync::Arc::make_mut(x).format = format; st.rev = st.rev.wrapping_add(1); Ok(Value::Null) }) } #[cfg(test)] mod tests { use super::*; #[test] fn cross_reference_follows_its_destination() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap(); // A heading on page 3 and a referring frame on page 1. let sp = s.doc().unwrap().doc.page_loc(2).unwrap().0; let r = s .execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Results: what we found"})) .unwrap(); let head = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "See ."})).unwrap(); let src = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story; s.execute("text.select", &json!({"story": src.0, "anchor": 4, "focus": 4})).unwrap(); s.execute("xref.insert", &json!({"paragraph": "Results"})).unwrap(); let l = s.execute("xref.list", &json!({})).unwrap(); assert_eq!(l[0]["text"], "\u{201C}Results: what we found\u{201D} on page 3"); // Edit the heading: the reference follows without an update step. // The paragraph now starts with its (zero-width) anchor mark. let at = designcraft_doc::ANCHOR_MARK.len_utf8(); s.execute("text.select", &json!({"story": head.0, "anchor": at, "focus": at})).unwrap(); s.execute("text.insert", &json!({"text": "Key "})).unwrap(); let l = s.execute("xref.list", &json!({})).unwrap(); assert_eq!(l[0]["text"], "\u{201C}Key Results: what we found\u{201D} on page 3"); // Formats. s.execute("xref.setFormat", &json!({"story": src.0, "index": 0, "format": "Partial Paragraph"})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Key Results\u{201D}"); s.execute("xref.defineFormat", &json!({"name": "Short", "definition": "(p. )"})).unwrap(); s.execute("xref.setFormat", &json!({"story": src.0, "index": 0, "format": "Short"})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); // The generated text is composed into the source frame. let d = s.doc().unwrap().doc.clone(); let cs = s.cache.get(&d, src, None); let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); assert!(glyphs >= "See (p. 3).".replace(' ', "").len(), "{glyphs}"); // Deleting the destination paragraph's anchor leaves an unresolved reference. let a = s.execute("anchor.list", &json!({})).unwrap(); assert_eq!(a[0]["page"], "3"); s.execute("text.select", &json!({"story": head.0, "anchor": 0, "focus": 3})).unwrap(); s.execute("text.delete", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "??"); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "(p. 3)"); } #[test] fn cross_reference_into_a_story_with_a_running_header_composes() { let mut s = Session::new(); s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); let head = s.execute("frame.create", &json!({"rect": [72, 72, 400, 120], "content": "text", "text": "Chapter"})).unwrap()["story"].clone(); let body = s.execute("frame.create", &json!({"rect": [72, 200, 400, 300], "content": "text", "text": "See"})).unwrap()["story"].clone(); s.execute("text.select", &json!({"story": head, "anchor": 0, "focus": 0})).unwrap(); s.execute("variables.insert", &json!({"name": "Running Header"})).unwrap(); s.execute("text.select", &json!({"story": body, "anchor": 3, "focus": 3})).unwrap(); s.execute("xref.insert", &json!({"story": head, "para": 0})).unwrap(); // The cross-reference index and the running-header index used to build each other without end. assert_eq!(s.execute("xref.list", &json!({})).unwrap()[0]["text"], "\u{201C}Chapter\u{201D} on page 1"); // The header shows the body's paragraph in front of its own text. let d = s.doc().unwrap().doc.clone(); let cs = s.cache.get(&d, StoryId(head.as_u64().unwrap()), None); let glyphs: usize = cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).count()).sum(); assert_eq!(glyphs, "SeeChapter".len()); } }