T: named lists continuing across stories (list.define, listName/startAt); null clears overrides

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 20:52:11 -04:00
parent 6b362c5f71
commit 1b778945af
14 changed files with 263 additions and 6 deletions

View File

@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool).
- Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`.
- Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`).
- Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override.
- View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen.
- Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come.
- Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come.

View File

@ -88,6 +88,10 @@ impl Cache {
format!("{:?}", doc.footnote_options).hash(&mut h);
sig.extend([doc.footnote_start(sid) as usize, h.finish() as usize]);
}
// Named lists continue from earlier stories.
for l in &doc.settings.lists {
sig.push(doc.list_start(sid, &l.name) as usize);
}
{
let mut g = self.map.lock().unwrap_or_else(|e| e.into_inner());
g.1 += 1;

View File

@ -361,6 +361,23 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
let para_ranges = story.para_ranges();
let np = para_ranges.len();
let mut list_counter: u32 = 0;
// Named lists number independently of layout: one pass up front.
let named_numbers: Vec<Option<u32>> = {
let mut counters: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
story
.paras
.iter()
.map(|p| {
let (pp, _) = doc.styles.resolve_para(p);
if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() {
return None;
}
let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name));
*c = pp.start_at.map_or(*c + 1, |s| s.max(1));
Some(*c)
})
.collect()
};
// Keep options: paragraphs are re-laid from a snapshot when a keep is violated, either forced
// into the next column or with a cap on the lines set before moving on (widow control).
let mut snaps: Vec<Snapshot> = Vec::with_capacity(np);
@ -465,8 +482,14 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
&pp.grep_styles,
);
match pp.list_type {
designcraft_doc::ListType::Numbers if !pp.list_name.is_empty() => {
// A named list: carries on past other paragraphs (and from earlier stories).
let n = named_numbers.get(pi).copied().flatten().unwrap_or(1);
let label = format!("{}.{}", pp.number_style.format(n), pp.list_separator);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
}
designcraft_doc::ListType::Numbers => {
list_counter += 1;
list_counter = pp.start_at.map_or(list_counter + 1, |s| s.max(1));
let label = format!("{}.{}", pp.number_style.format(list_counter), pp.list_separator);
prepend_label(db, &mut sp.glyphs, &label, prange.start, &base_chars, &pp, &mut table);
}

View File

@ -506,6 +506,10 @@ attr_set! {
rule_below: Rule = Rule::default(),
tabs: Vec<TabStop> = Vec::new(),
list_type: ListType = ListType::None,
/// Named list (Define Lists) the numbers belong to ("" = the story's own list).
list_name: String = String::new(),
/// Start At: restart the numbering at this number.
start_at: Option<u32> = None,
bullet_char: String = "\u{2022}".into(),
number_style: NumberStyle = NumberStyle::Arabic,
/// Text after the number/bullet (`^t` = tab).

View File

@ -116,6 +116,21 @@ impl Default for DocumentGrid {
}
}
/// A named numbered list.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct NumberedList {
pub name: String,
/// Numbering continues from story to story (in page order).
pub continue_across_stories: bool,
}
impl Default for NumberedList {
fn default() -> Self {
NumberedList { name: String::new(), continue_across_stories: true }
}
}
/// File → Document Setup + the guide/grid/unit preferences stored with the document.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@ -139,6 +154,9 @@ pub struct DocSettings {
pub bleed_color: [u8; 3],
pub slug_color: [u8; 3],
pub keyboard_increment: f64,
/// Named lists (Type › Bulleted and Numbered Lists › Define Lists).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lists: Vec<NumberedList>,
/// The story in the primary text frames (Smart Text Reflow adds and removes pages for it).
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_story: Option<StoryId>,
@ -167,6 +185,7 @@ impl Default for DocSettings {
slug_color: [100, 188, 221],
keyboard_increment: 1.0,
primary_story: None,
lists: Vec::new(),
chapter_number: 1,
}
}

View File

@ -219,6 +219,32 @@ impl Story {
}
impl crate::Document {
/// How many numbers of list `name` stories before `sid` used (page order), so `sid`'s numbering
/// carries on — 0 for lists that don't continue across stories.
pub fn list_start(&self, sid: StoryId, name: &str) -> u32 {
if !self.settings.lists.iter().any(|l| l.name == name && l.continue_across_stories) {
return 0;
}
let key = |st: &Story| -> (usize, u64) {
let page = st.frames.first().and_then(|f| self.page_of_item(*f)).unwrap_or(usize::MAX);
(page, st.id.0)
};
let Some(me) = self.story(sid) else { return 0 };
let mine = key(me);
let mut before: Vec<&Story> = self.stories.values().map(|s| s.as_ref()).filter(|st| st.id != sid && key(st) < mine).collect();
before.sort_by_key(|st| key(st));
let mut n = 0u32;
for st in before {
for p in &st.paras {
let (pp, _) = self.styles.resolve_para(p);
if pp.list_type == crate::ListType::Numbers && pp.list_name == name {
n = pp.start_at.map_or(n + 1, |s| s.max(1));
}
}
}
n
}
/// Number of the first footnote of story `sid` when numbering runs through the document:
/// the start number plus the footnotes of stories that start on earlier pages.
pub fn footnote_start(&self, sid: StoryId) -> u32 {

View File

@ -0,0 +1,102 @@
//! Type › Bulleted and Numbered Lists › Define Lists: named lists whose numbers can continue from
//! story to story. Paragraphs join one with `listName` (and restart with `startAt`).
use designcraft_doc::NumberedList;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"list.define",
"Define Lists…",
["Type", "Bulleted and Numbered Lists"],
None,
"{name, continueAcrossStories?: true, rename?} — paragraphs join it with type.para {listType: numbers, listName}",
has_doc,
|s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("list.define", "missing name"))?.to_string();
let cont = p.get("continueAcrossStories").and_then(Value::as_bool);
let rename = str_param(p, "rename").map(str::to_string);
s.edit(|d, _| {
let lists = &mut d.settings.lists;
match lists.iter_mut().find(|l| l.name == name) {
Some(l) => {
if let Some(c) = cont {
l.continue_across_stories = c;
}
if let Some(r) = &rename {
l.name = r.clone();
}
}
None => {
lists.push(NumberedList { name: rename.clone().unwrap_or(name.clone()), continue_across_stories: cont.unwrap_or(true) })
}
}
Ok(json!({"lists": d.settings.lists}))
})
}
),
cmd!("list.delete", "Delete List", [], None, "{name}", has_doc, |s, p| {
let name = str_param(p, "name").ok_or_else(|| bad("list.delete", "missing name"))?.to_string();
s.edit(|d, _| {
d.settings.lists.retain(|l| l.name != name);
Ok(json!({"lists": d.settings.lists}))
})
}),
]
}
#[cfg(test)]
mod tests {
use serde_json::json;
use crate::Session;
/// The digit the story's first line starts with (its list number).
fn digit(g: &designcraft_compose::PlacedGlyph) -> char {
let face = g.face.get();
('0'..='9').find(|c| face.glyph_for(*c) == g.gid).unwrap_or('?')
}
fn first_char(s: &Session, sid: u64) -> char {
let d = &s.doc().unwrap().doc;
let cs = designcraft_compose::compose_story(d, designcraft_doc::StoryId(sid), &Default::default());
digit(&cs.frames[0].lines[0].glyphs[0])
}
#[test]
fn named_lists_continue_across_stories() {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
s.execute("list.define", &json!({"name": "Steps"})).unwrap();
let mut stories = vec![];
for (spread, text) in [(0, "Mix\nStir"), (1, "Bake\nServe")] {
let r = s.execute("frame.create", &json!({"spread": spread, "rect": [72, 72, 400, 300], "content": "text", "text": text})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": text.len()})).unwrap();
s.execute("type.para", &json!({"attrs": {"listType": "numbers", "listName": "Steps"}})).unwrap();
stories.push(sid);
}
assert_eq!(first_char(&s, stories[0]), '1');
assert_eq!(first_char(&s, stories[1]), '3', "carries on from the first story");
// Not continuing: each story starts at 1.
s.execute("list.define", &json!({"name": "Steps", "continueAcrossStories": false})).unwrap();
assert_eq!(first_char(&s, stories[1]), '1');
s.execute("list.define", &json!({"name": "Steps", "continueAcrossStories": true})).unwrap();
// The session's cache follows earlier stories (no stale numbers).
s.execute("text.select", &json!({"story": stories[0], "anchor": 0, "focus": 0})).unwrap();
s.execute("text.insert", &json!({"text": "Prep\n"})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, designcraft_doc::StoryId(stories[1]), None);
assert_eq!(digit(&cs.frames[0].lines[0].glyphs[0]), '4');
// Start At restarts.
s.execute("text.select", &json!({"story": stories[1], "anchor": 0, "focus": 0})).unwrap();
s.execute("type.para", &json!({"attrs": {"startAt": 7}})).unwrap();
assert_eq!(first_char(&s, stories[1]), '7');
// null removes the override again.
s.execute("type.para", &json!({"attrs": {"startAt": null}})).unwrap();
assert_eq!(first_char(&s, stories[1]), '4');
}
}

View File

@ -15,6 +15,7 @@ mod interactive;
pub mod interchange;
mod layout;
mod links;
mod lists;
mod notes;
mod object;
mod overrides;
@ -131,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(object::specs());
v.extend(anchors::specs());
v.extend(paths::specs());
v.extend(lists::specs());
v.extend(path_type::specs());
v.extend(package::specs());
v.extend(print::specs());

View File

@ -483,6 +483,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
a.set_json(k, v).map_err(|e| bad("type.char", e))?;
}
}
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
s.edit(|d, _| {
for t in &targets {
@ -499,7 +500,12 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
}
continue;
}
st.format_chars(r.clone(), |f| f.over.merge(&a));
st.format_chars(r.clone(), |f| {
f.over.merge(&a);
for k in &cleared {
let _ = f.over.set_json(k, &Value::Null);
}
});
}
ok()
})
@ -512,11 +518,18 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
a.set_json(k, v).map_err(|e| bad("type.para", e))?;
}
}
// `null` removes the override (back to the style's value).
let cleared: Vec<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let targets = format_targets(s);
s.edit(|d, _| {
for t in &targets {
if let Some(st) = d.text_story_mut(t.story, t.cell) {
st.format_paras(t.range.clone(), |p| p.para.merge(&a));
st.format_paras(t.range.clone(), |p| {
p.para.merge(&a);
for k in &cleared {
let _ = p.para.set_json(k, &Value::Null);
}
});
}
}
ok()

View File

@ -235,3 +235,19 @@ fn newer_features_round_trip_through_idml() {
assert_eq!((pt.0.start, pt.0.flip, pt.0.align), (12.0, true, designcraft_doc::PathAlign::Center));
assert_eq!(back.story(pt.1).unwrap().text, "On the line");
}
#[test]
fn named_lists_round_trip_through_idml() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
s.execute("list.define", &json!({"name": "Steps", "continueAcrossStories": false})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "One\nTwo"})).unwrap();
s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 3})).unwrap();
s.execute("type.para", &json!({"attrs": {"listType": "numbers", "listName": "Steps", "startAt": 5}})).unwrap();
let d = s.doc().unwrap().doc.clone();
let back = designcraft_idml::import_idml(&designcraft_idml::export_idml(&d)).unwrap();
assert_eq!(back.settings.lists, vec![designcraft_doc::NumberedList { name: "Steps".into(), continue_across_stories: false }]);
let st = back.stories.values().find(|st| st.text.starts_with("One")).unwrap();
assert_eq!(st.paras[0].para.list_name.as_deref(), Some("Steps"));
assert_eq!(st.paras[0].para.start_at, Some(Some(5)));
}

View File

@ -820,6 +820,16 @@ impl<'a> Ex<'a> {
tables = tables.child(with_props(el, vec![p("BasedOn", "string", "$ID/[Basic Table]")]));
}
root.push(tables);
// Named numbered lists.
for l in &self.d.settings.lists {
root.push(
El::new("NumberingList")
.attr("Self", format!("NumberingList/{}", escape_id(&l.name)))
.attr("Name", &l.name)
.attr("ContinueNumbersAcrossStories", bool_s(l.continue_across_stories))
.attr("ContinueNumbersAcrossDocuments", "false"),
);
}
// Object styles.
let mut og = Group::default();
@ -973,6 +983,21 @@ impl<'a> Ex<'a> {
}
fn para_attrs(&mut self, el: &mut El, props: &mut Vec<El>, a: &ParaAttrs) {
if let Some(n) = &a.list_name {
el.set(
"AppliedNumberingList",
if n.is_empty() { "NumberingList/$ID/[Default]".to_string() } else { format!("NumberingList/{}", escape_id(n)) },
);
}
if let Some(v) = a.start_at {
match v {
Some(n) => {
el.set("NumberingContinue", "false");
el.set("NumberingStartAt", n);
}
None => el.set("NumberingContinue", "true"),
}
}
// Nested and GREP styles: property lists of records.
if let Some(list) = &a.nested_styles {
let mut l = El::new("AllNestedStyles").attr("type", "list");

View File

@ -100,6 +100,7 @@ struct Importer<'r> {
styles: Styles,
para_names: HashMap<String, String>,
char_names: HashMap<String, String>,
lists: Vec<designcraft_doc::NumberedList>,
object_names: HashMap<String, String>,
/// Object style Self → element (attribute fallback for page items).
object_els: HashMap<String, El>,
@ -177,6 +178,7 @@ impl<'r> Importer<'r> {
styles,
para_names: HashMap::new(),
char_names: HashMap::new(),
lists: Vec::new(),
object_names: HashMap::new(),
object_els: HashMap::new(),
layers: Vec::new(),
@ -595,6 +597,13 @@ impl<'r> Importer<'r> {
// ---------- styles ----------
fn styles(&mut self, top: &[El]) {
for e in top.iter().filter(|e| e.local() == "NumberingList") {
let name = e.get("Name").unwrap_or("").to_string();
if !name.is_empty() && !name.starts_with("$ID/") && !self.lists.iter().any(|l| l.name == name) {
self.lists
.push(designcraft_doc::NumberedList { name, continue_across_stories: e.get("ContinueNumbersAcrossStories") != Some("false") });
}
}
let mut paras: Vec<(String, El)> = Vec::new();
let mut chars: Vec<(String, El)> = Vec::new();
let mut objects: Vec<(String, El)> = Vec::new();
@ -988,6 +997,15 @@ impl<'r> Importer<'r> {
a.first_line_indent = e.num("FirstLineIndent");
a.last_line_indent = e.num("LastLineIndent");
a.space_before = e.num("SpaceBefore");
if let Some(r) = e.prop("AppliedNumberingList") {
let r = r.trim().trim_start_matches("NumberingList/");
a.list_name = Some(if r.starts_with("$ID/") { String::new() } else { unescape_id(r) });
}
match e.prop("NumberingContinue").as_deref().map(str::trim) {
Some("false") => a.start_at = Some(e.num("NumberingStartAt").map(|n| n.max(1.0) as u32)),
Some("true") => a.start_at = Some(None),
_ => {}
}
// Nested and GREP styles.
if let Some(l) = e.prop_el("AllNestedStyles") {
let v: Vec<designcraft_doc::NestedStyle> = l
@ -1903,6 +1921,7 @@ impl<'r> Importer<'r> {
if self.spreads.is_empty() {
return Err(IdmlError::Invalid("document has no spreads".into()));
}
self.settings.lists = std::mem::take(&mut self.lists);
// Make sure built-in paragraph styles exist and are first.
let d = Document {
title,

View File

@ -177,6 +177,9 @@ pub const MENUS: &[(&str, &[&str])] = &[
"ui:app.findFontDialog",
"-",
"cmd:type.createOutlines",
">Bulleted and Numbered Lists",
"cmd:list.define",
"<",
">Type on a Path",
"cmd:type.pathOptions",
"cmd:type.pathOptions|Delete Type from Path|{\"delete\": true}",

View File

@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Priority | Features | Done | Partial | Missing | Parity |
|---|---|---|---|---|---|
| P0 | 102 | 99 | 3 | 0 | 99% |
| P1 | 97 | 64 | 25 | 8 | 79% |
| P1 | 97 | 65 | 25 | 7 | 80% |
| P2 | 76 | 8 | 7 | 61 | 15% |
| Area | Parity |
@ -27,7 +27,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Typography | 85% |
| Styles | 92% |
| Tables | 81% |
| Long documents | 58% |
| Long documents | 75% |
| Interactivity & digital | 30% |
| Output & production | 68% |
| XML & automation | 39% |
@ -263,7 +263,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t
| Long documents | Table of contents | P1 | D |
| Long documents | Index | P2 | D |
| Long documents | Books | P2 | M |
| Long documents | Numbered lists across stories | P1 | M |
| Long documents | Numbered lists across stories | P1 | D |
| Long documents | Cross-references | P2 | D |
| Long documents | Running headers via variables | P1 | D |
| Long documents | Footnotes/endnotes | P1 | P |