mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
Table of Contents dialog: page number and separator styles, Sort Entries in Alphabetical Order (#1069)
The selected style's Page Number and Between Entry and Number rows get a character style menu ([None] first), and a Sort Entries in Alphabetical Order checkbox. toc.entries previews entries sorted as they will be set. ROADMAP and the parity doc name the new options.
This commit is contained in:
@@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
|
||||
Newest first.
|
||||
|
||||
- **2026-10-15** · Table of Contents: each included style can set a character style for its page number and for the text between entry and number, and Sort Entries in Alphabetical Order lists its entries alphabetically among their siblings, each with the entries under it (#1069). Both survive Update Table of Contents, renaming or deleting the character style, and IDML (`PageNumberStyle`, `SeparatorStyle`, `Sort`). No readiness percentage change.
|
||||
- **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change.
|
||||
- **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change.
|
||||
- **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change.
|
||||
|
||||
@@ -34,7 +34,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<_>>()))
|
||||
let found = sort_entries(collect(d, &entries, skip), &entries);
|
||||
Ok(json!(found.into_iter().map(|e| json!({"level": e.level, "text": e.text, "page": e.page})).collect::<Vec<_>>()))
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -118,6 +119,9 @@ fn char_style_exists(d: &Document, name: &str) -> Result<()> {
|
||||
/// other among their siblings (entries under the same entry of a higher level), each taking the
|
||||
/// lower-level entries under it along. Other entries keep their places.
|
||||
fn sort_entries(found: Vec<Found>, entries: &[TocEntry]) -> Vec<Found> {
|
||||
if !entries.iter().any(|e| e.sort) {
|
||||
return found;
|
||||
}
|
||||
// Siblings: each entry with the deeper entries that follow it.
|
||||
let mut blocks: Vec<Vec<Found>> = Vec::new();
|
||||
for f in found {
|
||||
@@ -246,7 +250,7 @@ fn fill(d: &mut Document, toc: &Toc, width: Option<f64>) -> Result<usize> {
|
||||
for name in toc.entries.iter().flat_map(|e| [e.page_number_style.as_deref(), e.separator_style.as_deref()]).flatten() {
|
||||
char_style_exists(d, name)?;
|
||||
}
|
||||
let found = if toc.entries.iter().any(|e| e.sort) { sort_entries(found, &toc.entries) } else { found };
|
||||
let found = sort_entries(found, &toc.entries);
|
||||
let mut text = String::new();
|
||||
let mut styles = Vec::new();
|
||||
// Character styles of page numbers and separators: (byte range, style).
|
||||
@@ -595,6 +599,10 @@ mod tests {
|
||||
assert_eq!(lines(&s), ["Zucchini", "Bake", "Zest", "apricot", "Mix", "Bread"]);
|
||||
s.execute("toc.update", &json!({})).unwrap();
|
||||
assert_eq!(lines(&s), ["Zucchini", "Bake", "Zest", "apricot", "Mix", "Bread"]);
|
||||
// The preview lists them as they will be set.
|
||||
let preview = s.execute("toc.entries", &json!({"entries": [{"style": "Chapter", "level": 1, "sort": true}]})).unwrap();
|
||||
let texts: Vec<&str> = preview.as_array().unwrap().iter().map(|e| e["text"].as_str().unwrap()).collect();
|
||||
assert_eq!(texts, ["apricot", "Bread", "Zucchini"]);
|
||||
// Unsorted: page order.
|
||||
s.execute("toc.generate", &json!({"page": page, "styles": ["Chapter", "Section"]})).unwrap();
|
||||
assert_eq!(lines(&s), ["Zucchini", "Zest", "Bake", "apricot", "Mix", "Bread"]);
|
||||
|
||||
@@ -4305,6 +4305,10 @@ struct TocRow {
|
||||
entry_style: String,
|
||||
page_number: String,
|
||||
separator: String,
|
||||
/// Character styles of the page number and the separator; "" is [None].
|
||||
page_number_style: String,
|
||||
separator_style: String,
|
||||
sort: bool,
|
||||
}
|
||||
|
||||
fn toc_separator_shown(s: &str) -> String {
|
||||
@@ -4324,6 +4328,7 @@ 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 char_styles: Vec<&str> = d.styles.character.iter().map(|c| c.name.as_str()).filter(|n| *n != designcraft_doc::NO_CHAR_STYLE).collect();
|
||||
let toc = d.toc.as_ref().filter(|t| d.story(t.story).is_some());
|
||||
let rows: Vec<TocRow> = toc
|
||||
.map(|t| {
|
||||
@@ -4340,6 +4345,9 @@ pub fn open_toc(app: &mut DesignApp) {
|
||||
}
|
||||
.into(),
|
||||
separator: toc_separator_shown(e.separator.as_deref().unwrap_or("\t")),
|
||||
page_number_style: e.page_number_style.clone().unwrap_or_default(),
|
||||
separator_style: e.separator_style.clone().unwrap_or_default(),
|
||||
sort: e.sort,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
@@ -4350,6 +4358,7 @@ pub fn open_toc(app: &mut DesignApp) {
|
||||
"included": rows,
|
||||
"selected": 0,
|
||||
"styles": styles,
|
||||
"charStyles": char_styles,
|
||||
"hasToc": toc.is_some(),
|
||||
"replace": toc.is_some(),
|
||||
});
|
||||
@@ -4368,6 +4377,15 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value {
|
||||
if !r.page_number.is_empty() {
|
||||
e["pageNumber"] = json!(r.page_number);
|
||||
}
|
||||
if !r.page_number_style.is_empty() {
|
||||
e["pageNumberStyle"] = json!(r.page_number_style);
|
||||
}
|
||||
if !r.separator_style.is_empty() {
|
||||
e["separatorStyle"] = json!(r.separator_style);
|
||||
}
|
||||
if r.sort {
|
||||
e["sort"] = json!(true);
|
||||
}
|
||||
e
|
||||
})
|
||||
.collect();
|
||||
@@ -4382,7 +4400,8 @@ fn toc_params(app: &DesignApp, d: &Dialog) -> Value {
|
||||
p
|
||||
}
|
||||
|
||||
/// A paragraph style menu whose first choice, stored as "", is the generated style `auto`.
|
||||
/// A style menu whose first choice, stored as "", is `auto` (the generated paragraph style, or
|
||||
/// [None] for a character style).
|
||||
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;
|
||||
@@ -4403,11 +4422,12 @@ fn toc_style_combo(ui: &mut egui::Ui, salt: &str, styles: &[String], auto: &str,
|
||||
|
||||
/// 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.
|
||||
/// position and separator with their character styles, alphabetical sorting 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 char_styles: Vec<String> = d.fields.get("charStyles").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");
|
||||
@@ -4467,7 +4487,8 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
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| {
|
||||
let none = designcraft_doc::NO_CHAR_STYLE;
|
||||
egui::Grid::new("toc_entry").num_columns(4).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);
|
||||
@@ -4482,9 +4503,16 @@ fn toc_dialog(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::rtl::label(ui, tr("Style:"));
|
||||
toc_style_combo(ui, "page_number", &char_styles, none, &mut r.page_number_style);
|
||||
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));
|
||||
crate::rtl::label(ui, tr("Style:"));
|
||||
toc_style_combo(ui, "separator", &char_styles, none, &mut r.separator_style);
|
||||
ui.end_row();
|
||||
crate::rtl::label(ui, "");
|
||||
ui.checkbox(&mut r.sort, tr("Sort Entries in Alphabetical Order"));
|
||||
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| {
|
||||
@@ -6241,6 +6269,43 @@ mod tests {
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_dialog_keeps_the_character_styles_and_sorting_of_its_entries() {
|
||||
let mut app = test_app();
|
||||
app.session.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
|
||||
app.session.execute("style.character.create", &json!({"name": "Number"})).unwrap();
|
||||
app.session.execute("style.character.create", &json!({"name": "Dots"})).unwrap();
|
||||
let sid =
|
||||
app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Pears\nApples"})).unwrap()["story"]
|
||||
.clone();
|
||||
app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap();
|
||||
app.session.execute("style.paragraph.apply", &json!({"name": "Head"})).unwrap();
|
||||
app.session.execute("layout.pages.insert", &json!({"count": 1})).unwrap();
|
||||
let entries = json!([{"style": "Head", "pageNumberStyle": "Number", "separatorStyle": "Dots", "sort": true}]);
|
||||
app.session.execute("toc.generate", &json!({"page": 2, "entries": entries})).unwrap();
|
||||
let before = app.session.active().unwrap().doc.toc.clone().unwrap();
|
||||
// The dialog shows the options, and OK (Replace Existing) writes them back unchanged.
|
||||
open_toc(&mut app);
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
let row = &toc_rows(d)[0];
|
||||
assert_eq!((row.page_number_style.as_str(), row.separator_style.as_str(), row.sort), ("Number", "Dots", true));
|
||||
let chars: Vec<String> = serde_json::from_value(d.fields["charStyles"].clone()).unwrap();
|
||||
assert_eq!(chars, ["Number", "Dots"], "[None] is the menu's first choice, not a style");
|
||||
let p = toc_params(&app, d);
|
||||
assert_eq!(
|
||||
(&p["entries"][0]["pageNumberStyle"], &p["entries"][0]["separatorStyle"], &p["entries"][0]["sort"]),
|
||||
(&json!("Number"), &json!("Dots"), &json!(true))
|
||||
);
|
||||
confirm(&mut app).expect("OK regenerates the table of contents");
|
||||
let st = app.session.active().unwrap();
|
||||
assert_eq!(st.doc.toc.as_ref().unwrap().entries, before.entries);
|
||||
// [None] and unsorted are left out of the params.
|
||||
let mut d = Dialog::new(TOC, json!({"included": [{"style": "Head", "level": 1, "separator": "^t"}]}));
|
||||
d.fields.insert("replace".into(), json!(true));
|
||||
let p = toc_params(&app, &d);
|
||||
assert!(p["entries"][0].get("pageNumberStyle").is_none() && p["entries"][0].get("sort").is_none(), "{p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_options_dialog_saves_edits() {
|
||||
let mut app = test_app();
|
||||
|
||||
@@ -3112,6 +3112,17 @@ const TABLE: &[(&str, [&str; 5])] = &[
|
||||
("Presentation", ["Präsentation", "Présentation", "Presentación", "プレゼンテーション", "演示文稿"]),
|
||||
("Extras", ["Extras", "Options supplémentaires", "Extras", "エクストラ", "附加"]),
|
||||
("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]),
|
||||
// Table of Contents.
|
||||
(
|
||||
"Sort Entries in Alphabetical Order",
|
||||
[
|
||||
"Einträge alphabetisch sortieren",
|
||||
"Trier les entrées par ordre alphabétique",
|
||||
"Ordenar entradas alfabéticamente",
|
||||
"項目をアルファベット順に並べ替え",
|
||||
"按字母顺序排列条目",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
/// Interface direction; independent of document binding and paragraph direction.
|
||||
|
||||
@@ -435,7 +435,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 (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.
|
||||
- Table of contents (Layout ▸ Table of Contents dialog: included styles, per-style entry style, page number position and its character style, separator and its character style, alphabetical sorting 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.
|
||||
|
||||
Reference in New Issue
Block a user