Merge remote-tracking branch 'origin/main' into pr171-fix

This commit is contained in:
zydvc26s67-web
2026-10-14 11:11:42 -07:00
13 changed files with 807 additions and 36 deletions
+3
View File
@@ -4,6 +4,7 @@
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
format + IDML), scriptable by agents (MCP), and available on the web.
@@ -150,6 +151,8 @@ Newest first.
`text.thread`, `text.unthread`, `text.removeFromThread` with undo; locked frames and layers
refuse threads. The alpha gate now waits only on the Primary Text Frame page fixes (#165). No
readiness percentage change.
- **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-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. 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
@@ -578,6 +578,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 {
@@ -592,6 +616,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
@@ -363,3 +363,33 @@ fn type_para_sets_a_drop_cap_that_survives_idml() {
assert_eq!(p.drop_cap_style.as_deref(), Some("Initial"));
assert_eq!(p.nested_styles, Some(vec![designcraft_doc::NestedStyle::drop_cap("Initial")]));
}
#[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.
@@ -1050,9 +1052,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)));
@@ -1959,7 +1998,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.
@@ -315,6 +316,7 @@ impl<'r> Importer<'r> {
xref_formats: designcraft_doc::xref::default_formats(),
index_topics: HashMap::new(),
index_xrefs: Vec::new(),
toc: None,
}
}
@@ -488,6 +490,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()
@@ -1143,6 +1146,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/"))))
}
@@ -2488,7 +2529,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
@@ -693,3 +693,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
}
+149 -9
View File
@@ -100,6 +100,30 @@ fn key_from(name: &str) -> Option<egui::Key> {
})
}
// Retain room for the existing maximum drag (10,000 moves plus move/down/up), but
// bound the whole gesture and already queued input before generating any events.
const MAX_PENDING_POINTER_EVENTS: usize = 10_003;
fn pointer_counts(p: &Value, drag: bool, pending: usize) -> Result<(u64, u64), String> {
let positive = |name: &str, default: u64| match p.get(name) {
None => Ok(default),
Some(v) => v.as_u64().filter(|n| *n > 0).ok_or_else(|| format!("`{name}` must be a positive integer")),
};
let count = positive("count", 1)?;
if count > 255 {
return Err("`count` must be an integer from 1 through 255".into());
}
let steps = if drag { positive("steps", 8)? } else { 0 };
// One initial move, two button events per click, and each drag step. Check the
// arithmetic too: hostile counts must never overflow into a small budget.
let events = count.checked_mul(2).and_then(|n| n.checked_add(steps)).and_then(|n| n.checked_add(1));
let available = MAX_PENDING_POINTER_EVENTS.saturating_sub(pending) as u64;
if events.is_none_or(|n| n > available) {
return Err(format!("pointer input would exceed the {MAX_PENDING_POINTER_EVENTS} pending-event limit"));
}
Ok((count, steps))
}
/// Handle one control request. Commands it runs leave the user's system clipboard alone.
pub fn handle(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
app.install_clipboard_guard(ctx);
@@ -196,14 +220,11 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
ok(Value::Null)
}
"ui.click" | "ui.drag" => {
let count = match p.get("count") {
None => 1,
Some(v) => match v.as_u64().filter(|n| (1..=255).contains(n)) {
Some(n) => n,
None => return err("`count` must be an integer from 1 through 255"),
},
};
// Screen-space (egui points) pointer input through egui itself: reaches every widget.
let (count, steps) = match pointer_counts(p, req.method == "ui.drag", app.control_synthetic.len().saturating_add(app.synthetic.len())) {
Ok(counts) => counts,
Err(e) => return err(e),
};
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let button = match s("button") {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
@@ -223,8 +244,6 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest
app.control_synthetic.push(egui::Event::PointerMoved(a));
app.control_synthetic.push(egui::Event::PointerButton { pos: a, button, pressed: true, modifiers });
if req.method == "ui.drag" {
// From the caller: a huge count would queue events until memory runs out.
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 10_000);
for i in 1..=steps {
let t = i as f32 / steps as f32;
app.control_synthetic.push(egui::Event::PointerMoved(a + (end - a) * t));
@@ -448,6 +467,127 @@ mod automation_contract_tests {
output.textures_delta.clear();
}
#[test]
fn oversized_pointer_requests_leave_pending_events_unchanged() {
let mut app = app();
let ctx = egui::Context::default();
let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))];
for (method, params) in [
("ui.click", json!({"count": 5002})),
("ui.drag", json!({"steps": 10000, "count": 2})),
("ui.drag", json!({"steps": 10001})),
("ui.click", json!({"count": u64::MAX})),
("ui.drag", json!({"steps": u64::MAX})),
("ui.drag", json!({"count": u64::MAX, "steps": u64::MAX})),
] {
app.control_synthetic = pending.clone();
let reply = call(&mut app, &ctx, method, params);
assert_eq!(reply["ok"], false, "{method}: {reply}");
assert_eq!(app.control_synthetic, pending, "a rejected gesture must not leave a press queued");
}
}
#[test]
fn pointer_counts_require_positive_json_integers() {
let mut app = app();
let ctx = egui::Context::default();
let pending = vec![egui::Event::PointerMoved(egui::pos2(12.0, 34.0))];
app.control_synthetic = pending.clone();
for value in [json!(0), json!(-1), json!(1.5), Value::Null, json!("2"), json!(true), json!(18446744073709551616_f64)] {
for (method, field) in [("ui.click", "count"), ("ui.drag", "count"), ("ui.drag", "steps")] {
let mut params = json!({});
params[field] = value.clone();
let reply = call(&mut app, &ctx, method, params);
assert_eq!(reply["ok"], false, "{method}, {field}={value}: {reply}");
assert!(reply["error"].as_str().unwrap().contains(field));
assert_eq!(app.control_synthetic, pending);
}
}
}
#[test]
fn pointer_gestures_accept_the_exact_event_budget() {
let mut app = app();
let ctx = egui::Context::default();
for (method, params) in
[("ui.drag", json!({"steps": 9492, "count": 255})), ("ui.drag", json!({"steps": 10000})), ("ui.drag", json!({"steps": 9998, "count": 2}))]
{
app.control_synthetic.clear();
assert_eq!(call(&mut app, &ctx, method, params)["ok"], true, "{method}");
assert_eq!(app.control_synthetic.len(), 10003);
assert!(matches!(app.control_synthetic.last(), Some(egui::Event::PointerButton { pressed: false, .. })));
let queued = app.control_synthetic.clone();
assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false);
assert_eq!(app.control_synthetic, queued, "a full queue retains its release events");
}
}
#[test]
fn pointer_budget_counts_pending_input_and_recovers_when_it_drains() {
let mut app = app();
let ctx = egui::Context::default();
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9998}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 10001);
let queued = app.control_synthetic.clone();
assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], false);
assert_eq!(app.control_synthetic, queued);
// The ordinary input hook consumes one pointer event per frame, freeing
// enough room for a complete click without dropping earlier input.
app.raw_input_hook(&mut egui::RawInput::default());
assert_eq!(app.control_synthetic.len(), 10000);
assert_eq!(call(&mut app, &ctx, "ui.click", json!({}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 10003);
app.control_synthetic.clear();
assert_eq!(call(&mut app, &ctx, "ui.move", json!({"x": 12, "y": 34}))["ok"], true);
let queued = app.control_synthetic.clone();
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 10000}))["ok"], false);
assert_eq!(app.control_synthetic, queued);
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"steps": 9999}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 10003);
}
#[test]
fn ordinary_pointer_gestures_keep_their_defaults_and_event_order() {
let mut app = app();
let ctx = egui::Context::default();
let a = egui::pos2(10.0, 20.0);
let end = egui::pos2(30.0, 40.0);
assert_eq!(call(&mut app, &ctx, "ui.click", json!({"x": 10, "y": 20}))["ok"], true);
assert_eq!(
app.control_synthetic,
vec![
egui::Event::PointerMoved(a),
egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE },
egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE },
]
);
app.control_synthetic.clear();
assert_eq!(call(&mut app, &ctx, "ui.drag", json!({"x": 10, "y": 20, "toX": 30, "toY": 40}))["ok"], true);
assert_eq!(app.control_synthetic.len(), 11, "default drag has eight steps");
assert_eq!(app.control_synthetic.get(9), Some(&egui::Event::PointerMoved(end)));
app.control_synthetic.clear();
assert_eq!(
call(
&mut app,
&ctx,
"ui.drag",
json!({"x": 10, "y": 20, "toX": 30, "toY": 40, "steps": 2, "count": 2, "button": "right", "shift": true})
)["ok"],
true
);
let modifiers = egui::Modifiers { shift: true, ..Default::default() };
let button = egui::PointerButton::Secondary;
assert_eq!(
app.control_synthetic,
vec![
egui::Event::PointerMoved(a),
egui::Event::PointerButton { pos: a, button, pressed: true, modifiers },
egui::Event::PointerMoved(egui::pos2(20.0, 30.0)),
egui::Event::PointerMoved(end),
egui::Event::PointerButton { pos: end, button, pressed: false, modifiers },
egui::Event::PointerButton { pos: end, button, pressed: true, modifiers },
egui::Event::PointerButton { pos: end, button, pressed: false, modifiers },
]
);
}
#[test]
fn control_typing_uses_explicit_caret_but_retains_widget_events() {
let mut app = app();
let ctx = egui::Context::default();
+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;
@@ -1770,6 +1770,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| {
@@ -2343,6 +2344,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);
@@ -3877,6 +3887,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";
@@ -4251,6 +4478,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
@@ -1936,6 +1936,10 @@ fn activate_inner(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;
+12 -1
View File
@@ -39,7 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser
| `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) |
| `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) |
| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) |
| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel |
| `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel |
| `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state |
| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) |
| `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog |
@@ -57,6 +57,17 @@ Headless window screenshots (locked screen, hidden window): `cargo run -p design
The MCP server (`designcraft-cli mcp`) wraps the same methods for Claude and other agents.
### Click and drag limits
`ui.click` and `ui.drag` validate the whole gesture before queuing any input. They permit at
most 10,003 pending synthetic events, including input already waiting for native frames. A
click uses `1 + 2 * count` events; a drag uses `1 + steps + 2 * count`. The count includes the
initial click or drag; further clicks occur at its endpoint. `count` is at most 255; an empty
queue accepts a single drag with up to 10,000 steps. `count` and `steps` must be positive
JSON integers when supplied; invalid or over-budget requests return `ok: false` and leave pending
input unchanged. Oversized gestures are rejected rather than truncated. Wait for queued input
to finish before retrying a request that would exceed the pending-event limit.
## Shared panel docking
Panel arrangements support tab grouping and reordering, horizontal and vertical splits,
+3 -1
View File
@@ -1,6 +1,6 @@
# Where DesignCraft falls short of InDesign
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (gap 0 shrunk: text threads by hand; threading gaps added) · **Target:** Adobe InDesign 2026 (21.6)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026 (21.6)
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
InDesign professional from doing real work. This is the work list: agents pick from the top.
@@ -32,6 +32,8 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
open (#238, #187, #160, #312); double-click word selection in fields
(#189); character formatting set at a caret is discarded (#289).
- **Evidence:** 16 issues from 2026-10-05 to 2026-10-10, mostly against the 0.4/0.5 release builds.
- **Safety:** control-channel clicks and drags reject invalid or over-budget counts before
queuing input (#732), including combined drag steps, repeated clicks and already pending events.
- **Impact:** a layout artist can't finish the simplest job. This dominates the
ready-for-real-work number.
- **Estimate:** 30–50 h (reproduce per platform through the control channel, regression tests).
+1 -1
View File
@@ -431,7 +431,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.