mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
Layout: Table of Contents dialog with entry styles, page number position and IDML TOC styles (#755)
This commit is contained in:
+199
-21
@@ -1,9 +1,9 @@
|
||||
//! 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). Update Table of
|
||||
//! Contents regenerates the same story after edits.
|
||||
//! 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};
|
||||
use designcraft_doc::{Align, Document, ParaAttrs, ParaFormat, ParagraphStyle, SpreadRef, StoryId, TabAlign, TabStop, Toc, TocEntry, TocPageNumber};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Table of Contents…",
|
||||
["Layout"],
|
||||
None,
|
||||
"{entries: [{style, level?: 1}] | styles: [names], title?: \"Contents\", pageNumbers?: true, page?: 1-based page for a new frame (its margins), rect?: [x0,y0,x1,y1] (spread coords)} → {story, entries}",
|
||||
"{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
|
||||
),
|
||||
@@ -44,20 +44,66 @@ fn has_toc(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
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>> {
|
||||
if let Some(v) = p.get("entries") {
|
||||
return serde_json::from_value(v.clone()).map_err(|e| bad("toc.generate", e.to_string()));
|
||||
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;
|
||||
}
|
||||
}
|
||||
if let Some(a) = p.get("styles").and_then(Value::as_array) {
|
||||
return Ok(a.iter().enumerate().filter_map(|(i, v)| v.as_str().map(|s| TocEntry { style: s.into(), level: (i + 1).min(9) as u8 })).collect());
|
||||
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}`"))),
|
||||
}
|
||||
Err(bad("toc.generate", "give `entries` or `styles`"))
|
||||
}
|
||||
|
||||
pub struct Found {
|
||||
pub level: u8,
|
||||
pub text: String,
|
||||
pub page: String,
|
||||
entry: usize,
|
||||
key: (usize, i64, i64),
|
||||
}
|
||||
|
||||
@@ -70,7 +116,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec
|
||||
}
|
||||
let cs = compose_story(d, *sid, &ComposeOptions::default());
|
||||
for (pi, r) in st.para_ranges().into_iter().enumerate() {
|
||||
let Some(e) = entries.iter().find(|e| e.style == st.paras[pi].style) else { continue };
|
||||
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))
|
||||
@@ -95,6 +141,7 @@ pub fn collect(d: &Document, entries: &[TocEntry], skip: Option<StoryId>) -> Vec
|
||||
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),
|
||||
});
|
||||
@@ -146,22 +193,35 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
|
||||
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".to_string());
|
||||
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');
|
||||
}
|
||||
text.push_str(&f.text);
|
||||
if toc.page_numbers {
|
||||
text.push('\t');
|
||||
text.push_str(&f.page);
|
||||
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(format!("TOC Level {}", f.level.max(1)));
|
||||
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();
|
||||
@@ -179,13 +239,29 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if entries.is_empty() {
|
||||
return Err(bad("toc.generate", "no entries"));
|
||||
}
|
||||
let title = str_param(p, "title").unwrap_or("Contents").to_string();
|
||||
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");
|
||||
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1;
|
||||
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| {
|
||||
// Reuse the existing TOC story when there is one; otherwise make a frame.
|
||||
let existing = d.toc.as_ref().map(|t| t.story).filter(|sid| d.story(*sid).is_some());
|
||||
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 => {
|
||||
@@ -198,7 +274,7 @@ fn generate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(sid, w)
|
||||
}
|
||||
};
|
||||
let toc = Toc { story, title: title.clone(), entries: entries.clone(), page_numbers };
|
||||
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}))
|
||||
@@ -237,4 +313,106 @@ mod tests {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,3 +251,33 @@ fn named_lists_round_trip_through_idml() {
|
||||
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
|
||||
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_of_contents_definition_round_trips_through_idml_and_updates() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let page = s.doc().unwrap().doc.page_count() + 1;
|
||||
s.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
|
||||
let basic = designcraft_doc::story::BASIC_PARAGRAPH;
|
||||
s.execute(
|
||||
"toc.generate",
|
||||
&json!({"page": page, "title": "Inside", "titleStyle": basic, "entries": [
|
||||
{"style": "Cover Title", "level": 1, "pageNumber": "before", "separator": " ^ "},
|
||||
{"style": "Headline", "level": 2, "entryStyle": basic, "pageNumber": "none"},
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let toc_text = |s: &Session| {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.story(d.toc.as_ref().unwrap().story).unwrap().text.clone()
|
||||
};
|
||||
let before = s.doc().unwrap().doc.toc.clone().unwrap();
|
||||
let text = toc_text(&s);
|
||||
let b64 = s.execute("file.exportIdml", &json!({})).unwrap()["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.openIdml", &json!({"base64": b64, "name": "toc.idml"})).unwrap();
|
||||
let after = s.doc().unwrap().doc.toc.clone().expect("the TOC definition is imported");
|
||||
assert_eq!((&after.title, &after.title_style, &after.entries), (&before.title, &before.title_style, &before.entries));
|
||||
assert_eq!(toc_text(&s), text);
|
||||
s.execute("toc.update", &json!({})).unwrap();
|
||||
assert_eq!(toc_text(&s), text);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user