Files
designcraft/crates/doc/src/xref.rs
T
aionggandBrandon Thomas 70974afc66 Lists: numbering formats and expressions from IDML; an empty paragraph gets no bullet or number (#298)
* Lists: numbering formats from IDML (A, B, C; i, ii, iii; 01, 02 …)

A paragraph's numbering format, number expression, bullet character and
text after the bullet import from IDML and export back. Numbered labels
come from the expression ("^#.^t", "Tabel ^#^t") with its
metacharacters expanded, in place of a fixed "n." label. Adds 001 and
0001 number styles, keeps letter and roman labels short for huge
numbers, and counts saturate instead of overflowing.

* Lists: an empty paragraph gets no bullet or number

A list paragraph with no characters before its return draws no label,
and numbering carries on past it: in a story's own list, in named lists
(also across stories) and in cross-reference paragraph numbers.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:08:56 -04:00

425 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Text anchors and cross-references (Type › Hyperlinks & Cross-References).
//!
//! A text anchor is a zero-width [`ANCHOR_MARK`] character; the story's [`Story::anchors`] holds
//! one [`TextAnchor`] per mark in text order (like footnotes). A cross-reference is an
//! [`XREF_MARK`] character with one [`CrossRef`] per mark in [`Story::xrefs`]; it shows text
//! generated from its format (paragraph text, page number, …) and is resolved at composition, so
//! it never goes stale (InDesign's cross-references need Update Cross-References).
//!
//! Anchor ids are document-unique: `story id << 24 | n` when created, kept when text moves.
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::Document;
use crate::ids::StoryId;
use crate::story::Story;
/// A text anchor (destination of cross-references and hyperlinks).
pub const ANCHOR_MARK: char = '\u{E00B}';
/// A cross-reference source (generated text).
pub const XREF_MARK: char = '\u{E00C}';
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TextAnchor {
pub id: u64,
/// Shown in the Hyperlinks panel; paragraph destinations get a generated name.
pub name: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CrossRef {
/// The destination anchor (0 = unresolved, e.g. pasted without its destination).
pub target: u64,
/// Name of the format in [`Document::xref_formats`].
pub format: String,
}
/// A cross-reference format: building blocks between literal text.
///
/// Blocks: `<fullPara />`, `<paraText />` (without the list number), `<paraNum />`,
/// `<pageNum />`, `<txtAnchrName />`, `<chapNum />`, `<fileName />`,
/// `<partialPara delim="x" includeDelim="false" />`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct XrefFormat {
pub name: String,
pub definition: String,
}
pub fn default_formats() -> Vec<XrefFormat> {
let f = |name: &str, definition: &str| XrefFormat { name: name.into(), definition: definition.into() };
vec![
f("Full Paragraph & Page Number", "\"<fullPara />\" on page <pageNum />"),
f("Full Paragraph", "\"<fullPara />\""),
f("Paragraph Text & Page Number", "\"<paraText />\" on page <pageNum />"),
f("Paragraph Text", "\"<paraText />\""),
f("Paragraph Number & Page Number", "<paraNum /> on page <pageNum />"),
f("Paragraph Number", "<paraNum />"),
f("Text Anchor Name & Page Number", "\"<txtAnchrName />\" on page <pageNum />"),
f("Text Anchor Name", "\"<txtAnchrName />\""),
f("Page Number", "page <pageNum />"),
f("Partial Paragraph & Page Number", "\"<partialPara delim=\":\" includeDelim=\"false\" />\" on page <pageNum />"),
f("Partial Paragraph", "\"<partialPara delim=\":\" includeDelim=\"false\" />\""),
]
}
/// Values a format can show for one destination.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct XrefValues {
pub para: String,
pub para_num: String,
pub page: String,
pub anchor_name: String,
pub chapter: String,
pub file_name: String,
}
/// Expand a format definition.
pub fn expand(definition: &str, v: &XrefValues) -> String {
let mut out = String::new();
let mut rest = definition;
while let Some(i) = rest.find('<') {
out.push_str(&rest[..i]);
let Some(j) = rest[i..].find('>') else {
out.push_str(&rest[i..]);
return out;
};
let tag = rest[i + 1..i + j].trim().trim_end_matches('/').trim();
let name = tag.split_whitespace().next().unwrap_or("");
match name {
"fullPara" => {
if !v.para_num.is_empty() {
out.push_str(&v.para_num);
out.push(' ');
}
out.push_str(&v.para);
}
"paraText" => out.push_str(&v.para),
"paraNum" => out.push_str(&v.para_num),
"pageNum" => out.push_str(&v.page),
"txtAnchrName" => out.push_str(&v.anchor_name),
"chapNum" => out.push_str(&v.chapter),
"fileName" => out.push_str(&v.file_name),
"partialPara" => {
let delim = attr(tag, "delim").unwrap_or_default();
let include = attr(tag, "includeDelim").is_some_and(|x| x == "true");
match (!delim.is_empty()).then(|| v.para.find(&delim)).flatten() {
Some(k) => out.push_str(&v.para[..if include { k + delim.len() } else { k }]),
None => out.push_str(&v.para),
}
}
_ => out.push_str(&rest[i..i + j + 1]),
}
rest = &rest[i + j + 1..];
}
out.push_str(rest);
typographic_quotes(&out)
}
/// Straight double quotes in format text become typographer's quotes.
fn typographic_quotes(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut prev = ' ';
for c in s.chars() {
out.push(match c {
'"' if prev.is_whitespace() || matches!(prev, '(' | '[') => '\u{201C}',
'"' => '\u{201D}',
c => c,
});
prev = c;
}
out
}
fn attr(tag: &str, key: &str) -> Option<String> {
let k = tag.find(&format!("{key}=\""))? + key.len() + 2;
let e = tag[k..].find('"')?;
Some(tag[k..k + e].to_string())
}
impl Story {
fn marks_before(&self, mark: char, pos: usize) -> usize {
let pos = crate::story::floor_char_boundary(&self.text, pos);
self.text[..pos].matches(mark).count()
}
/// Byte offset of an anchor's mark.
pub fn anchor_pos(&self, id: u64) -> Option<usize> {
let k = self.anchors.iter().position(|a| a.id == id)?;
self.text.match_indices(ANCHOR_MARK).nth(k).map(|(i, _)| i)
}
/// The cross-reference whose mark is at byte `pos`.
pub fn xref_at(&self, pos: usize) -> Option<&CrossRef> {
if !self.text[pos.min(self.text.len())..].starts_with(XREF_MARK) {
return None;
}
self.xrefs.get(self.marks_before(XREF_MARK, pos)).map(|x| &**x)
}
fn next_anchor_id(&self) -> u64 {
let base = self.id.0 << 24;
self.anchors.iter().filter(|a| a.id >> 24 == self.id.0).map(|a| a.id + 1).max().unwrap_or(base + 1).max(base + 1)
}
/// Insert a text anchor at `pos`. Returns its id.
pub fn insert_anchor(&mut self, pos: usize, name: &str) -> u64 {
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
self.insert(pos, &ANCHOR_MARK.to_string());
let k = self.marks_before(ANCHOR_MARK, pos);
let a = Arc::make_mut(&mut self.anchors[k]);
a.name = name.to_string();
a.id
}
/// Insert a cross-reference at `pos` (replacing nothing).
pub fn insert_xref(&mut self, pos: usize, xref: CrossRef) {
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
self.insert(pos, &XREF_MARK.to_string());
let k = self.marks_before(XREF_MARK, pos);
self.xrefs[k] = Arc::new(xref);
}
/// Keep anchor/xref lists in step with an insertion of `text` at `pos` (before it happens).
pub(crate) fn marks_inserted(&mut self, pos: usize, text: &str) {
let n = text.matches(ANCHOR_MARK).count();
if n > 0 {
let k = self.marks_before(ANCHOR_MARK, pos);
for i in 0..n {
let id = self.next_anchor_id();
self.anchors.insert(k + i, Arc::new(TextAnchor { id, name: format!("Anchor {}", id & 0xFF_FFFF) }));
}
}
let n = text.matches(XREF_MARK).count();
if n > 0 {
let k = self.marks_before(XREF_MARK, pos);
for i in 0..n {
self.xrefs.insert(k + i, Arc::new(CrossRef { target: 0, format: String::new() }));
}
}
let n = text.matches(crate::index::INDEX_MARK).count();
if n > 0 {
let k = self.marks_before(crate::index::INDEX_MARK, pos);
for i in 0..n {
self.index_refs.insert(k + i, Arc::new(crate::index::IndexRef::default()));
}
}
let n = text.matches(crate::endnotes::NOTE_MARK).count();
if n > 0 {
let k = self.marks_before(crate::endnotes::NOTE_MARK, pos);
for i in 0..n {
let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0) + 1;
self.editorial.insert((k + i).min(self.editorial.len()), Arc::new(crate::endnotes::EditorialNote { id, ..Default::default() }));
}
}
let n = text.matches(crate::anchored::OBJECT_MARK).count();
if n > 0 {
let k = self.marks_before(crate::anchored::OBJECT_MARK, pos);
for i in 0..n {
self.objects.insert(k + i, Arc::new(crate::anchored::AnchoredObject::default()));
}
}
}
/// Insert an index page reference at `pos`.
pub fn insert_index_ref(&mut self, pos: usize, r: crate::index::IndexRef) {
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
self.insert(pos, &crate::index::INDEX_MARK.to_string());
let k = self.marks_before(crate::index::INDEX_MARK, pos);
self.index_refs[k] = Arc::new(r);
}
/// Keep anchor/xref lists in step with a deletion of `a..b` (before it happens).
pub(crate) fn marks_deleted(&mut self, a: usize, b: usize) {
for mark in [ANCHOR_MARK, XREF_MARK, crate::index::INDEX_MARK, crate::anchored::OBJECT_MARK, crate::endnotes::NOTE_MARK] {
let n = self.text[a..b].matches(mark).count();
if n == 0 {
continue;
}
let k = self.marks_before(mark, a);
if mark == ANCHOR_MARK {
self.anchors.drain(k..(k + n).min(self.anchors.len()));
} else if mark == XREF_MARK {
self.xrefs.drain(k..(k + n).min(self.xrefs.len()));
} else if mark == crate::index::INDEX_MARK {
self.index_refs.drain(k..(k + n).min(self.index_refs.len()));
} else if mark == crate::endnotes::NOTE_MARK {
self.editorial.drain(k..(k + n).min(self.editorial.len()));
} else {
self.objects.drain(k..(k + n).min(self.objects.len()));
}
}
}
/// Make the lists match the marks after wholesale text changes.
pub fn fix_marks(&mut self) {
let n = self.text.matches(ANCHOR_MARK).count();
while self.anchors.len() > n {
self.anchors.pop();
}
while self.anchors.len() < n {
let id = self.next_anchor_id();
self.anchors.push(Arc::new(TextAnchor { id, name: format!("Anchor {}", id & 0xFF_FFFF) }));
}
let n = self.text.matches(XREF_MARK).count();
self.xrefs.truncate(n);
while self.xrefs.len() < n {
self.xrefs.push(Arc::new(CrossRef { target: 0, format: String::new() }));
}
let n = self.text.matches(crate::index::INDEX_MARK).count();
self.index_refs.truncate(n);
while self.index_refs.len() < n {
self.index_refs.push(Arc::new(crate::index::IndexRef::default()));
}
let n = self.text.matches(crate::endnotes::NOTE_MARK).count();
self.editorial.truncate(n);
while self.editorial.len() < n {
let id = self.editorial.iter().map(|x| x.id).max().unwrap_or(0) + 1;
self.editorial.push(Arc::new(crate::endnotes::EditorialNote { id, ..Default::default() }));
}
let n = self.text.matches(crate::anchored::OBJECT_MARK).count();
self.objects.truncate(n);
while self.objects.len() < n {
self.objects.push(Arc::new(crate::anchored::AnchoredObject::default()));
}
}
pub(crate) fn check_marks(&self) -> Result<(), String> {
let (a, x) = (self.text.matches(ANCHOR_MARK).count(), self.text.matches(XREF_MARK).count());
if a != self.anchors.len() || x != self.xrefs.len() {
return Err(format!("story {}: {a} anchor / {x} xref marks for {} anchors / {} xrefs", self.id.0, self.anchors.len(), self.xrefs.len()));
}
let o = self.text.matches(crate::anchored::OBJECT_MARK).count();
if o != self.objects.len() {
return Err(format!("story {}: {o} object marks for {} anchored objects", self.id.0, self.objects.len()));
}
let e = self.text.matches(crate::endnotes::NOTE_MARK).count();
if e != self.editorial.len() {
return Err(format!("story {}: {e} note marks for {} notes", self.id.0, self.editorial.len()));
}
let i = self.text.matches(crate::index::INDEX_MARK).count();
if i != self.index_refs.len() {
return Err(format!("story {}: {i} index marks for {} references", self.id.0, self.index_refs.len()));
}
Ok(())
}
}
impl Document {
/// (story, byte) of anchor `id`.
pub fn find_anchor(&self, id: u64) -> Option<(StoryId, usize)> {
if id == 0 {
return None;
}
// Fast path: the story the id was created in.
let home = StoryId(id >> 24);
if let Some(p) = self.story(home).and_then(|s| s.anchor_pos(id)) {
return Some((home, p));
}
self.stories.values().find_map(|s| s.anchor_pos(id).map(|p| (s.id, p)))
}
pub fn anchor(&self, id: u64) -> Option<&TextAnchor> {
self.stories.values().find_map(|s| s.anchors.iter().find(|a| a.id == id).map(|a| &**a))
}
/// The anchor at the start of paragraph containing `pos` in `sid`, creating one if needed
/// (cross-references to paragraphs point at such an anchor).
pub fn paragraph_anchor(&mut self, sid: StoryId, pos: usize) -> Option<u64> {
let st = self.story(sid)?;
let pi = st.para_at(pos);
let r = st.para_ranges()[pi].clone();
if st.text[r.clone()].starts_with(ANCHOR_MARK) {
let k = st.marks_before(ANCHOR_MARK, r.start);
return st.anchors.get(k).map(|a| a.id);
}
let name: String = clean(&st.text[r.clone()]).chars().take(40).collect();
let st = self.story_mut(sid)?;
Some(st.insert_anchor(r.start, &name))
}
/// Values for the paragraph holding anchor `id` (page left empty: it needs composition).
pub fn xref_values(&self, id: u64) -> Option<XrefValues> {
let (sid, pos) = self.find_anchor(id)?;
let st = self.story(sid)?;
let pi = st.para_at(pos);
let ranges = st.para_ranges();
let r = ranges[pi].clone();
let (pp, _) = self.styles.resolve_para(&st.paras[pi]);
let para_num = match pp.list_type {
crate::ListType::Numbers if !r.is_empty() => {
// Count the run of numbered paragraphs before this one (empty ones have no number).
let n = (0..=pi)
.rev()
.take_while(|&k| self.styles.resolve_para(&st.paras[k]).0.list_type == crate::ListType::Numbers)
.filter(|&k| ranges.get(k).is_some_and(|r| !r.is_empty()))
.count();
pp.number_label(u32::try_from(n).unwrap_or(u32::MAX)).trim_end().to_string()
}
_ => String::new(),
};
Some(XrefValues {
para: clean(&st.text[r]),
para_num,
page: String::new(),
anchor_name: self.anchor(id).map(|a| a.name.clone()).unwrap_or_default(),
chapter: self.settings.chapter_number.to_string(),
file_name: self.title.clone(),
})
}
pub fn xref_format(&self, name: &str) -> Option<&XrefFormat> {
self.xref_formats.iter().find(|f| f.name == name)
}
}
/// Paragraph text without markers, tabs and breaks.
pub fn clean(s: &str) -> String {
let t: String =
s.chars().filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c)).map(|c| if c == '\t' || c == '\u{2028}' { ' ' } else { c }).collect();
t.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_expand() {
let v =
XrefValues { para: "Intro: the start".into(), page: "7".into(), para_num: "2.".into(), anchor_name: "A".into(), ..Default::default() };
let fs = default_formats();
let get = |n: &str| expand(&fs.iter().find(|f| f.name == n).unwrap().definition, &v);
assert_eq!(get("Full Paragraph & Page Number"), "\u{201C}2. Intro: the start\u{201D} on page 7");
assert_eq!(get("Paragraph Text"), "\u{201C}Intro: the start\u{201D}");
assert_eq!(get("Partial Paragraph"), "\u{201C}Intro\u{201D}");
assert_eq!(get("Page Number"), "page 7");
assert_eq!(get("Text Anchor Name"), "\u{201C}A\u{201D}");
assert_eq!(expand("see <bogus /> x", &v), "see <bogus /> x");
}
#[test]
fn marks_follow_edits() {
let mut s = Story::with_text(StoryId(3), "Heading\nBody text.", Default::default());
let a = s.insert_anchor(0, "Heading");
assert_eq!(a >> 24, 3);
s.insert_xref(13, CrossRef { target: a, format: "Page Number".into() });
s.check().unwrap();
assert_eq!(s.anchor_pos(a), Some(0));
s.insert(0, "New ");
assert_eq!(s.anchor_pos(a), Some(4));
let x = s.text.find(XREF_MARK).unwrap();
assert_eq!(s.xref_at(x).unwrap().target, a);
s.delete(x..x + XREF_MARK.len_utf8());
s.check().unwrap();
assert!(s.xrefs.is_empty());
s.delete(0..8);
s.check().unwrap();
assert!(s.anchors.is_empty());
}
}