Files
wordcraft/crates/engine/src/cmd/lists.rs
T
e7f5d32c35 Numbered headings: heading-linked multilevel list presets keep the heading styles (#1793) (#2239)
* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1793: wording: other paragraph styles become Heading 1, not only body text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 04:22:12 -04:00

540 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<CommandSpec> {
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<u32> {
let para = s.doc.para_at(&s.sel.focus)?;
para.props.numbering.or_else(|| s.doc.styles.resolve_para(&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<Vec<Level>> {
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::<Vec<_>>()}))
}
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<Level> = 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::<String>()).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<u32> = 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<Vec<Level>> {
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::<Vec<_>>().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::<Option<Vec<_>>>()?;
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<StoryRef> = 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<String> {
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>, String)> {
let l = s.layout();
let labels: Vec<Option<String>> = 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>, 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<Option<String>> = 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(&paras, 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}");
}
}
}