mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
`anchor.create` took any name, including one another anchor already had, so a
document could hold two anchors called "Photo 1". Every reference to the name
then means whichever the search finds first: `xref.insert {"anchor": "Photo 1"}`
and `hyperlink.create {"anchor": "Photo 1"}` take the first, and an export
writes both destinations under the one name. A name nobody can resolve is not a
name.
A name already in the document is a usage error now, naming it and where to
look: "a text anchor named `Photo 1` is already in this document (anchor.list
names them)". A name that is empty or only spaces is refused too — a
cross-reference built from `<txtAnchrName />` would show nothing.
Case still distinguishes names, as it does elsewhere in the document, and
nothing about existing anchors changes: the check is on creation.
The test covers the same name in the same story and in another one, the empty
names, that only one anchor exists afterwards, and that another name and a
name differing in case are both fine. Without the check the duplicate is
created again.
Not from a ticket: found probing `anchor.create`.
283 lines
16 KiB
Rust
283 lines
16 KiB
Rust
//! 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<CommandSpec> {
|
||
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; the name is the document's, so it must not be one another anchor already has",
|
||
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: <fullPara />, <paraText />, <paraNum />, <pageNum />, <txtAnchrName />, <chapNum />, <fileName />, <partialPara delim=\":\" includeDelim=\"false\" />; 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<u64> {
|
||
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<Value> {
|
||
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<Value> {
|
||
let name = str_param(p, "name").ok_or_else(|| bad("anchor.create", "missing name"))?.to_string();
|
||
if name.trim().is_empty() {
|
||
return Err(bad("anchor.create", "a text anchor needs a name (a cross-reference and a hyperlink find it by that name)"));
|
||
}
|
||
// Two anchors of the same name made every reference to it ambiguous: `xref.insert` and
|
||
// `hyperlink.create` take the first they find, and a file writes both under one name.
|
||
if s.doc()?.doc.stories.values().any(|st| st.anchors.iter().any(|a| a.name == name)) {
|
||
return Err(bad("anchor.create", format!("a text anchor named `{name}` is already in this document (anchor.list names them)")));
|
||
}
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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<Value> {
|
||
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. <pageNum />)"})).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)");
|
||
}
|
||
|
||
/// Two text anchors of the same name made every reference to it ambiguous: `xref.insert
|
||
/// {"anchor": "A"}` and `hyperlink.create {"anchor": "A"}` take the first they find, and an
|
||
/// exported file writes both destinations under one name.
|
||
#[test]
|
||
fn a_text_anchor_name_is_the_documents_so_it_is_not_reused() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
|
||
let a = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "Photo one here"})).unwrap();
|
||
let select = |s: &mut Session, story: serde_json::Value, from: usize| {
|
||
s.execute("text.select", &json!({"story": story, "anchor": from, "focus": from})).unwrap();
|
||
};
|
||
select(&mut s, a["story"].clone(), 0);
|
||
s.execute("anchor.create", &json!({"name": "Photo 1"})).unwrap();
|
||
|
||
// The same name again, in the same story and in another one.
|
||
select(&mut s, a["story"].clone(), 10);
|
||
let e = s.execute("anchor.create", &json!({"name": "Photo 1"})).expect_err("refused").to_string();
|
||
assert!(e.contains("already in this document"), "{e}");
|
||
let sp = s.doc().unwrap().doc.page_loc(1).unwrap().0;
|
||
let b = s.execute("frame.create", &json!({"spread": sp, "rect": [72, 72, 400, 200], "content": "text", "text": "Photo two"})).unwrap();
|
||
select(&mut s, b["story"].clone(), 0);
|
||
assert!(s.execute("anchor.create", &json!({"name": "Photo 1"})).is_err(), "not in another story either");
|
||
// A name of spaces is not a name.
|
||
assert!(s.execute("anchor.create", &json!({"name": " "})).is_err());
|
||
assert!(s.execute("anchor.create", &json!({"name": ""})).is_err());
|
||
|
||
// One anchor, so a reference by name can only mean that one.
|
||
let anchors = s.execute("anchor.list", &json!({})).unwrap();
|
||
assert_eq!(anchors.as_array().unwrap().len(), 1, "{anchors}");
|
||
// Another name is fine.
|
||
s.execute("anchor.create", &json!({"name": "Photo 2"})).unwrap();
|
||
assert_eq!(s.execute("anchor.list", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||
// The check is on the name, not on where it is: a name that differs only in case is a
|
||
// different name, as it is everywhere else in the document.
|
||
select(&mut s, b["story"].clone(), 0);
|
||
s.execute("anchor.create", &json!({"name": "photo 1"})).expect("a different name");
|
||
}
|
||
|
||
#[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());
|
||
}
|
||
}
|