Files
wordcraft/crates/engine/src/cmd/lists.rs
T
9b6e9564a2 Lists: a numFmt none level shows its literal text, so RESPONSE: labels the paragraph (#4985) (#5055)
* #4985: a numFmt none list level shows its literal text

Word labels a paragraph whose level has numFmt none and lvlText RESPONSE:
with RESPONSE:. Counters::next_label returned no label for any none level;
now only a none level of placeholders shows nothing. Its own %n stays
empty, and it still doesn't count. Numbering::shows_label agrees.

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

* #4985: rustfmt

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

* #4985: a "none" list level counts, and only its own number is empty

Taken from MeHail-02's numbering-literal-none branch (46405e66), checked in Word
for Windows: a none level is an ordinary level whose own number reads as
nothing. It counts and restarts the levels below it, stays empty in legal
numbering, and an empty label still tabs the text to the indent unless it
already ends there (the Chapter 1 list's Heading 2 stays at the margin).

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

* #4985: STYLEREF in full context keeps a none-level heading's parent

Review: a none level now has a label, so its headings reach the style index,
which leaves the none level out of the levels it keeps; \w and \r took the last
kept level for the heading's own and dropped the parent. Levels now carry their
index, and the levels above are picked by it.

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

* #4985: STYLEREF number switches on a none-level heading

Review: in full context an empty label of its own added a trailing period (3.),
and the suppress switch gave a none level's label (Note), which holds no number,
back whole. An empty own part is left out, and a none level's label with no
number is suppressed to nothing.

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

* #4985: STYLEREF \s and \t on none-level labels

Review: \s took a none level's label (Note) for a chapter number; the heading's
count stands in, as for an unnumbered heading. \t matched any ancestor's number
inside a word (the a of Chapter); only the numbers the label's template shows
count now.

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

* #4985: STYLEREF takes a label's numbers by their spans

Review: \s took the last word of a label (Response of "3 Response") and fell
back on the heading count for a none level showing its parent's number (%1);
\t kept text attached to a number (Section3). A label's numbers are now the
spans of the values its template shows, with their delimiters (a letter number
only standing apart from other letters); \s takes the last of them.

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

* #4985: tests edit the numbering through Arc::make_mut after merging main

Main shares the numbering between undo steps (#2694), so `Document::numbering` is an
`Arc<Numbering>`; the tests this PR adds wrote to it directly and no longer built.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: MeHail <mihailsmetanin15@gmail.com>
Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
2026-11-08 03:20:49 +01:00

572 lines
27 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 std::sync::Arc;
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) = Arc::make_mut(&mut 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) = Arc::make_mut(&mut 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 => Arc::make_mut(&mut 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) = Arc::make_mut(&mut 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 = Arc::make_mut(&mut 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) = Arc::make_mut(&mut 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);
}
/// #5757: a list numbered in Thai digits shows them on the page, with the level's text around
/// them (`๑.`, `(๒)`), and keeps its format through a save to .docx and back.
#[test]
fn a_thai_numbered_list_shows_thai_digits_and_survives_docx() {
let mut s = Session::new(Document::new());
for (i, t) in ["หนึ่ง", "สอง", "สาม", "a", "b", "c", "d", "e", "f", "g", "h"].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": "thaiNumbers", "text": "%1.", "start": 1}]);
s.run("list.define", &json!({"levels": levels})).unwrap();
let page: Vec<Option<String>> = numbered(&mut s).into_iter().map(|x| x.0).collect();
let want: Vec<Option<String>> =
["๑.", "๒.", "๓.", "๔.", "๕.", "๖.", "๗.", "๘.", "๙.", "๑๐.", "๑๑."].iter().map(|l| Some(l.to_string())).collect();
assert_eq!(page, want);
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
let mut b = Session::new(back);
let again: Vec<Option<String>> = numbered(&mut b).into_iter().map(|x| x.0).collect();
assert_eq!(again, want, "thaiNumbers survives .docx");
// In parentheses too.
let mut s = Session::new(Document::new());
s.run("text.insert", &json!({"text": "x"})).unwrap();
s.run("select.all", &json!({})).unwrap();
s.run("list.define", &json!({"levels": [{"format": "thaiNumbers", "text": "(%1)", "start": 2}]})).unwrap();
assert_eq!(numbered(&mut s)[0].0.as_deref(), Some("(๒)"));
}
/// #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.")]),
// The lower levels are "none" levels with no text: an empty label, as in Word (#4985).
("chapter", [Some("Chapter 1"), Some(""), Some("")]),
] {
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}");
}
}
}