Files
designcraft/crates/engine/src/cmd/toc.rs
T

419 lines
19 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.
//! Layout → Table of Contents: list the paragraphs of chosen styles with their page names, as a
//! story with "TOC Title" / "TOC Level n" styles (right tab with dot leader) or the entry styles
//! chosen per style. Update Table of Contents regenerates the same story after edits.
use designcraft_compose::{ComposeOptions, caret, compose_story};
use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber};
use designcraft_geom::Rect;
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!(
"toc.generate",
"Table of Contents…",
["Layout"],
None,
"{entries: [{style, level?: 1 (1-9), entryStyle?: paragraph style of its entries (default \"TOC Level n\"), pageNumber?: \"after\"|\"before\"|\"none\", separator?: text between entry and number (default a tab)}] | styles: [names] (levels 1, 2, …), title?: \"Contents\", titleStyle?: paragraph style of the title (default \"TOC Title\"), pageNumbers?: true, replace?: true (regenerate the existing table of contents in place; false makes a new frame), page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
has_doc,
generate
),
cmd!("toc.update", "Update Table of Contents", ["Layout"], None, "{} — regenerate the existing table of contents", has_toc, |s, _| {
s.edit(|d, _| {
let toc = d.toc.clone().ok_or_else(|| bad("toc.update", "no table of contents"))?;
let n = fill(d, &toc, None)?;
Ok(json!({"story": toc.story.0, "entries": n}))
})
}),
cmd!(query "toc.entries", "Table of Contents Entries", [], None, "{entries | styles} → [{level, text, page}] without changing the document", has_doc, |s, p| {
let entries = entries_param(p)?;
let d = &s.doc()?.doc;
let skip = d.toc.as_ref().map(|t| t.story);
Ok(json!(collect(d, &entries, skip).into_iter().map(|e| json!({"level": e.level, "text": e.text, "page": e.page})).collect::<Vec<_>>()))
}),
]
}
fn has_toc(s: &Session) -> std::result::Result<(), String> {
match s.doc() {
Ok(st) if st.doc.toc.as_ref().is_some_and(|t| st.doc.story(t.story).is_some()) => Ok(()),
_ => Err("the document has no table of contents".into()),
}
}
const MAX_ENTRIES: usize = 64;
const MAX_NAME: usize = 256;
const MAX_TITLE: usize = 1024;
const MAX_SEPARATOR: usize = 64;
fn entries_param(p: &Value) -> Result<Vec<TocEntry>> {
let mut entries: Vec<TocEntry> = if let Some(v) = p.get("entries") {
if v.as_array().is_some_and(|a| a.len() > MAX_ENTRIES) {
return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} entries")));
}
serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()))?
} else if let Some(a) = p.get("styles").and_then(Value::as_array) {
if a.len() > MAX_ENTRIES {
return Err(bad("toc.generate", format!("at most {MAX_ENTRIES} styles")));
}
a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry::new(s, (i + 1).min(9) as u8))).collect()
} else {
return Err(bad("toc.generate", "give `entries` or `styles`"));
};
for e in &mut entries {
if !(1..=9).contains(&e.level) {
return Err(bad("toc.generate", format!("level {} of `{}` is not 1–9", e.level, e.style)));
}
if e.style.chars().count() > MAX_NAME || e.entry_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) {
return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters")));
}
if let Some(sep) = &mut e.separator {
if sep.chars().count() > MAX_SEPARATOR {
return Err(bad("toc.generate", format!("the separator is at most {MAX_SEPARATOR} characters")));
}
*sep = one_line(sep);
}
// The defaults are stored as `None`, so a definition reads the same however it was made.
if e.entry_style.as_deref() == Some("") {
e.entry_style = None;
}
if e.separator.as_deref() == Some("\t") {
e.separator = None;
}
}
Ok(entries)
}
/// Paragraph breaks in a title or separator would shift the paragraph styles after them.
fn one_line(s: &str) -> String {
s.chars().map(|c| if matches!(c, '\n' | '\r' | '\u{2029}') { ' ' } else { c }).collect()
}
fn para_style_exists(d: &Document, name: &str) -> Result<()> {
match d.styles.para(name) {
Some(_) => Ok(()),
None => Err(bad("toc.generate", format!("no paragraph style `{name}`"))),
}
}
pub struct Found {
pub level: u8,
pub text: String,
pub page: String,
entry: usize,
key: (usize, i64, i64),
}
/// Paragraphs in `entries` styles across the document, in page order.
pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec<Found> {
let mut out = Vec::new();
for (sid, st) in &d.stories {
if Some(*sid) == skip || !st.paras.iter().any(|f| entries.iter().any(|e| e.style == f.style)) {
continue;
}
let cs = compose_story(d, *sid, &ComposeOptions::default());
for (pi, r) in st.para_ranges().into_iter().enumerate() {
let Some((ei, e)) = entries.iter().enumerate().find(|(_, e)| e.style == st.paras[pi].style) else { continue };
let text: String = st.text[r.clone()]
.chars()
.filter(|c| !('\u{E000}'..='\u{E1FF}').contains(c))
.map(|c| if c == '\t' || c == '\u{2028}' { ' ' } else { c })
.collect();
let text = text.trim().to_string();
if text.is_empty() {
continue;
}
// Overset paragraphs have no page and are left out (as InDesign does).
let Some((fi, x, baseline, ..)) = caret(&cs, r.start) else { continue };
let Some(ft) = cs.frames.get(fi) else { continue };
if r.start >= ft.range.end && r.start != ft.range.start {
continue;
}
let Some(loc) = d.find(ft.frame) else { continue };
let SpreadRef::Doc(si) = loc.spread else { continue };
let Some(item) = d.item(ft.frame) else { continue };
let b = item.bounds();
let in_spread = d.spreads[si].page_at_x(b.center().x).unwrap_or(0);
let abs = d.first_page_of_spread(si) + in_spread;
out.push(Found {
level: e.level,
text,
entry: ei,
page: d.page_name(abs),
key: (abs, ((b.y0 + baseline) * 10.0) as i64, ((b.x0 + x) * 10.0) as i64),
});
}
}
out.sort_by_key(|f| f.key);
out
}
fn ensure_styles(d: &mut Document, levels: u8, width: f64) {
let mut want = vec![("TOC Title".to_string(), ParaAttrs { space_after: Some(12.0), ..Default::default() }, Some(16.0))];
for l in 1..=levels.max(1) {
let indent = (l as f64 - 1.0) * 12.0;
want.push((
format!("TOC Level {l}"),
ParaAttrs {
left_indent: Some(indent),
space_before: Some(if l == 1 { 4.0 } else { 0.0 }),
align: Some(Align::Left),
tabs: Some(vec![TabStop { position: width.max(36.0), align: TabAlign::Right, leader: ". ".into(), align_on: String::new() }]),
..Default::default()
},
None,
));
}
let missing: Vec<_> = want.into_iter().filter(|(n, ..)| d.styles.para(n).is_none()).collect();
if missing.is_empty() {
return;
}
let st = d.styles_mut();
for (name, para, size) in missing {
let chars = designcraft_doc::CharAttrs { size, ..Default::default() };
st.paragraph.push(ParagraphStyle {
name,
based_on: Some(designcraft_doc::story::BASIC_PARAGRAPH.into()),
next_style: None,
para,
chars,
shortcut: String::new(),
});
}
}
/// Rewrite the TOC story; returns the number of entries.
fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
let found = collect(d, &toc.entries, Some(toc.story));
let levels = toc.entries.iter().map(|e| e.level).max().unwrap_or(1);
let width = width.unwrap_or_else(|| {
d.story(toc.story).and_then(|st| st.frames.first()).and_then(|f| d.item(*f)).map(|i| i.text_area().width()).unwrap_or(300.0)
});
ensure_styles(d, levels, width);
for name in toc.entries.iter().filter_map(|e| e.entry_style.as_deref()).chain(toc.title_style.as_deref()) {
para_style_exists(d, name)?;
}
let mut text = String::new();
let mut styles = Vec::new();
if !toc.title.is_empty() {
text.push_str(&toc.title);
styles.push(toc.title_style.clone().unwrap_or_else(|| "TOC Title".into()));
}
for f in &found {
let Some(e) = toc.entries.get(f.entry) else { continue };
if !text.is_empty() {
text.push('\n');
}
let sep = e.separator.as_deref().unwrap_or("\t");
match e.page_number {
TocPageNumber::After if toc.page_numbers => {
text.push_str(&f.text);
text.push_str(sep);
text.push_str(&f.page);
}
TocPageNumber::Before if toc.page_numbers => {
text.push_str(&f.page);
text.push_str(sep);
text.push_str(&f.text);
}
_ => text.push_str(&f.text),
}
styles.push(e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", f.level.max(1))));
}
let st = d.story_mut(toc.story).ok_or(designcraft_doc::DocError::NoStory(toc.story))?;
let len = st.len();
st.replace(0..len, &text);
for (pi, r) in st.para_ranges().into_iter().enumerate() {
if let Some(name) = styles.get(pi) {
st.format_paras(r.start..r.start, |f| *f = ParaFormat { style: name.clone(), ..Default::default() });
}
}
Ok(found.len())
}
fn generate(s: &mut Session, p: &Value) -> Result<Value> {
let entries = entries_param(p)?;
if entries.is_empty() {
return Err(bad("toc.generate", "no entries"));
}
let title = str_param(p, "title").unwrap_or("Contents");
if title.chars().count() > MAX_TITLE {
return Err(bad("toc.generate", format!("the title is at most {MAX_TITLE} characters")));
}
let title = one_line(title);
let title_style = str_param(p, "titleStyle").filter(|n| !n.is_empty()).map(str::to_string);
if title_style.as_ref().is_some_and(|n| n.chars().count() > MAX_NAME) {
return Err(bad("toc.generate", format!("style names are at most {MAX_NAME} characters")));
}
let page_numbers = p.get("pageNumbers").and_then(Value::as_bool).unwrap_or(true);
let replace = p.get("replace").and_then(Value::as_bool).unwrap_or(true);
let rect = super::rect_param(p, "rect");
if rect.is_some_and(|r| ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0) {
return Err(bad("toc.generate", "`rect` must be finite with a positive size"));
}
let page = p.get("page").and_then(Value::as_u64).map_or(Some(0), |n| usize::try_from(n.max(1) - 1).ok());
let page = page.ok_or_else(|| bad("toc.generate", "no such page"))?;
s.edit(|d, sel| {
for e in &entries {
para_style_exists(d, &e.style)?;
}
// Replace: regenerate the existing TOC story in place; otherwise make a frame.
let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| replace && d.story(*sid).is_some());
let (story, width) = match existing {
Some(sid) => (sid, None),
None => {
let (si, pi) = d.page_loc(page).ok_or_else(|| bad("toc.generate", format!("no page {}", page + 1)))?;
let r: Rect = rect.unwrap_or_else(|| d.spreads[si].pages[pi].margin_rect());
let layer = d.default_layer();
let (fid, sid) = d.add_text_frame(SpreadRef::Doc(si), r, layer, "", ParaFormat::default())?;
*sel = designcraft_doc::Selection::items(vec![fid]);
let w = d.item(fid).map(|i| i.text_area().width());
(sid, w)
}
};
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers, title_style: title_style.clone() };
let n = fill(d, &toc, width)?;
d.toc = Some(toc);
Ok(json!({"story": story.0, "entries": n}))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toc_lists_headings_in_page_order_and_updates() {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let pages = s.doc().unwrap().doc.page_count();
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
let r = s.execute("toc.generate", &json!({"styles": ["Headline", "Cover Title"], "page": pages + 1})).unwrap();
assert!(r["entries"].as_u64().unwrap() >= 2, "{r}");
let d = &s.doc().unwrap().doc;
let sid = StoryId(r["story"].as_u64().unwrap());
let text = d.story(sid).unwrap().text.clone();
let lines: Vec<&str> = text.split('\n').collect();
assert_eq!(lines[0], "Contents");
// Cover title (page 1) comes before the feature headline (page 2+).
let cover = lines.iter().position(|l| l.starts_with("The Quiet")).unwrap();
let head = lines.iter().position(|l| l.starts_with("Notes on the Grid")).unwrap();
assert!(cover < head);
assert!(lines[head].ends_with("\t2") || lines[head].contains('\t'), "{}", lines[head]);
assert_eq!(d.story(sid).unwrap().paras[head].style, "TOC Level 1");
assert!(d.styles.para("TOC Level 2").is_some());
// Edit a heading, then update.
let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap();
s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap();
s.execute("toc.update", &json!({})).unwrap();
let text = s.doc().unwrap().doc.story(sid).unwrap().text.clone();
assert!(text.contains("Thoughts on the Grid"), "{text}");
s.doc().unwrap().doc.check().unwrap();
}
fn sample_with_toc_page() -> (Session, usize) {
let mut s = Session::new();
s.execute("file.newSample", &json!({})).unwrap();
let pages = s.doc().unwrap().doc.page_count();
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
(s, pages + 1)
}
fn toc_lines(s: &Session) -> Vec<(String, String)> {
let d = &s.doc().unwrap().doc;
let st = d.story(d.toc.as_ref().unwrap().story).unwrap();
st.para_ranges().into_iter().zip(&st.paras).map(|(r, f)| (st.text[r].trim_end_matches('\n').to_string(), f.style.clone())).collect()
}
#[test]
fn toc_entry_style_page_number_position_and_separator_survive_update() {
let (mut s, page) = sample_with_toc_page();
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
s.execute(
"toc.generate",
&json!({"page": page, "title": "In This Issue", "titleStyle": basic, "entries": [
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " — "},
{"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"},
]}),
)
.unwrap();
let check = |s: &Session, headline: &str| {
let lines = toc_lines(s);
assert_eq!(lines[0], ("In This Issue".to_string(), basic.to_string()));
let cover = lines.iter().find(|(t, _)| t.contains("The Quiet")).unwrap();
assert!(cover.0.starts_with("1 — The Quiet"), "{cover:?}");
assert_eq!(cover.1, "TOC Level 1");
let head = lines.iter().find(|(t, _)| t.contains("on the Grid")).unwrap();
assert!(head.0.starts_with(headline) && !head.0.contains('\t'), "{head:?}");
assert_eq!(head.1, basic);
};
check(&s, "Notes on the Grid");
let d = &s.doc().unwrap().doc;
let hid = d.stories.iter().find(|(_, st)| st.text.starts_with("Notes on the Grid")).map(|(id, _)| *id).unwrap();
s.execute("story.replaceRange", &json!({"story": hid.0, "start": 0, "end": 5, "text": "Thoughts"})).unwrap();
s.execute("toc.update", &json!({})).unwrap();
check(&s, "Thoughts on the Grid");
}
#[test]
fn replace_regenerates_the_toc_in_place_and_off_makes_a_new_frame() {
let (mut s, page) = sample_with_toc_page();
let stories = |s: &Session| s.doc().unwrap().doc.stories.len();
let first = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page})).unwrap()["story"].clone();
let n = stories(&s);
let undo = s.doc().unwrap().history.undo.len();
let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "title": "Inside", "page": page, "replace": true})).unwrap();
assert_eq!(r["story"], first);
assert_eq!(stories(&s), n, "one table of contents");
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
assert_eq!(toc_lines(&s)[0].0, "Inside");
let r = s.execute("toc.generate", &json!({"styles": ["Headline"], "page": page, "replace": false})).unwrap();
assert_ne!(r["story"], first);
assert_eq!(stories(&s), n + 1);
assert_eq!(s.doc().unwrap().doc.toc.as_ref().unwrap().story.0, r["story"].as_u64().unwrap());
}
#[test]
fn hostile_toc_params_are_errors_that_leave_the_document_alone() {
let (mut s, page) = sample_with_toc_page();
let long = "x".repeat(5000);
let many: Vec<Value> = (0..65).map(|_| json!({"style": "Headline"})).collect();
for p in [
json!({"entries": [{"style": "Headline", "level": 0}]}),
json!({"entries": [{"style": "Headline", "level": 10}]}),
json!({"entries": [{"style": "Headline", "level": 300}]}),
json!({"entries": [{"style": "Headline", "level": -1}]}),
json!({"entries": [{"style": "Headline", "pageNumber": "sideways"}]}),
json!({"entries": [{"style": "No Such Style"}]}),
json!({"entries": [{"style": "Headline", "entryStyle": "No Such Style"}]}),
json!({"entries": [{"style": "Headline", "separator": long}]}),
json!({"entries": [{"style": long}]}),
json!({"entries": many}),
json!({"entries": "Headline"}),
json!({"entries": []}),
json!({"styles": ["Headline"], "titleStyle": "No Such Style"}),
json!({"styles": ["Headline"], "title": long}),
json!({"styles": ["Headline"], "rect": [100, 100, 50, 50]}),
json!({"styles": ["Headline"], "page": u64::MAX}),
json!([1, 2]),
] {
let rev = s.doc().unwrap().revision;
assert!(s.execute("toc.generate", &merge(&p, page)).is_err(), "{p}");
assert_eq!(s.doc().unwrap().revision, rev, "{p}");
assert!(s.doc().unwrap().doc.toc.is_none());
}
}
/// `p` with the TOC page added when it is an object and has none.
fn merge(p: &Value, page: usize) -> Value {
let mut p = p.clone();
if let Some(o) = p.as_object_mut() {
o.entry("page").or_insert(json!(page));
}
p
}
}