//! List definitions: Define New Multilevel List (`list.define`) and reading one back //! (`list.get`). A definition is Word's abstract numbering: nine levels, each with its number //! format, style, start, restart rule, linked paragraph style, alignment, indents, what follows //! the number and the number's font. use serde_json::{Value, json}; use wordcraft_doc::StoryRef; use wordcraft_doc::numbering::{Level, LevelSuffix, ListKind, levels_for}; use wordcraft_doc::props::{Align, NumRef, Rgb, TextColor}; use wordcraft_doc::section::NumFormat; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; /// The most levels a list has (Word's nine). pub const LEVELS: usize = 9; pub fn specs() -> Vec { vec![ CommandSpec::new("list.define", "Define New Multilevel List", "Home › Paragraph › Multilevel List", define).params( r#"{"levels": [{"format"?: "decimal|lowerLetter|upperLetter|lowerRoman|upperRoman|decimalZero|ordinal|bullet|none", "text"?: "%1.%2." (%n = level n's number; the bullet character for bullet), "start"?: n, "restartAfter"?: 0-8 (0 = never; omit = the level above), "style"?: paragraph style linked to the level | null, "align"?: "left|center|right", "alignedAt"?: pt, "indentAt"?: pt, "follow"?: "tab|space|nothing", "tabAt"?: pt | null, "font"?: string | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "legal"?: bool}, …] (index = level, up to 9; fields left out keep the base list's), "num"?: list id to change in place (default: a new list), "apply"?: bool (default true: number the selected paragraphs), "name"?: string}"#, ), CommandSpec::new("list.get", "List Definition", "Home › Paragraph › Multilevel List", get) .params(r#"{"num"?: list id (default: the caret's list)} → {"num", "levels": [as list.define]}"#) .pure(), CommandSpec::new("list.multilevelPreset", "Multilevel List Library", "Home › Paragraph › Multilevel List", preset).params( r#"{"preset": "headings" (1 / 1.1 / 1.1.1) | "article" (Article I. / Section 1.01 / (a)) | "outlineHeadings" (I. / A. / 1.) | "chapter" (Chapter 1, lower levels unnumbered)} — a new list whose nine levels are linked to Heading 1–9: every paragraph in those styles is numbered, selected paragraphs already in a Heading style keep it, and any other selected paragraph becomes Heading 1 (the first level)"#, ), ] } /// A level as `list.define` takes it. pub fn level_json(l: &Level) -> Value { let color = match l.chr.color { Some(TextColor::Rgb(c)) => json!(c.hex()), _ => Value::Null, }; json!({ "format": l.format.ooxml(), "text": l.text, "start": l.start, "restartAfter": if l.restart { l.restart_after.map(Value::from).unwrap_or(Value::Null) } else { json!(0) }, "style": l.style, "align": match l.align { Align::Center => "center", Align::Right => "right", _ => "left", }, "alignedAt": l.indent - l.hanging, "indentAt": l.indent, "follow": match l.suffix { LevelSuffix::Tab => "tab", LevelSuffix::Space => "space", LevelSuffix::Nothing => "nothing", }, "tabAt": l.tab, "font": l.chr.font, "bold": l.chr.bold.unwrap_or(false), "italic": l.chr.italic.unwrap_or(false), "color": color, "legal": l.legal, }) } /// `base` changed by the fields `v` gives. pub fn level_from(mut l: Level, i: usize, v: &Value) -> Level { let pt = |k: &str| p::f32(v, k).map(|x| x.clamp(-1584.0, 1584.0)); if let Some(f) = p::str(v, "format") { l.format = NumFormat::from_ooxml(f); } if let Some(t) = p::str(v, "text") { l.text = t.chars().take(64).collect(); } if let Some(s) = p::u64(v, "start") { l.start = s.min(32_767) as u32; } match v.get("restartAfter") { Some(Value::Null) => { l.restart = true; l.restart_after = None; } Some(r) => { if let Some(k) = r.as_u64() { l.restart = k != 0; // Restarting after the level just above is the default. l.restart_after = Some(k.min(8) as u8).filter(|k| *k != 0 && *k as usize != i); } } None => {} } match v.get("style") { Some(Value::Null) => l.style = None, Some(Value::String(s)) => l.style = Some(s.chars().take(253).collect()).filter(|s: &String| !s.is_empty()), _ => {} } if let Some(a) = p::str(v, "align") { l.align = match a { "center" => Align::Center, "right" => Align::Right, _ => Align::Left, }; } let aligned = pt("alignedAt").unwrap_or(l.indent - l.hanging); if let Some(ind) = pt("indentAt") { l.indent = ind; } l.hanging = l.indent - aligned; if let Some(f) = p::str(v, "follow") { l.suffix = match f { "space" => LevelSuffix::Space, "nothing" => LevelSuffix::Nothing, _ => LevelSuffix::Tab, }; } match v.get("tabAt") { Some(Value::Null) => l.tab = None, Some(_) => l.tab = pt("tabAt").or(l.tab), None => {} } match v.get("font") { Some(Value::Null) => l.chr.font = None, Some(Value::String(s)) => l.chr.font = Some(s.chars().take(64).collect()).filter(|s: &String| !s.trim().is_empty()), _ => {} } if let Some(b) = p::bool(v, "bold") { l.chr.bold = Some(b); } if let Some(b) = p::bool(v, "italic") { l.chr.italic = Some(b); } match v.get("color") { Some(Value::Null) => l.chr.color = None, Some(Value::String(s)) => l.chr.color = Rgb::parse(s).map(TextColor::Rgb).or(l.chr.color), _ => {} } if let Some(b) = p::bool(v, "legal") { l.legal = b; } l } /// The caret's list, if it is in one. pub fn caret_list(s: &Session) -> Option { let para = s.doc.para_at(&s.sel.focus)?; para.props.numbering.or_else(|| s.doc.styles.resolve_para(¶.props).numbering).filter(|n| n.num != 0).map(|n| n.num) } /// The nine levels list `num` shows (its own level definitions over its abstract list's). pub fn levels_of(s: &Session, num: u32) -> Option> { s.doc.numbering.num(num)?; Some((0..LEVELS).map(|i| s.doc.numbering.level(num, i as u8).cloned().unwrap_or_else(|| default_level(i))).collect()) } /// Level `i` of a new multilevel list: 1. a. i. fn default_level(i: usize) -> Level { levels_for(ListKind::Numbered).into_iter().nth(i).unwrap_or_default() } fn get(s: &mut Session, v: &Value) -> CmdResult { let num = p::u64(v, "num").map(|n| n.min(u32::MAX as u64) as u32).or_else(|| caret_list(s)).ok_or_else(|| CmdError::Failed("not in a list".into()))?; let levels = levels_of(s, num).ok_or_else(|| CmdError::Params("no such list".into()))?; Ok(json!({"num": num, "levels": levels.iter().map(level_json).collect::>()})) } fn define(s: &mut Session, v: &Value) -> CmdResult { let given = v.get("levels").and_then(Value::as_array).ok_or_else(|| CmdError::Params("`levels` (array) is required".into()))?; let target = p::u64(v, "num").map(|n| n.min(u32::MAX as u64) as u32); let base = match target { Some(n) => levels_of(s, n).ok_or_else(|| CmdError::Params("no such list".into()))?, None => (0..LEVELS).map(default_level).collect(), }; let levels: Vec = base .into_iter() .enumerate() .map(|(i, l)| match given.get(i) { Some(lv) if lv.is_object() => level_from(l, i, lv), _ => l, }) .collect(); let name = p::str(v, "name").map(|n| n.chars().take(64).collect::()).filter(|n| !n.trim().is_empty()); let num = match target { Some(n) => { // Change the list in place: every list sharing its definition follows. let aid = s.doc.numbering.num(n).map(|x| x.abstract_id).ok_or_else(|| CmdError::Params("no such list".into()))?; if let Some(a) = s.doc.numbering.abstracts.iter_mut().find(|a| a.id == aid) { a.levels = levels.clone(); if name.is_some() { a.name = name; } } if let Some(x) = s.doc.numbering.nums.iter_mut().find(|x| x.id == n) { x.level_overrides.clear(); } let sharing: Vec = s.doc.numbering.nums.iter().filter(|x| x.abstract_id == aid).map(|x| x.id).collect(); touch_lists(s, &sharing)?; n } None => s.doc.numbering.try_add_levels(name, levels.clone())?, }; // Levels linked to a paragraph style number every paragraph in that style. let mut linked = Vec::new(); for (i, l) in levels.iter().enumerate() { let Some(id) = l.style.as_deref().map(|st| s.doc.styles.find(st).map(|x| x.id.clone()).unwrap_or_else(|| st.to_string())) else { continue }; if let Some(st) = s.doc.styles.get_mut(&id) { st.para.numbering = Some(NumRef { num, level: i as u8 }); linked.push(id); } } if !linked.is_empty() { touch_styles(s, &linked)?; } if p::bool(v, "apply").unwrap_or(true) { super::para::fmt(s, &|pp| { // Paragraphs already in a list keep their level. let level = pp.numbering.filter(|n| n.num != 0).map(|n| n.level).unwrap_or(0); pp.numbering = Some(NumRef { num, level }); pp.indent_left = None; pp.indent_first = None; pp.indent_left_chars = None; pp.indent_first_chars = None; if pp.style.is_none() || pp.style.as_deref() == Some("Normal") { pp.style = Some("ListParagraph".into()); } })?; } let mut r = sel_result(s)?; r["num"] = json!(num); Ok(r) } /// The heading-linked lists of the Multilevel List gallery's library, by preset id: level `i` /// is linked to Heading `i + 1`. fn preset_levels(preset: &str) -> Option> { use NumFormat as F; let levels = (0..LEVELS) .map(|i| { let n = i + 1; // Numbers start at the margin; each level's text a little further in. let at = |indent: f32| Level { indent, hanging: indent, style: Some(format!("Heading{n}")), ..Level::default() }; let (format, text, indent) = match preset { "headings" => (F::Decimal, (1..=n).map(|k| format!("%{k}")).collect::>().join("."), 21.6 + 7.2 * i as f32), "article" => match i { 0 => (F::UpperRoman, "Article %1.".to_string(), 0.0), 1 => (F::DecimalZero, "Section %1.%2".to_string(), 0.0), _ => { let f = [F::LowerLetter, F::LowerRoman, F::Decimal, F::LowerLetter, F::LowerRoman, F::LowerLetter, F::LowerRoman]; let t = if i < 4 { format!("(%{n})") } else if i < 7 { format!("%{n})") } else { format!("%{n}.") }; (f.get(i - 2).copied().unwrap_or(F::Decimal), t, 36.0 + 7.2 * (i - 2) as f32) } }, "outlineHeadings" => { let f = [ F::UpperRoman, F::UpperLetter, F::Decimal, F::LowerLetter, F::Decimal, F::LowerLetter, F::LowerRoman, F::LowerLetter, F::LowerRoman, ]; let t = match i { 0..=2 => format!("%{n}."), 3 => format!("%{n})"), _ => format!("(%{n})"), }; (f.get(i).copied().unwrap_or(F::Decimal), t, 36.0 * n as f32) } "chapter" if i == 0 => (F::Decimal, "Chapter %1".to_string(), 0.0), "chapter" => (F::None, String::new(), 0.0), _ => return None, }; // "Section 1.01" shows the article number in digits (legal numbering). Some(Level { format, text, legal: preset == "article" && i == 1, ..at(indent) }) }) .collect::>>()?; Some(levels) } /// A Multilevel List gallery preset: a new list linked to Heading 1–9. The heading styles carry /// the number, so every heading (and every heading added later) is numbered. Paragraphs in a /// Heading style keep their level; any other paragraph under the caret or selection (Normal, /// Quote, Title…) becomes Heading 1, as in Word. fn preset(s: &mut Session, v: &Value) -> CmdResult { let id = p::req_str(v, "preset")?; let levels = preset_levels(id).ok_or_else(|| CmdError::Params("`preset` must be headings, article, outlineHeadings or chapter".into()))?; let num = s.doc.numbering.try_add_levels(None, levels)?; let mut linked = Vec::new(); for n in 1..=LEVELS as u8 { let id = super::para::ensure_heading_style(s, n); if let Some(st) = s.doc.styles.get_mut(&id) { st.para.numbering = Some(NumRef { num, level: n - 1 }); linked.push(id); } } touch_styles(s, &linked)?; super::para::fmt(s, &|pp| { // A heading keeps its style and level; any other paragraph (Normal, Quote, Title…) // becomes the first level's heading, Heading 1. The number comes from the style. if !pp.style.as_ref().is_some_and(|st| linked.contains(st)) { pp.style = linked.first().cloned(); } pp.numbering = None; pp.indent_left = None; pp.indent_first = None; pp.indent_left_chars = None; pp.indent_first_chars = None; })?; let mut r = sel_result(s)?; r["num"] = json!(num); Ok(r) } /// Lay out again every paragraph in one of `nums`. fn touch_lists(s: &mut Session, nums: &[u32]) -> Result<(), CmdError> { touch_where(s, &|p| p.props.numbering.is_some_and(|n| nums.contains(&n.num))) } /// Lay out again every paragraph in one of `styles`. fn touch_styles(s: &mut Session, styles: &[String]) -> Result<(), CmdError> { touch_where(s, &|p| p.props.style.as_ref().is_some_and(|st| styles.contains(st))) } /// Lay out again every paragraph, in every story, that `f` picks. fn touch_where(s: &mut Session, f: &dyn Fn(&wordcraft_doc::Paragraph) -> bool) -> Result<(), CmdError> { let stories: Vec = std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect(); for story in stories { for path in s.doc.para_paths(story) { if s.doc.para(story, &path).is_some_and(f) { s.doc.para_mut(story, &path)?.touch(); } } } Ok(()) } #[cfg(test)] mod tests { use serde_json::json; use wordcraft_doc::Document; use wordcraft_layout::Placed; use crate::Session; fn labels(s: &mut Session) -> Vec { let l = s.layout(); l.pages .iter() .flat_map(|p| &p.items) .filter_map(|i| if let Placed::Lines { para, l0: 0, .. } = i { para.label.as_ref().map(|lb| lb.text.clone()) } else { None }) .collect() } /// #328: a custom three-level list numbers paragraphs 1. / 1.1. / a) and survives a save to /// .docx and back with every level setting. #[test] fn a_defined_multilevel_list_numbers_and_round_trips_through_docx() { let mut s = Session::new(Document::new()); for (i, t) in ["One", "Two", "Three"].iter().enumerate() { if i > 0 { s.run("text.newParagraph", &json!({})).unwrap(); } s.run("text.insert", &json!({"text": t})).unwrap(); } s.run("select.all", &json!({})).unwrap(); let levels = json!([ {"format": "decimal", "text": "%1.", "start": 1, "alignedAt": 0, "indentAt": 18, "follow": "tab", "tabAt": 24, "font": "Arial", "bold": true}, {"format": "decimal", "text": "%1.%2.", "alignedAt": 18, "indentAt": 45, "follow": "space", "restartAfter": 1}, {"format": "lowerLetter", "text": "%3)", "start": 1, "align": "right", "alignedAt": 54, "indentAt": 72, "follow": "nothing", "restartAfter": 0, "style": "Heading3"}, ]); let r = s.run("list.define", &json!({"levels": levels, "name": "Spec"})).unwrap(); let num = r["num"].as_u64().unwrap() as u32; // Second and third paragraphs one and two levels down. let paths = s.doc.para_paths(wordcraft_doc::StoryRef::Body); for (k, lvl) in [(1usize, 1u8), (2, 2)] { let p = s.doc.para_mut(wordcraft_doc::StoryRef::Body, &paths[k]).unwrap(); p.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: lvl }); p.touch(); } s.relayout(); assert_eq!(labels(&mut s), ["1.", "1.1.", "a)"]); let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); let mut b = Session::new(back); assert_eq!(labels(&mut b), ["1.", "1.1.", "a)"]); let got = b.run("list.get", &json!({"num": num})).unwrap(); let want = s.run("list.get", &json!({"num": num})).unwrap(); assert_eq!(got["levels"], want["levels"], "every level setting round-trips"); let l = &got["levels"]; assert_eq!((l[0]["tabAt"].as_f64(), l[0]["font"].as_str(), l[0]["bold"].as_bool()), (Some(24.0), Some("Arial"), Some(true))); assert_eq!((l[1]["follow"].as_str(), l[1]["alignedAt"].as_f64(), l[1]["indentAt"].as_f64()), (Some("space"), Some(18.0), Some(45.0))); assert_eq!((l[2]["restartAfter"].as_u64(), l[2]["align"].as_str(), l[2]["style"].as_str()), (Some(0), Some("right"), Some("Heading3"))); assert_eq!(l[2]["follow"], "nothing"); // Programmatic calls without levels are an error, not a dialog. assert!(s.run("list.define", &json!({})).is_err()); } /// Each body paragraph's number as laid out (`None`: no number), and its style. fn numbered(s: &mut Session) -> Vec<(Option, String)> { let l = s.layout(); let labels: Vec> = l .pages .iter() .flat_map(|p| &p.items) .filter_map(|i| if let Placed::Lines { para, l0: 0, .. } = i { Some(para.label.as_ref().map(|lb| lb.text.clone())) } else { None }) .collect(); let styles = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.props.style.clone().unwrap_or_else(|| "Normal".into())); labels.into_iter().zip(styles).collect() } fn n(label: Option<&str>, style: &str) -> (Option, String) { (label.map(str::to_string), style.to_string()) } /// Paragraphs "One" (Heading 1), "Two" (Heading 2), "Three" (`third`), the caret in "One". fn headings(third: &str) -> Session { let mut s = Session::new(Document::new()); for (i, (t, st)) in [("One", "Heading1"), ("Two", "Heading2"), ("Three", third)].iter().enumerate() { if i > 0 { s.run("text.newParagraph", &json!({})).unwrap(); } s.run("text.insert", &json!({"text": t})).unwrap(); s.run("para.style", &json!({"style": st})).unwrap(); } s.sel = crate::Selection::caret(wordcraft_doc::Pos::body(0, 0)); s } /// #1793: the Multilevel List gallery's heading presets number headings through their styles /// (Word: One=1, Two=1.1, Three=2), keep the styles, and survive a save to .docx and back. #[test] fn a_heading_preset_numbers_headings_and_keeps_their_styles() { let mut s = headings("Heading1"); s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); let want = [n(Some("1"), "Heading1"), n(Some("1.1"), "Heading2"), n(Some("2"), "Heading1")]; assert_eq!(numbered(&mut s), want); // The number comes from the style, so a heading added later is numbered too. let h2 = s.doc.styles.get("Heading2").and_then(|st| st.para.numbering).unwrap(); assert_eq!(h2.level, 1); let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); assert_eq!(back.styles.get("Heading2").and_then(|st| st.para.numbering).map(|x| x.level), Some(1), "numPr on the style"); let mut b = Session::new(back); assert_eq!(numbered(&mut b), want); } /// #1793: the other heading presets of Word's List Library, level by level. #[test] fn every_heading_preset_numbers_three_heading_levels() { for (preset, want) in [ ("headings", [Some("1"), Some("1.1"), Some("1.1.1")]), ("article", [Some("Article I."), Some("Section 1.01"), Some("(a)")]), ("outlineHeadings", [Some("I."), Some("A."), Some("1.")]), ("chapter", [Some("Chapter 1"), None, None]), ] { let mut s = headings("Heading3"); s.run("list.multilevelPreset", &json!({"preset": preset})).unwrap(); let got: Vec> = numbered(&mut s).into_iter().map(|x| x.0).collect(); assert_eq!(got, want.map(|x| x.map(str::to_string)), "{preset}"); } let mut s = headings("Heading3"); assert!(s.run("list.multilevelPreset", &json!({"preset": "nope"})).is_err()); assert!(s.run("list.multilevelPreset", &json!({})).is_err()); } /// Paragraphs (text, style id), the selection from paragraph `from` to the end of `to`. fn doc(paras: &[(&str, &str)], from: usize, to: usize) -> Session { let mut s = Session::new(Document::new()); for (i, (t, st)) in paras.iter().enumerate() { if i > 0 { s.run("text.newParagraph", &json!({})).unwrap(); } s.run("text.insert", &json!({"text": t})).unwrap(); s.run("para.style", &json!({"style": st})).unwrap(); } let end = paras.get(to).map_or(0, |p| p.0.len()); s.sel = crate::Selection { anchor: wordcraft_doc::Pos::body(from, 0), focus: wordcraft_doc::Pos::body(to, end) }; s } /// #1793 (Word for Mac 16.113.4, 2026-10-16): with a caret or a selection, a heading preset /// leaves paragraphs already in a Heading style at their own style and level; every other /// selected paragraph (Normal, Quote, Title…) becomes Heading 1. #[test] fn a_heading_preset_keeps_headings_and_makes_other_paragraphs_heading_1() { let (h1, h2, h3) = ("Heading1", "Heading2", "Heading3"); let cases: Vec<(Vec<(&str, &str)>, usize, usize, Vec<(Option<&str>, &str)>)> = vec![ // All selected. (vec![("One", h1), ("Two", h2), ("Three", h3)], 0, 2, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("1.1.1"), h3)]), // No Heading 1 above. (vec![("Two", h2), ("Three", h3)], 0, 1, vec![(Some("1.1"), h2), (Some("1.1.1"), h3)]), (vec![("Body", "Normal"), ("Three", h3)], 0, 1, vec![(Some("1"), h1), (Some("1.1.1"), h3)]), (vec![("One", h1), ("Body", "Normal"), ("Two", h2)], 0, 2, vec![(Some("1"), h1), (Some("2"), h1), (Some("2.1"), h2)]), (vec![("One", h1), ("Two", h2), ("Body", "Normal")], 0, 2, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("2"), h1)]), // The caret in Two. (vec![("One", h1), ("Two", h2), ("Three", h3)], 1, 1, vec![(Some("1"), h1), (Some("1.1"), h2), (Some("1.1.1"), h3)]), (vec![("Quote", "Quote"), ("Two", h2)], 0, 1, vec![(Some("1"), h1), (Some("1.1"), h2)]), (vec![("Title", "Title")], 0, 0, vec![(Some("1"), h1)]), // Body text after the selection stays unnumbered. (vec![("One", h1), ("After", "Normal")], 0, 0, vec![(Some("1"), h1), (None, "Normal")]), ]; for (paras, from, to, want) in cases { let mut s = doc(¶s, from, to); s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); let want: Vec<_> = want.into_iter().map(|(l, st)| n(l, st)).collect(); assert_eq!(numbered(&mut s), want, "{paras:?} {from}..={to}"); } } /// #1793 (Word for Mac): the caret alone in a Normal paragraph makes it Heading 1 numbered 1. #[test] fn a_heading_preset_on_a_lone_normal_paragraph_makes_it_heading_1() { let mut s = doc(&[("Body", "Normal")], 0, 0); s.sel = crate::Selection::caret(wordcraft_doc::Pos::body(0, 2)); s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); assert_eq!(numbered(&mut s), [n(Some("1"), "Heading1")]); } /// #1793: one Undo takes the preset back: paragraph styles and the heading styles' numbers. #[test] fn undo_takes_a_heading_preset_back_in_one_step() { let mut s = doc(&[("Body", "Normal"), ("Two", "Heading2")], 0, 1); let before = numbered(&mut s); s.run("list.multilevelPreset", &json!({"preset": "headings"})).unwrap(); assert_ne!(numbered(&mut s), before); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(numbered(&mut s), before); for n in 1..=9 { assert_eq!(s.doc.styles.get(&format!("Heading{n}")).and_then(|st| st.para.numbering), None, "Heading{n}"); } } }