Layout: Table of Contents dialog with entry styles, page number position and IDML TOC styles (#755)

This commit is contained in:
aiongg committed 2026-10-13 20:41:56 +00:00
1 parent e862e0a613
commit 129adfa429
10 files changed
+641 -25

No files matched your search

+1
View File
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
+27
View File
@@ -584,6 +584,30 @@ pub struct TocEntry {
/// 1-based nesting level.
#[serde(default = "one")]
pub level: u8,
/// Paragraph style of this style's entries; `None` is the generated "TOC Level n" style.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_style: Option<String>,
#[serde(default)]
pub page_number: TocPageNumber,
/// Text between the entry and its page number; `None` is a tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub separator: Option<String>,
}
impl TocEntry {
pub fn new(style: impl Into<String>, level: u8) -> Self {
Self { style: style.into(), level, entry_style: None, page_number: TocPageNumber::After, separator: None }
}
}
/// Where an entry's page number goes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TocPageNumber {
#[default]
After,
Before,
None,
}
fn one() -> u8 {
@@ -598,6 +622,9 @@ pub struct Toc {
pub entries: Vec<TocEntry>,
#[serde(default)]
pub page_numbers: bool,
/// Paragraph style of the title; `None` is the generated "TOC Title" style.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title_style: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+199 -21
View File
@@ -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
}
}
+30
View File
@@ -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);
}
+45 -1
View File
@@ -16,6 +16,8 @@ use crate::{DOM_VERSION, MIMETYPE, base64_encode};
const PKG_NS: &str = "http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging";
const NO_PARA_ID: &str = "ParagraphStyle/$ID/[No paragraph style]";
const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents";
const TOC_STYLE_NAME: &str = "Table of Contents";
const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]";
/// Export options.
@@ -1049,9 +1051,46 @@ impl<'a> Ex<'a> {
let mut ro = El::new("RootObjectStyleGroup").attr("Self", self.fresh());
og.emit(&mut ro, "ObjectStyleGroup", "");
root.push(ro);
if let Some(toc) = d.toc.as_ref().filter(|t| d.story(t.story).is_some()) {
root.push(self.toc_style_el(toc));
}
document(&root)
}
/// The document's table of contents as a TOC style; its story names it in `AppliedTOCStyle`.
fn toc_style_el(&mut self, toc: &designcraft_doc::Toc) -> El {
let para = |n: &str| names::style_self("ParagraphStyle", PARA_BUILTINS, n);
let mut el = El::new("TOCStyle")
.attr("Self", TOC_STYLE_ID)
.attr("Name", TOC_STYLE_NAME)
.attr("Title", &toc.title)
.attr("TitleStyle", para(toc.title_style.as_deref().unwrap_or("TOC Title")))
.attr("RunIn", "false")
.attr("IncludeHidden", "false")
.attr("IncludeBookDocuments", "false")
.attr("CreatePdfBookmarks", "false");
for e in &toc.entries {
let format = e.entry_style.clone().unwrap_or_else(|| format!("TOC Level {}", e.level));
let position = match e.page_number {
_ if !toc.page_numbers => "NoPageNumber",
designcraft_doc::TocPageNumber::After => "AfterEntry",
designcraft_doc::TocPageNumber::Before => "BeforeEntry",
designcraft_doc::TocPageNumber::None => "NoPageNumber",
};
el.push(
El::new("TOCStyleEntry")
.attr("Self", self.fresh())
.attr("Name", names::style_name_out(PARA_BUILTINS, &e.style))
.attr("FormatStyle", para(&format))
.attr("Level", e.level)
.attr("PageNumberPosition", position)
.attr("Separator", names::toc_separator_out(e.separator.as_deref().unwrap_or("\t")))
.attr("Sort", "false"),
);
}
el
}
fn char_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &CharAttrs) {
if let Some(v) = a.leading_aki {
el.set("LeadingAki", num(v.unwrap_or(-1.0)));
@@ -1923,7 +1962,12 @@ impl<'a> Ex<'a> {
// ---------- stories ----------
fn story_el(&mut self, s: &Story) -> El {
let mut el = El::new("Story").attr("Self", uid(s.id.0)).attr("AppliedTOCStyle", "n").attr("TrackChanges", "false").attr("StoryTitle", "$ID/");
let toc = self.d.toc.as_ref().is_some_and(|t| t.story == s.id);
let mut el = El::new("Story")
.attr("Self", uid(s.id.0))
.attr("AppliedTOCStyle", if toc { TOC_STYLE_ID } else { "n" })
.attr("TrackChanges", "false")
.attr("StoryTitle", "$ID/");
el.push(
El::new("StoryPreference")
.attr("OpticalMarginAlignment", "false")
+42 -1
View File
@@ -209,6 +209,7 @@ struct Importer<'r> {
/// Index topic Self → topic path and per-level sort keys; topic cross-references.
index_topics: HashMap<String, IndexTopic>,
index_xrefs: Vec<designcraft_doc::IndexRef>,
toc: Option<designcraft_doc::Toc>,
}
/// IDML stores a sort key on each topic, rather than on its page references.
@@ -312,6 +313,7 @@ impl<'r> Importer<'r> {
xref_formats: designcraft_doc::xref::default_formats(),
index_topics: HashMap::new(),
index_xrefs: Vec::new(),
toc: None,
}
}
@@ -485,6 +487,7 @@ impl<'r> Importer<'r> {
let story = self.story(id, e);
self.stories.insert(id, story);
}
self.toc = self.toc_of(top);
// Topic cross-references (See / See also) become markers at the start of the first story
// with text.
if !self.index_xrefs.is_empty()
@@ -1140,6 +1143,44 @@ impl<'r> Importer<'r> {
self.footnote_options = o;
}
/// The table of contents: the TOC style a story is generated from (`AppliedTOCStyle`), with
/// its entries. Styles the document doesn't have fall back to the generated TOC styles.
fn toc_of(&self, top: &[El]) -> Option<designcraft_doc::Toc> {
use designcraft_doc::{TocEntry, TocPageNumber};
let (ts, story) = top.iter().filter(|e| e.local() == "TOCStyle").find_map(|ts| {
let id = ts.get("Self")?;
let story = top.iter().find(|e| e.local() == "Story" && e.get("AppliedTOCStyle") == Some(id))?;
Some((ts, *self.story_ids.get(story.get("Self")?)?))
})?;
let known = |r: Option<&str>| r.map(|r| self.para_style_ref(r)).filter(|n| self.styles.para(n).is_some());
let entries: Vec<TocEntry> = ts
.elements()
.filter(|e| e.local() == "TOCStyleEntry")
.filter_map(|e| {
let style = names::style_name_in(PARA_BUILTINS, e.get("Name")?);
if style.is_empty() || style.chars().count() > 256 {
return None;
}
let level = e.get("Level").and_then(|l| l.trim().parse::<u8>().ok()).unwrap_or(1).clamp(1, 9);
let page_number = match e.get("PageNumberPosition") {
Some("BeforeEntry") => TocPageNumber::Before,
Some("NoPageNumber") => TocPageNumber::None,
_ => TocPageNumber::After,
};
let separator = e.get("Separator").map(crate::names::toc_separator_in).filter(|s| s != "\t" && s.chars().count() <= 64);
let entry_style = known(e.get("FormatStyle")).filter(|n| *n != format!("TOC Level {level}"));
Some(TocEntry { style, level, entry_style, page_number, separator })
})
.take(64)
.collect();
if entries.is_empty() {
return None;
}
let title: String = ts.get("Title").unwrap_or("").chars().filter(|c| !matches!(c, '\n' | '\r' | '\u{2029}')).take(1024).collect();
let title_style = known(ts.get("TitleStyle")).filter(|n| n != "TOC Title");
Some(designcraft_doc::Toc { story, title, entries, page_numbers: true, title_style })
}
fn para_style_ref(&self, r: &str) -> String {
self.para_names.get(r).cloned().unwrap_or_else(|| names::style_name_in(PARA_BUILTINS, &unescape_id(r.trim_start_matches("ParagraphStyle/"))))
}
@@ -2434,7 +2475,7 @@ impl<'r> Importer<'r> {
bookmarks: vec![],
user_words: vec![],
hyphenation_exceptions: vec![],
toc: None,
toc: self.toc.take(),
text_variables: designcraft_doc::vars::defaults(),
footnote_options: std::mem::take(&mut self.footnote_options),
xref_formats: std::mem::take(&mut self.xref_formats),
+26
View File
@@ -689,3 +689,29 @@ pub fn digits_in(s: &str) -> Option<designcraft_doc::Digits> {
use designcraft_doc::Digits;
[Digits::Default, Digits::Arabic, Digits::Hindi, Digits::Farsi, Digits::Native].into_iter().find(|d| digits_out(*d) == s)
}
/// A TOC separator in IDML's special-character notation: `^t` is a tab, `^^` a caret.
pub fn toc_separator_out(s: &str) -> String {
s.replace('^', "^^").replace('\t', "^t")
}
/// IDML TOC separator → text (`^t` and `^y`, the right indent tab, are tabs; other codes stay).
pub fn toc_separator_in(s: &str) -> String {
let mut out = String::new();
let mut it = s.chars();
while let Some(c) = it.next() {
if c != '^' {
out.push(c);
continue;
}
match it.next() {
Some('t' | 'y') => out.push('\t'),
Some('^') => out.push('^'),
Some(o) => {
out.push('^');
out.push(o);
}
None => out.push('^'),
}
}
out
}
+266 -1
View File
@@ -2,7 +2,7 @@
//! (`ui.dialog.set {field, value}`) and confirm them (`ui.dialog.confirm`) like a user would.
use designcraft_geom::{Unit, format_measure, units::parse_measure};
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::DesignApp;
@@ -1657,6 +1657,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
});
}
STEP_AND_REPEAT => step_and_repeat_dialog(app, ui, &mut d),
TOC => toc_dialog(app, ui, &mut d),
id if id.starts_with("cmd:") => command_form(app, ui, &mut d),
"goToPage" => {
ui.horizontal(|ui| {
@@ -2188,6 +2189,15 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
}
r
}
TOC => {
let r = app.run("toc.generate", toc_params(app, &d));
if let Err(e) = &r {
let mut d = d.clone();
d.fields.insert("status".into(), json!(e));
app.ui.dialog = Some(d);
}
r
}
id if id.starts_with("cmd:") => {
let cid = &id[4..];
let doc = designcraft_engine::find_command(cid).map_or("", |c| c.params);
@@ -3580,6 +3590,223 @@ fn command_form(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
}
const STEP_AND_REPEAT: &str = "cmd:edit.stepAndRepeat";
const TOC: &str = "cmd:toc.generate";
/// One included style of the Table of Contents dialog, as `toc.generate` takes it, with the
/// separator in the dialog's notation (`^t` a tab).
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", default)]
struct TocRow {
style: String,
level: u8,
entry_style: String,
page_number: String,
separator: String,
}
fn toc_separator_shown(s: &str) -> String {
s.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>")
}
fn toc_separator_typed(s: &str) -> String {
s.replace("^t", "\t").replace("^m", "\u{2003}").replace("^>", "\u{2002}")
}
fn toc_rows(d: &Dialog) -> Vec<TocRow> {
d.fields.get("included").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default()
}
/// Layout › Table of Contents…: prefilled from the document's table of contents when it has one.
pub fn open_toc(app: &mut DesignApp) {
let Some(st) = app.session.active() else { return };
let d = &st.doc;
let styles: Vec<&str> = d.styles.paragraph.iter().map(|p| p.name.as_str()).filter(|n| *n != designcraft_doc::NO_PARA_STYLE).collect();
let toc = d.toc.as_ref().filter(|t| d.story(t.story).is_some());
let rows: Vec<TocRow> = toc
.map(|t| {
t.entries
.iter()
.map(|e| TocRow {
style: e.style.clone(),
level: e.level,
entry_style: e.entry_style.clone().unwrap_or_default(),
page_number: match (t.page_numbers, e.page_number) {
(false, _) | (_, designcraft_doc::TocPageNumber::None) => "none",
(_, designcraft_doc::TocPageNumber::Before) => "before",
_ => "after",
}
.into(),
separator: toc_separator_shown(e.separator.as_deref().unwrap_or("\t")),
})
.collect()
})
.unwrap_or_default();
let fields = json!({
"title": toc.map_or("Contents", |t| t.title.as_str()),
"titleStyle": toc.and_then(|t| t.title_style.clone()).unwrap_or_default(),
"included": rows,
"selected": 0,
"styles": styles,
"hasToc": toc.is_some(),
"replace": toc.is_some(),
});
app.ui.dialog = Some(Dialog::new(TOC, fields));
}
/// The dialog's fields as `toc.generate` params. A new frame goes on the page in view.
fn toc_params(app: &DesignApp, d: &Dialog) -> Value {
let entries: Vec<Value> = toc_rows(d)
.iter()
.map(|r| {
let mut e = json!({"style": r.style, "level": r.level, "separator": toc_separator_typed(&r.separator)});
if !r.entry_style.is_empty() {
e["entryStyle"] = json!(r.entry_style);
}
if !r.page_number.is_empty() {
e["pageNumber"] = json!(r.page_number);
}
e
})
.collect();
let mut p = json!({"title": d.s("title"), "entries": entries, "replace": d.b("replace")});
let title_style = d.s("titleStyle");
if !title_style.is_empty() {
p["titleStyle"] = json!(title_style);
}
if let Some(page) = crate::canvas::current_page(app) {
p["page"] = json!(page + 1);
}
p
}
/// A paragraph style menu whose first choice, stored as "", is the generated style `auto`.
fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str, cur: &mut String) -> bool {
let shown = if cur.is_empty() { auto.to_string() } else { cur.clone() };
let mut changed = false;
egui::ComboBox::from_id_salt(("toc_style", salt)).selected_text(shown).width(200.0).show_ui(ui, |ui| {
if ui.selectable_label(cur.is_empty(), auto).clicked() {
cur.clear();
changed = true;
}
for n in styles.iter().filter(|n| *n != auto) {
if ui.selectable_label(cur == n, n.as_str()).clicked() {
*cur = n.clone();
changed = true;
}
}
});
changed
}
/// Layout › Table of Contents: the title and its style, the paragraph styles to include (Add /
/// Remove between the two lists) and, for the selected one, its entry style, page number
/// position, separator and level.
fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
let lang = app.ui.language.clone();
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
let styles: Vec<String> = d.fields.get("styles").cloned().and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default();
let mut rows = toc_rows(d);
let mut sel = d.n("selected").map_or(0, |n| n.max(0.0) as usize);
let mut other = d.s("other");
let mut title_style = d.s("titleStyle");
egui::Grid::new("toc_title").num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Title:"));
text_field(ui, d, "title", 200.0);
crate::rtl::label(ui, tr("Style:"));
toc_style_combo(ui, "title", &styles, "TOC Title", &mut title_style);
ui.end_row();
});
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(tr("Styles in Table of Contents")).font(semibold(12.0)));
let others: Vec<&String> = styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)).collect();
ui.horizontal_top(|ui| {
ui.vertical(|ui| {
crate::rtl::label(ui, tr("Include Paragraph Styles:"));
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_width(200.0);
egui::ScrollArea::vertical().id_salt("toc_included").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| {
for (i, r) in rows.iter().enumerate() {
let indent = " ".repeat(usize::from(r.level.saturating_sub(1)));
if ui.selectable_label(i == sel, format!("{indent}{}", r.style)).clicked() {
sel = i;
}
}
});
});
});
ui.vertical(|ui| {
ui.add_space(40.0);
if ui.button(tr("« Add")).clicked() && others.iter().any(|n| **n == other) {
let level = u8::try_from(rows.len() + 1).unwrap_or(9).min(9);
rows.push(TocRow { style: other.clone(), level, page_number: "after".into(), separator: "^t".into(), ..Default::default() });
sel = rows.len() - 1;
other.clear();
}
if ui.button(tr("Remove »")).clicked() && sel < rows.len() {
other = rows.remove(sel).style;
sel = sel.min(rows.len().saturating_sub(1));
}
});
ui.vertical(|ui| {
crate::rtl::label(ui, tr("Other Styles:"));
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.set_width(200.0);
egui::ScrollArea::vertical().id_salt("toc_other").max_height(150.0).min_scrolled_height(150.0).show(ui, |ui| {
for n in styles.iter().filter(|n| !rows.iter().any(|r| &r.style == *n)) {
if ui.selectable_label(*n == other, n.as_str()).clicked() {
other = n.clone();
}
}
});
});
});
});
if let Some(r) = rows.get_mut(sel) {
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(format!("{} {}", tr("Style:"), r.style)).font(semibold(12.0)));
egui::Grid::new("toc_entry").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| {
crate::rtl::label(ui, tr("Entry Style:"));
let auto = format!("TOC Level {}", r.level.clamp(1, 9));
toc_style_combo(ui, "entry", &styles, &auto, &mut r.entry_style);
ui.end_row();
crate::rtl::label(ui, tr("Page Number:"));
let choices = [("after", "After Entry"), ("before", "Before Entry"), ("none", "No Page Number")];
let cur = choices.iter().find(|(v, _)| *v == r.page_number).map_or("After Entry", |(_, l)| l);
egui::ComboBox::from_id_salt("toc_page_number").selected_text(tr(cur)).width(200.0).show_ui(ui, |ui| {
for (v, l) in choices {
if ui.selectable_label(r.page_number == v, tr(l)).clicked() {
r.page_number = v.into();
}
}
});
ui.end_row();
crate::rtl::label(ui, tr("Between Entry and Number:"));
ui.add(egui::TextEdit::singleline(&mut r.separator).desired_width(80.0).horizontal_align(egui::Align::Min));
ui.end_row();
crate::rtl::label(ui, tr("Level:"));
egui::ComboBox::from_id_salt("toc_level").selected_text(r.level.to_string()).width(60.0).show_ui(ui, |ui| {
for l in 1..=9u8 {
if ui.selectable_label(r.level == l, l.to_string()).clicked() {
r.level = l;
}
}
});
ui.end_row();
});
}
ui.add_space(8.0);
crate::rtl::label(ui, egui::RichText::new(tr("Options")).font(semibold(12.0)));
let has_toc = d.b("hasToc");
ui.add_enabled_ui(has_toc, |ui| check(ui, d, "replace", &tr("Replace Existing Table of Contents")));
if let Some(st) = d.fields.get("status").and_then(Value::as_str) {
ui.add_space(6.0);
crate::rtl::label(ui, egui::RichText::new(st).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
}
d.fields.insert("included".into(), json!(rows));
d.fields.insert("selected".into(), json!(sel));
d.fields.insert("other".into(), json!(other));
d.fields.insert("titleStyle".into(), json!(title_style));
}
/// The params a previewing dialog shows on the page (its interaction is open while it is set).
const PREVIEWED: &str = "_previewed";
@@ -3954,6 +4181,44 @@ mod tests {
assert_eq!(d.s("status"), "Invalid value: up");
}
#[test]
fn toc_dialog_adds_a_style_and_ok_makes_the_table_of_contents() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.newSample", json!({})).unwrap();
crate::menus::activate(&mut app, "toc.generate", &Value::Null);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some(TOC));
let ctx = dialog_context("en");
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0));
dialog_frame(&mut app, &ctx, screen, vec![]);
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for expected in
["Table of Contents", "Title:", "Include Paragraph Styles:", "Other Styles:", "« Add", "Remove »", "Replace Existing Table of Contents"]
{
visible_label(&labels, screen, expected);
}
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Headline").center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "« Add").center());
let labels = dialog_frame(&mut app, &ctx, screen, vec![]);
for expected in ["Entry Style:", "TOC Level 1", "After Entry", "Between Entry and Number:", "Level:"] {
visible_label(&labels, screen, expected);
}
let undo = app.session.doc().unwrap().history.undo.len();
click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "OK").center());
assert!(app.ui.dialog.is_none());
let d = &app.session.doc().unwrap().doc;
let toc = d.toc.clone().unwrap();
assert_eq!(toc.entries, vec![designcraft_doc::TocEntry::new("Headline", 1)]);
let text = &d.story(toc.story).unwrap().text;
assert!(text.starts_with("Contents\n") && text.contains("\nNotes on the Grid\t"), "{text}");
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1);
// Reopened, the dialog shows the table of contents and replaces it.
crate::menus::activate(&mut app, "toc.generate", &Value::Null);
let d = app.ui.dialog.as_ref().unwrap();
assert!(d.b("replace"));
assert_eq!(toc_rows(d).iter().map(|r| r.style.as_str()).collect::<Vec<_>>(), ["Headline"]);
}
fn dialog_context(language: &str) -> egui::Context {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx, language);
+4
View File
@@ -1888,6 +1888,10 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) {
crate::dialogs::open_document_setup(app);
return;
}
if params.is_null() && id == "toc.generate" {
crate::dialogs::open_toc(app);
return;
}
if params.is_null() && id == "file.print" {
crate::dialogs::open_print(app);
return;
+1 -1
View File
@@ -428,7 +428,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `<PDF>` in IDML.
- Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation.
- Table of contents (generate/update, dot leaders) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position, separator and level, title style, Replace Existing; generate/update, dot leaders; the definition round-trips through IDML as a TOC style) and text variables (running headers, last page number, chapter number, file name, dates, custom) resolved per page.
- Soft effects: blurred drop shadow, inner shadow, outer glow, basic feather.
- ~200 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input.