From 9b6e9564a2d276a0a7898c75580bf6708d0a21d8 Mon Sep 17 00:00:00 2001 From: Diego Guerini <106814760+D13GOOOO@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:20:49 +0100 Subject: [PATCH] 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 * #4985: rustfmt Co-Authored-By: Claude Opus 5.5 * #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 Co-Authored-By: Claude Opus 5.5 * #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 * #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 * #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 * #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 * #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`; the tests this PR adds wrote to it directly and no longer built. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: MeHail Co-authored-by: Martin Ball --- ROADMAP.md | 7 ++- crates/doc/src/numbering.rs | 43 +++++++++++--- crates/docx/tests/it/fixtures.rs | 25 ++++++++ crates/engine/src/cmd/lists.rs | 3 +- crates/layout/src/para/lines.rs | 3 + crates/layout/src/styleref.rs | 91 +++++++++++++++++++++++------ crates/layout/src/tests.rs | 24 ++++++++ crates/layout/src/tests/styleref.rs | 43 ++++++++++++++ docs/gaps.md | 4 +- docs/layout-parity.md | 3 +- 10 files changed, 216 insertions(+), 30 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6549c2d8bf..2418e28c8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -156,6 +156,10 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-05: ODT pictures offered in several formats show and survive a save (gap 19): LibreOffice's letter templates lost theirs. - 2026-11-07: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). Exact spacing keeps the line's baseline. - 2026-11-03: Search types in place: a click on the title bar's Search box, or Alt+Q, takes the typing in that box, with the results under it, as in Word, instead of a second box in a dialog (#5398). +- 2026-10-31: List labels: a numbering level with format `none` and literal text, such as `RESPONSE:` with a one-inch hanging indent, shows that text as its label, as Word does; it used to show nothing. As in Word, a `none` level still counts and restarts the levels below it, its own number is empty (in legal numbering too), and an empty label still tabs the text to the indent (#4985). Readiness unchanged. +- 2026-11-01: DOCX tables keep rows and cells wrapped in `mc:AlternateContent`, selecting one supported Choice or Fallback with the existing reader policy (#5219). Original synthetic checks cover namespace aliases, unsupported requirements, nested row/cell content controls and two save/reopen cycles; broader corpus and native Word qualification remain open. + +- 2026-11-01: Shapes: a line's arrowheads are as long and as wide as Word draws them: 2, 3 or 5 times the line's width for small, medium and large (a line under 2 points as a 2-point one). Their kind, width and length are read from DOCX (DrawingML and VML) and saved back; before, every arrowhead was one size and saved as a medium triangle. - 2026-11-06: Windows print submissions run in separate helper processes (#5710). A helper acknowledges cancellation before it stops, so a driver stalled in a cancelled job cannot hold another job’s provider lock. A private temporary PDF and bounded settings messages cross the process boundary; normal shutdown reaps helpers and removes their inputs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Jobs already sending still complete. Readiness estimates unchanged. - 2026-11-06: File › Print: before sending starts, Cancel printing stops a waiting job, frees the Print page and keeps the settings; a successfully cancelled job is never sent. Cancellation remains available after the last document closes. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. Readiness estimates unchanged. - 2026-11-06: File › Print on Windows no longer waits on printer drivers to list printers (#5710). The list used to wait until each printer's driver said whether it prints on both sides, and a networked printer asleep in power save could keep it empty with Refresh unavailable; now discovery asks no driver (the Windows spooler still supplies the list), and printing still checks the job's options with the driver. The reporter's sleeping printer has not been retested yet. Readiness estimates unchanged. @@ -834,6 +838,8 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-05 | trivial | Page Setup's margin, paper-size and document-grid number boxes scroll long text inside the field instead of enlarging the dialog (#5650). Readiness estimates unchanged. | | 2026-11-07 | trivial | Progress log: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | | 2026-11-03 | trivial | Progress log: the title bar's Search box takes the typing in place (#5398) | +| 2026-10-31 | trivial | Progress log: a numFmt none list level shows its literal text (#4985); readiness unchanged | +| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions isolated per helper process; cancelled driver work cannot block a retry (#5710) | | 2026-11-06 | trivial | Progress log: print cancellation before sending, including without a document, and the Windows repeated-conversion limitation (#5710) | @@ -883,7 +889,6 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-01 | trivial | Progress log: Language packs install from File › Options › Language and the missing-pack bar once language-packs-1 is published (CP4) | | 2026-11-01 | trivial | Progress log: English spelling reads SCOWL 2026 (CP2) | | 2026-11-01 | trivial | Progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes | -| 2026-11-02 | trivial | Progress log: alternative format chunks and MHT open | | 2026-11-01 | trivial | Progress log: columns for selected text draw their line between (#5178) | | 2026-10-31 | trivial | Progress log: a Word 365 .doc in Aptos opens in Aptos (#5066); readiness unchanged | | 2026-10-31 | trivial | Progress log: a table row doesn't split off a picture that doesn't fit (#4272) | diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index a5d2ec6e10..5ab94ea877 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -228,7 +228,7 @@ impl Numbering { /// Whether a paragraph in list `num` at `level` gets a label ([`Counters::next_label`] gives /// one), without counting it. pub fn shows_label(&self, num: u32, level: u8) -> bool { - self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some_and(|d| d.format != NumFormat::None) + self.abstract_of(num).is_some() && self.level(num, level.min(8)).is_some() } pub fn level(&self, num: u32, level: u8) -> Option<&Level> { if let Some((_, l)) = self.num(num)?.level_overrides.iter().find(|(i, _)| *i == level) { @@ -325,12 +325,9 @@ impl Counters { let lv = (level as usize).min(8); // This list's own definition of a level wins over its abstract list's. let level_def = |k: usize| numbering.level(num, k as u8); + // A "none" level (Word's numFmt none) still counts and restarts the levels below it; only + // its number reads as nothing, so its literal text ("RESPONSE:") shows (#4985). let def = level_def(lv)?.clone(); - // A "none" level shows no label at all (Word's numFmt none) — not even its text. - if def.format == NumFormat::None { - self.labels.insert((abs.id, lv as u8), CountedLabel { label: None, levels: Vec::new() }); - return None; - } let st = self.state.entry(abs.id).or_insert([0; 9]); let fl = self.flags.entry(abs.id).or_default(); // Only this level's override, not the num's others: the counters are shared by every @@ -382,7 +379,14 @@ impl Counters { let started = *fl; let numeric = def.format != NumFormat::Bullet; let label = if numeric { render_label(numbering, num, &def, &vals, &started) } else { def.text.clone() }; - self.labels.insert((abs.id, lv as u8), CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) }); + // A "none" level shows its literal text, but has no number of its own for a reference to + // show (#4985): it is kept as one that shows none. + let counted = if def.format == NumFormat::None { + CountedLabel { label: None, levels: Vec::new() } + } else { + CountedLabel { label: numeric.then(|| label.clone()), levels: template_levels(&def.text) } + }; + self.labels.insert((abs.id, lv as u8), counted); Some((label, def)) } /// Which levels of `num`'s list have counted a paragraph yet (a level never used has no @@ -455,9 +459,9 @@ fn render_label(numbering: &Numbering, num: u32, def: &Level, vals: &[u32; 9], s let counted = started.get(k).is_some_and(|f| f.started); let val = if counted { val } else { level_def(k).map(|l| l.start).unwrap_or(1) }; // Legal numbering (w:isLgl) shows every level in Arabic digits, whatever its own - // format (二 of a Chinese outline becomes 2 in "2.1"). + // format (二 of a Chinese outline becomes 2 in "2.1"); a "none" level stays empty. let fmt = match level_def(k).map(|l| l.format).unwrap_or(NumFormat::Decimal) { - NumFormat::DecimalZero => NumFormat::DecimalZero, + f @ (NumFormat::DecimalZero | NumFormat::None) => f, _ if def.legal => NumFormat::Decimal, f => f, }; @@ -556,6 +560,27 @@ mod tests { assert_eq!(n.level(id, 2).map(|l| l.format), Some(NumFormat::DecimalZero)); } + #[test] + fn a_none_level_shows_its_text_and_still_counts() { + // As Word shows them (#4985): a "none" level's literal text is its label, its own number + // reads as nothing (also in legal numbering), and it restarts the levels below it. + let mut n = Numbering::default(); + let id = n.add_list(ListKind::Numbered); + let lvl = |format, text: &str| Level { format, text: text.into(), ..Level::default() }; + let legal = Level { legal: true, ..lvl(NumFormat::Decimal, "%2.%3") }; + if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) { + num.level_overrides = vec![(0, lvl(NumFormat::None, "RESPONSE:")), (1, lvl(NumFormat::None, "[%2]")), (2, legal)]; + } + assert!(n.shows_label(id, 0)); + let mut c = Counters::default(); + assert_eq!(c.next_label(&n, id, 0).unwrap().0, "RESPONSE:"); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".2"); + assert_eq!(c.next_label(&n, id, 1).unwrap().0, "[]"); + assert_eq!(c.next_label(&n, id, 2).unwrap().0, ".1"); + } + #[test] fn a_start_override_restarts_only_its_own_level_of_a_shared_list() { // One abstract list numbers the headings through two nums: one for levels 1-4, and one diff --git a/crates/docx/tests/it/fixtures.rs b/crates/docx/tests/it/fixtures.rs index 1df2e96f9a..3b4779a248 100644 --- a/crates/docx/tests/it/fixtures.rs +++ b/crates/docx/tests/it/fixtures.rs @@ -2462,6 +2462,31 @@ fn east_asian_number_formats_read_label_and_round_trip() { assert_eq!(formats(&back), want); } +/// #4985: a level with numFmt none and literal text (`RESPONSE:`) labels its paragraph with that +/// text, as in Word, and keeps both through a save. +#[test] +fn a_none_level_s_literal_text_is_its_label() { + use wordcraft_doc::numbering::Counters; + use wordcraft_doc::section::NumFormat; + let numbering = format!( + r#""# + ); + let body = r#"X"#; + let bytes = docx(body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]); + let label = |d: &Document| { + let n = paras(d).first().and_then(|p| p.props.numbering)?; + Counters::default().next_label(&d.numbering, n.num, n.level).map(|l| l.0) + }; + let d = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(label(&d).as_deref(), Some("RESPONSE:")); + let back = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap(); + assert_eq!( + back.numbering.level(1, 0).map(|l| (l.format, l.text.as_str(), l.indent, l.hanging)), + Some((NumFormat::None, "RESPONSE:", 72.0, 72.0)) + ); + assert_eq!(label(&back).as_deref(), Some("RESPONSE:")); +} + /// #464: a bookmark whose end follows the document's final table keeps its end (it goes into /// the table's last cell) and is saved as a matched pair. #[test] diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 5fa185324c..987e9fd537 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -489,7 +489,8 @@ mod tests { ("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]), + // 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(); diff --git a/crates/layout/src/para/lines.rs b/crates/layout/src/para/lines.rs index 9edfeb2b7f..f29a7029f9 100644 --- a/crates/layout/src/para/lines.rs +++ b/crates/layout/src/para/lines.rs @@ -297,6 +297,9 @@ impl<'e> Breaker<'e> { }; let end = lab.x + lab.width; match self.level.map(|l| l.suffix).unwrap_or(LevelSuffix::Tab) { + // An empty label (a "none" level with no text) that ends right at the left indent takes + // no tab: the text starts there, as in Word (#4985), and not at the next stop. + LevelSuffix::Tab if lab.width <= 0.01 && (end - self.rp.indent_left).abs() <= 0.01 => end, LevelSuffix::Tab => { let t = next_tab(end, &self.rp.tabs, self.default_tab, self.hanging_at, self.tab_origin).pos; // The level's own tab stop after the number, when it comes first. diff --git a/crates/layout/src/styleref.rs b/crates/layout/src/styleref.rs index 5f2db6045e..8d03623e6f 100644 --- a/crates/layout/src/styleref.rs +++ b/crates/layout/src/styleref.rs @@ -125,9 +125,11 @@ struct Entry { runs: Vec<(String, String)>, /// The list label as shown (`Chapter 2.`). label: Option, - /// Each level's value up to the paragraph's, formatted; and whether the label's own - /// template shows that level. - levels: Vec<(String, bool)>, + /// Each level up to the paragraph's that has a number (not a bullet, nor a "none" level): + /// its index, its value formatted, and whether the label's own template shows it. + levels: Vec<(u8, String, bool)>, + /// The paragraph's own list level. + level: u8, /// How many paragraphs of its style there are up to this one. ordinal: u32, } @@ -180,7 +182,7 @@ impl StyleIndex { *ord = ord.saturating_add(1); let ordinal = *ord; let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0); - let (mut label, mut levels) = (None, Vec::new()); + let (mut label, mut levels, mut level) = (None, Vec::new(), 0u8); if let Some(n) = num && let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level) && def.format != NumFormat::Bullet @@ -193,9 +195,10 @@ impl StyleIndex { } let v = if *v == 0 { lv.start } else { *v }; let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format }; - levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1)))); + levels.push((k as u8, fmt.format(v), def.text.contains(&format!("%{}", k + 1)))); } label = Some(l); + level = n.level.min(8); } let mut runs: Vec<(String, String)> = Vec::new(); let mut off = 0usize; @@ -221,7 +224,7 @@ impl StyleIndex { list.push(i); } } - entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal }); + entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, level, ordinal }); } StyleIndex { entries, by_path, styles, para_entries, char_entries } } @@ -323,18 +326,32 @@ impl StyleIndex { let mut out = match code.number { Number::Text if code.position => String::new(), Number::Text => text, - Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()), + // The last number the label shows (`3` of `3 Response`, or of a "none" level's `%1`); + // a label of the paragraph's own number that shows none recognisably, its last word; + // else, as for an unnumbered heading or a "none" level's `Note`, the heading's count. + Number::Chapter => { + let own = e.levels.iter().any(|(k, ..)| *k == e.level); + e.label + .as_deref() + .and_then(|l| { + let shown = l.split_whitespace().rev().find_map(|w| number_span(w, &e.levels)); + shown.map(|n| n.trim_matches(|c: char| !c.is_alphanumeric()).to_string()).or_else(|| own.then(|| chapter_number(l))) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| e.ordinal.to_string()) + } Number::Plain | Number::Full => { let own = match &e.label { - Some(l) if code.suppress => suppress(l, &e.levels), + Some(l) if code.suppress => suppress(l, &e.levels, e.level), Some(l) => l.trim().trim_end_matches('.').to_string(), None => e.ordinal.to_string(), }; if code.number == Number::Full && e.label.is_some() { - // Levels above that the label's own template doesn't show. - let above: Vec<&str> = - e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect(); - if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) } + // Levels above the paragraph's that the label's own template doesn't show. By + // level, not position: a "none" level has no entry, its own (#4985) included. + let above: Vec<&str> = e.levels.iter().filter(|(k, _, shown)| *k < e.level && !shown).map(|(_, v, _)| v.as_str()).collect(); + // An empty label of its own (a "none" level with no text) adds no part: `3`, not `3.`. + above.into_iter().chain(Some(own.as_str()).filter(|o| !o.is_empty())).collect::>().join(".") } else { own } @@ -356,13 +373,46 @@ fn chapter_number(label: &str) -> String { word.trim_matches(|c: char| !c.is_alphanumeric()).to_string() } -/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). -fn suppress(label: &str, levels: &[(String, bool)]) -> String { - let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect(); - let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") }; +/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`). A "none" level's +/// label (`level` has no number of its own) holding none is left with nothing (`Note`, #4985); +/// any other label that holds none is kept whole. +fn suppress(label: &str, levels: &[(u8, String, bool)], level: u8) -> String { + // Each word's numbers, without the text attached to them (`Section3` → `3`). + let kept: Vec<&str> = label.split_whitespace().filter_map(|w| number_span(w, levels)).collect(); + let none = !levels.iter().any(|(k, ..)| *k == level); + let s = match kept.is_empty() { + true if none => String::new(), + true => label.trim().to_string(), + false => kept.join(" "), + }; s.trim_end_matches('.').to_string() } +/// The part of `word` that holds the numbers the label's template shows (`levels` flagged shown), +/// with the delimiters around it (`(3)`, `1.1`, `2.`), without the text attached (`Section3` → +/// `3`); `None` when it holds none. A number written in letters (`a`, `iv`) counts only standing +/// apart from other letters: the `a` of `Chapter` isn't one. +fn number_span<'w>(word: &'w str, levels: &[(u8, String, bool)]) -> Option<&'w str> { + let alpha = |c: Option| c.is_some_and(char::is_alphabetic); + let mut span: Option<(usize, usize)> = None; + for (_, v, _) in levels.iter().filter(|(_, v, shown)| *shown && !v.is_empty()) { + let letters = v.chars().any(char::is_alphabetic); + for (i, _) in word.match_indices(v.as_str()) { + let end = i.saturating_add(v.len()); + let before = word.get(..i).and_then(|s| s.chars().next_back()); + let after = word.get(end..).and_then(|s| s.chars().next()); + if letters && (alpha(before) || alpha(after)) { + continue; + } + span = Some(span.map_or((i, end), |(a, b)| (a.min(i), b.max(end)))); + } + } + let (a, b) = span?; + let lead: usize = word.get(..a)?.chars().rev().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum(); + let trail: usize = word.get(b..)?.chars().take_while(|c| !c.is_alphanumeric()).map(char::len_utf8).sum(); + word.get(a.saturating_sub(lead)..b.saturating_add(trail)) +} + /// Does `p` hold a STYLEREF field? pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool { p.objects.iter().any(is_styleref) @@ -441,7 +491,14 @@ mod tests { #[test] fn suppress_keeps_the_number() { - assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2"); + assert_eq!(suppress("Chapter 2.", &[(0, "2".into(), true)], 0), "2"); + assert_eq!(suppress("Chapter", &[(0, "a".into(), false)], 1), "", "an ancestor's number the label doesn't show"); + assert_eq!(suppress("Section3", &[(0, "3".into(), true)], 1), "3", "the text attached to the number"); + assert_eq!(suppress("Chapter a.", &[(0, "a".into(), true)], 0), "a", "a letter number standing apart, not Chapter's a"); + assert_eq!(suppress("(iv)", &[(0, "iv".into(), true)], 0), "(iv)", "delimiters stay"); + assert_eq!(suppress("1.2", &[(0, "1".into(), true), (1, "2".into(), true)], 1), "1.2"); + assert_eq!(suppress("Note", &[(0, "3".into(), false)], 1), "", "a none level's label"); + assert_eq!(suppress("Appendix", &[(0, "3".into(), false)], 0), "Appendix", "kept whole when the level has a number"); assert_eq!(chapter_number("Chapter IV."), "IV"); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index ca0805afc7..fa69a3d93a 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -1820,6 +1820,30 @@ fn lists_get_labels_and_indent() { assert!((x - 108.0).abs() < 0.5, "{x}"); } +#[test] +fn a_none_format_label_shows_its_text_and_tabs_to_the_indent() { + // #4985: Word draws a "none" level's literal text ("RESPONSE:"), and an empty one still tabs + // the text to a hanging indent, as a numbered item's. An empty label already at the left indent + // takes no tab (Word's Chapter 1 list: Heading 2 starts at the margin, not at 0.5"). + use wordcraft_doc::section::NumFormat; + for (text, indent, hanging, want) in [("RESPONSE:", 72.0, 72.0, 144.0), ("", 72.0, 72.0, 144.0), ("", 0.0, 0.0, 72.0), ("", 50.0, 0.0, 122.0)] { + let mut d = Document::from_text("X"); + let id = std::sync::Arc::make_mut(&mut d.numbering).add_list(wordcraft_doc::ListKind::Numbered); + for a in &mut std::sync::Arc::make_mut(&mut d.numbering).abstracts { + if let Some(l) = a.levels.first_mut() { + l.format = NumFormat::None; + l.text = text.into(); + (l.indent, l.hanging) = (indent, hanging); + } + } + d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 1), &|p| p.numbering = Some(wordcraft_doc::props::NumRef { num: id, level: 0 })).unwrap(); + let l = lay(&d); + assert_eq!(labels(&l), [text]); + let x = l.caret(&Pos::body(0, 0)).unwrap().x; + assert!((x - want).abs() < 0.5, "{text:?} at {indent}/{hanging}: {x}, not {want}"); + } +} + #[test] fn break_only_list_has_no_label() { use wordcraft_doc::ListKind; diff --git a/crates/layout/src/tests/styleref.rs b/crates/layout/src/tests/styleref.rs index e800f51f87..b6e91f6017 100644 --- a/crates/layout/src/tests/styleref.rs +++ b/crates/layout/src/tests/styleref.rs @@ -97,6 +97,49 @@ fn character_styles_and_paragraph_numbers() { assert_eq!(h[3], ["two", "3", "3.a", "3"]); } +/// #4985: a heading on a "none" list level (its label is its text, `Note`) shows its parent's +/// number in full context (`\w`): the parent is not taken for the heading's own level, which +/// has no number to list. +#[test] +fn full_context_keeps_the_parent_of_a_none_level_heading() { + let mut d = doc(CHAPTERS, &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) { + let note = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: "Note".into(), ..Default::default() }; + n.level_overrides = vec![(1, note)]; + } + for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl }); + } + assert_eq!(headers(&d)[3], ["3.Note", "3.Note"]); +} + +/// #4985 review: `\t` leaves a "none" level's label, which holds no number, with nothing, and an +/// empty label of its own adds no part in full context: `3`, not `3.`. +#[test] +fn a_none_level_heading_s_number_has_no_text_and_no_trailing_period() { + let numbered = |text: &str, codes: &[&str]| { + let mut d = doc(CHAPTERS, codes); + let num = Arc::make_mut(&mut d.numbering).add_list(ListKind::Numbered); + if let Some(n) = Arc::make_mut(&mut d.numbering).nums.iter_mut().find(|n| n.id == num) { + let level = wordcraft_doc::numbering::Level { format: wordcraft_doc::section::NumFormat::None, text: text.into(), ..Default::default() }; + n.level_overrides = vec![(1, level)]; + } + for (i, lvl) in [(0, 0u8), (2, 0), (5, 0), (6, 1)] { + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().props.numbering = Some(NumRef { num, level: lvl }); + } + headers(&d)[3].clone() + }; + assert!(numbered("Note", &[r#"STYLEREF "Heading 2" \n \t"#]).is_empty(), "nothing is drawn"); + // `\s` is a chapter number: a "none" label isn't one, so the heading's count stands in; a + // label showing its parent's number (`%1`, `%1 Response`) gives that number. + assert_eq!(numbered("Note", &[r#"STYLEREF "Heading 2" \s"#]), ["1"]); + assert_eq!(numbered("%1", &[r#"STYLEREF "Heading 2" \s"#]), ["3"]); + assert_eq!(numbered("%1 Response", &[r#"STYLEREF "Heading 2" \s"#, r#"STYLEREF "Heading 2" \n \t"#]), ["3", "3"]); + assert_eq!(numbered("Section%1", &[r#"STYLEREF "Heading 2" \n \t"#]), ["3"]); + assert_eq!(numbered("", &[r#"STYLEREF "Heading 2" \w"#, r#"STYLEREF "Heading 2" \r"#]), ["3", "3"]); +} + /// Editing a heading updates the headers that show it, through the paragraph cache. #[test] fn headers_follow_heading_edits() { diff --git a/docs/gaps.md b/docs/gaps.md index 173a8d8db9..66f41b99e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -189,6 +189,9 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-04 | trivial | Field argument import and page-format-aware layout/cache follow-ups; no ranked gap changed | | 2026-11-03 | trivial | Field argument parsing follows Word quoted backslash escapes; body PAGE formatting follows the active section in paragraphs and tables; no ranked gap changed | | 2026-11-07 | trivial | Pagination gap #4: An in-line picture lowered past its own height (`w:position`) lowers its line as far, as in Word; all 156 synthetic lines measured for #3984 match Word (#5796). | +| 2026-10-31 | trivial | A list level with numFmt none shows its literal text as its label and still counts, as in Word (#4985); no ranked gap changed; readiness estimates unchanged | +| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | +| 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. | | 2026-11-06 | trivial | Print helpers remove loaded PDF/settings files before driver work, tolerate prior removal and report cleanup errors; a held-driver regression checks the files are already absent (#5710) | | 2026-11-06 | trivial | Windows print submissions use a separate helper per job, preserving provider serialization inside the helper. An acknowledged cancellation stops that helper, so a stalled cancelled driver cannot block a later job; normal shutdown reaps helpers and removes temporary PDFs. The helper erases its plaintext inputs before driver work; a hard app crash during startup can still leave private temporary files. Startup requires an explicit start grant; after sending begins, jobs complete (#5710) | | 2026-11-06 | trivial | Gap 6: File › Print: before sending starts, Cancel printing (`ui.printCancel`) stops a waiting job and frees the Print page while keeping its settings, including after the last document closes; a successfully cancelled job is never sent. Quitting cancels waiting jobs and joins jobs already sending (#5710). On Windows, winprint repeats the DEVMODE conversion after sending starts. A stall during that repeat prevents cancellation and can keep shutdown waiting, even before a native job exists. No ranked gap changed; readiness estimates unchanged | @@ -223,7 +226,6 @@ Page Setup's margin, paper-size and document-grid number boxes scroll long text | 2026-11-02 | trivial | Gap 8's evidence: English spelling reads the SCOWL 2026 dictionaries, one per English (en-US, British-spelling via en-GB, en-CA), with regression tests for #40 (British words in en-GB) and #520 (common modern words); #40 is no longer listed as open (#5205). No ranked gap changed. | | 2026-11-01 | minor | #8: Language packs (CP4.3): install and remove a language's spelling and hyphenation from File › Options › Language, the missing-pack bar, About › Language data, `proof.packs`/`proof.installPack`/`proof.removePack`; Lithuanian [#4707](https://github.com/storytold/wordcraft/issues/4707) and Romanian [#3438](https://github.com/storytold/wordcraft/issues/3438) are available as a Language pack once language-packs-1 is published (with Polish, Portuguese, Italian, Russian, Czech, Spanish, Bulgarian, Bosnian, Slovenian, Ukrainian and the other packs that pass CI); the packs for Dutch [#4145](https://github.com/storytold/wordcraft/issues/4145)/[#2505](https://github.com/storytold/wordcraft/issues/2505) and Catalan [#2895](https://github.com/storytold/wordcraft/issues/2895)/[#4947](https://github.com/storytold/wordcraft/issues/4947) are defined but wait on craft-proof engine support (two-character flags, `FLAG long`), French [#1325](https://github.com/storytold/wordcraft/issues/1325) on `OCONV`, Greek [#2561](https://github.com/storytold/wordcraft/issues/2561) on ISO 8859-7 decoding, Slovak [#3806](https://github.com/storytold/wordcraft/issues/3806) on `CIRCUMFIX`, Serbian [#4968](https://github.com/storytold/wordcraft/issues/4968) on numeric flags (`FLAG num`), and Latvian [#4438](https://github.com/storytold/wordcraft/issues/4438) on `FULLSTRIP` (and `CIRCUMFIX`, `NEEDAFFIX`) | | 2026-11-01 | trivial | Gap 8's evidence names craft-proof (storytold/craft-libs `crates/proof`) and craft-i18n `language-data/` instead of WordCraft's removed `crates/proof` (#5202). No ranked gap changed. | -| 2026-11-02 | trivial | #26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains | | 2026-11-01 | trivial | Preserve table rows and cells inside selected alternate-content branches (#5219); readiness estimates unchanged. || 2026-11-01 | trivial | Arrowheads keep their kind, width and length (DrawingML `a:headEnd`/`a:tailEnd` `type`/`w`/`len`, VML `startarrow`/`endarrow` with `…arrowwidth`/`…arrowlength`), read and written back, and are drawn 2, 3 or 5 times the line's width long and wide (small, medium, large), a line under 2 points as a 2-point one, as measured in Word for Windows; every kind is still drawn as a triangle; no ranked gap changed; readiness estimates unchanged | | 2026-10-31 | trivial | The .doc reader's default font is the stylesheet's standard font, not the font table's first entry (#5066); no ranked gap changed; readiness estimates unchanged | | 2026-11-01 | trivial | A middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed; readiness estimates unchanged | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index b3bb97b603..a1125c2619 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -58,7 +58,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42). A break inserted with text selected goes before the selection and keeps it, as in Word (#852). An otherwise empty section at page start retains its line (`empty_page_section_keeps_line`); a closing mark after text stays quiet, but one after another section's mark takes its line while it fits and then stays at the page's bottom, so the text after a long run starts the next page; a run of continuous sections with text goes onto new pages like plain text, and a kept-together paragraph low in a continuous section is measured against the next page's body (its header, footer and carried notes), not the current one's (#3907). | 1–2 | | Labels on page-break-only list paragraphs | ✅ | `layout::tests::break_only_list_has_no_label`: no bullet or number on the break, but the next number is still 3 after item 1 and a break. Plain empty items keep their labels. Synthetic DOCX observed through Adobe PDF conversion | — | | List labels after a leading page break | ✅ | `layout::tests::bidi::list_label_after_leading_page_break_uses_its_text_lines_alignment`: the number/bullet and text retain the no-break horizontal positions for start, centre, end and justified alignment, LTR/RTL, including text with a tab. The issue's synthetic DOCX exports with the number before the heading, matching the reporter's Word/LibreOffice reference (#2964); reference applications were not rerun locally | — | -| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | a level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word: after 10.1–10.4 the next level-1 item is 11 (`doc::numbering::tests::a_deeper_item_first_counts_the_levels_above_it`); `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). | — | +| List labels: number formats (decimal, letters, Russian letters, roman, ordinal text, Chinese/Japanese/Korean counting and legal, 一〇一 digits, 甲乙丙, ①…⑳, full-width), `%n` text such as `第%1章` / `(%1)`, legal numbering | ✅ | a level above that hasn't counted yet takes its first number at the first deeper item, as measured in Word: after 10.1–10.4 the next level-1 item is 11 (`doc::numbering::tests::a_deeper_item_first_counts_the_levels_above_it`); `doc::cjk_numbers` tests, `doc::section::tests::russian_letters_follow_word`, `layout::tests::chinese_counting_list_labels_and_their_font_runs`, `layout::tests::russian_letter_list_labels_draw_cyrillic`; a label mixing faces (digit + CJK comma) is drawn run by run (#534); Russian letters skip ё, й, ъ and ь and then double (аа, бб), as observed in Word. Hebrew, Hindi, Thai and Vietnamese formats show decimal; `arabicAlpha` and `arabicAbjad` draw Arabic letters, then double (أأ, بب) (`doc::section::tests::arabic_letters_count_in_both_orders`). The label stays attached to the start of the text when the paragraph is centred or aligned to the end, as in Word (#1159). A level with number format `none` is an ordinary level whose own number reads as nothing, as in Word for Windows (Microsoft 365, checked by MeHail-02): its label is its text with that number empty (`RESPONSE:`; `Part` above `%1.%2` gives `.1`), it counts and restarts the levels below it, it stays empty in legal numbering, and an empty label still tabs the text to the indent unless it already ends there (Heading 2 of the Chapter 1 list stays at the margin) (#4985; `doc::numbering::tests::a_none_level_shows_its_text_and_still_counts`, `layout::tests::a_none_format_label_shows_its_text_and_tabs_to_the_indent`). | — | | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | @@ -299,6 +299,7 @@ The front-of-text opt-out is retained from the earlier implementation; this patc | 2026-11-04 | trivial | Header table measurements and fitted text-box heights use the page section’s PAGE format; cache reuse follows the field context | | 2026-11-03 | trivial | Body PAGE fields in tables use the active section number format; reflow views measure headers and footers with it before body placement | | 2026-11-06 | trivial | An in-line picture lowered further than its own height (`w:position`) was held with its top on the baseline and its line only as deep as the picture; Word keeps the top there but lowers the line as far as the position says. A 6 pt picture lowered 30 pt now makes a 30 pt line alone and the text's ascent plus 30 pt beside text, as in Word. This closes the gap noted on 2026-11-05: all 156 synthetic lines measured then now match Word within 0.35 pt, heights and picture positions. Exact spacing keeps the line's baseline as before (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`, `tests::an_inline_picture_moves_with_its_runs_position`). | +| 2026-10-31 | trivial | List labels: a level with numFmt none is an ordinary level whose own number reads as nothing: it shows its literal text (`RESPONSE:`), counts and restarts the levels below it, stays empty in legal numbering, and an empty label still tabs the text to the indent, as in Word (#4985) | | 2026-11-05 | trivial | PR #4545: eligible cell floats retain in-bounds offsets and touch the outer cell edge only on overflow, ignoring wrap distances for object placement. Front-of-text, turned-cell and outside-cell opt-outs remain. Synthetic geometry controls; native front/behind-text parity remains unqualified. | | 2026-11-05 | trivial | With multiple spacing a line of only in-line pictures is as tall as they reach, raised or lowered, and never less than its font's height (a list label's font counting too): a picture shorter than the font keeps the font's ascent and descent, as noted for ODT documents below; a taller one ends at the line's bottom with no descent under it. The spacing adds its share of the font's height, not of a lowered picture's. A legacy check box sizes its line as text of its size. Measured in Word with Arial at 10 and 20 pt, single, 1.15, 1.25 and 1.5 spacing, 156 synthetic lines (`tests::lowered_pictures_and_check_boxes_take_words_line_heights`). Known gap: a picture lowered further than its own height is limited to that height where Word's line keeps growing. | | 2026-11-04 | trivial | PR #4609: relative REF keeps context already displayed by the own or a needed ancestor label; six synthetic legal-template cases and separate-list context follow the Word for Windows measurements reported by @pakiat in PR discussion. Existing unknown-switch/cache boundaries and readiness estimates unchanged; no new native Word run. |