diff --git a/ROADMAP.md b/ROADMAP.md index 1e3085c..6e8f6e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/crates/compose/src/cache.rs b/crates/compose/src/cache.rs index 547bf8a..cf4b163 100644 --- a/crates/compose/src/cache.rs +++ b/crates/compose/src/cache.rs @@ -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; diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1dda64b..fe09926 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -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> = { + let mut counters: std::collections::HashMap = 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 = 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); } diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 20f337a..8ce1646 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -506,6 +506,10 @@ attr_set! { rule_below: Rule = Rule::default(), tabs: Vec = 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 = None, bullet_char: String = "\u{2022}".into(), number_style: NumberStyle = NumberStyle::Arabic, /// Text after the number/bullet (`^t` = tab). diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index e8ab82d..6a220c7 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, /// 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, @@ -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, } } diff --git a/crates/doc/src/notes.rs b/crates/doc/src/notes.rs index a38020c..0b0162d 100644 --- a/crates/doc/src/notes.rs +++ b/crates/doc/src/notes.rs @@ -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 { diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs new file mode 100644 index 0000000..f675456 --- /dev/null +++ b/crates/engine/src/cmd/lists.rs @@ -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 { + 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'); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 78eb8ac..506f9ca 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2f00f29..7c775d3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -483,6 +483,7 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { a.set_json(k, v).map_err(|e| bad("type.char", e))?; } } + let cleared: Vec = 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 { } 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 { 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 = 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() diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs index cd84917..cc40768 100644 --- a/crates/engine/src/tests_idml.rs +++ b/crates/engine/src/tests_idml.rs @@ -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))); +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index f4be2b8..f44acb2 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -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, 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"); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index b05a569..e0c09ad 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -100,6 +100,7 @@ struct Importer<'r> { styles: Styles, para_names: HashMap, char_names: HashMap, + lists: Vec, object_names: HashMap, /// Object style Self → element (attribute fallback for page items). object_els: HashMap, @@ -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 = 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, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 45f10ee..6200677 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -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}", diff --git a/docs/parity.md b/docs/parity.md index e9879e0..cf12303 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -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 |