From fbacffd282715f41c3d4f2ffe5b6ea0a7c819a72 Mon Sep 17 00:00:00 2001 From: Navid Zaman Khan Date: Mon, 12 Oct 2026 22:03:41 +0600 Subject: [PATCH] fix(doc): count generated numbered-list labels as words (#893) (#908) Microsoft Word treats generated numbered-list labels as independent words in document word statistics, while omitting them from character counts. Previously, word counting in WordCraft inspected plain run text only, causing sample_document() to report 357 words instead of Word's 361. - Add Document::story_word_count and story_word_count_of to compute words directly, including generated numbered labels while skipping bullet markers and empty labels. - Handle LevelSuffix::Nothing correctly by counting labels as independent words without string concatenation artifacts. - Keep Document::plain_text unadorned to maintain exact run text across docbin, clipboard, and tests. - In review.wordCount, report words via story_word_count while preserving character counts from plain_text. - Add regression tests covering numbered lists, bullets, LevelSuffix::Nothing, and review.wordCount word vs character parity. Fixes #893 Co-authored-by: Brandon Thomas --- crates/doc/src/lib.rs | 43 ++++++++++++++++++++++++++++--- crates/doc/src/tests.rs | 45 +++++++++++++++++++++++++++++++++ crates/engine/src/cmd/review.rs | 6 ++++- crates/engine/src/sample.rs | 1 + crates/engine/src/tests.rs | 19 ++++++++++++++ 5 files changed, 110 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4d710ad..3f8a9e4 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -892,14 +892,22 @@ impl Document { id } - /// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit). + /// Count words like Word's status bar (whitespace-separated tokens containing a letter or digit, + /// including generated numbered-list labels). pub fn word_count(&self) -> usize { - count_words(&self.plain_text(StoryRef::Body)) + self.story_word_count(StoryRef::Body) } /// Word's default count: the body plus its text boxes, footnotes and endnotes. pub fn word_count_including_notes(&self) -> usize { - self.counted_stories().iter().map(|s| count_words(&self.plain_text(*s))).sum() + self.counted_stories().iter().map(|s| self.story_word_count(*s)).sum() + } + + /// Word count of one story, including generated numbered-list labels. + pub fn story_word_count(&self, s: StoryRef) -> usize { + let Some(bl) = self.story(s) else { return 0 }; + let mut counters = numbering::Counters::default(); + story_word_count_of(bl, &self.numbering, &self.styles, &mut counters, 0) } /// The body, then the text boxes, footnotes and endnotes it shows (nested ones too), in @@ -1313,5 +1321,34 @@ fn text_of(bl: &Blocks, out: &mut String, depth: usize, shown: bool) { } } +fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::StyleSheet, counters: &mut numbering::Counters, depth: usize) -> usize { + if depth > 16 { + return 0; + } + let mut total = 0usize; + for b in bl { + match &**b { + Block::Para(p) => { + let n = p.props.numbering.or_else(|| styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0); + if let Some(n) = n + && let Some((label, def)) = counters.next_label(numbering, n.num, n.level) + && !matches!(def.format, section::NumFormat::Bullet | section::NumFormat::None) + { + total = total.saturating_add(count_words(&label)); + } + total = total.saturating_add(count_words(&p.plain_text())); + } + Block::Table(t) => { + for row in &t.rows { + for c in &row.cells { + total = total.saturating_add(story_word_count_of(&c.blocks, numbering, styles, counters, depth + 1)); + } + } + } + } + } + total +} + #[cfg(test)] mod tests; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 2041a4e..83b9aa8 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -16,6 +16,51 @@ fn word_count() { assert_eq!(count_words(" -- "), 0); } +#[test] +fn word_count_includes_numbered_list_labels_but_not_bullets() { + let mut d = Document::new(); + let num_id = d.numbering.add_list(ListKind::Numbered); + let bul_id = d.numbering.add_list(ListKind::Bullet); + + let mut p1 = Paragraph::with_text("Apple", CharProps::default()); + p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 }); + let mut p2 = Paragraph::with_text("Banana", CharProps::default()); + p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 }); + + let mut p3 = Paragraph::with_text("Cherry", CharProps::default()); + p3.props.numbering = Some(props::NumRef { num: bul_id, level: 0 }); + let mut p4 = Paragraph::with_text("Date", CharProps::default()); + p4.props.numbering = Some(props::NumRef { num: bul_id, level: 0 }); + + d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2)), Arc::new(Block::Para(p3)), Arc::new(Block::Para(p4))]; + + // Numbered items contribute 2 words each (label + text) = 4 words. + // Bullet items contribute 1 word each (bullet marker is not a word + text) = 2 words. + assert_eq!(d.word_count(), 6); + let plain = d.plain_text(StoryRef::Body); + assert_eq!(plain, "Apple\nBanana\nCherry\nDate"); +} + +#[test] +fn word_count_includes_numbered_labels_with_nothing_suffix() { + let mut d = Document::new(); + let num_id = d.numbering.add_list(ListKind::Numbered); + let aid = d.numbering.num(num_id).expect("list num exists").abstract_id; + let a = d.numbering.abstracts.iter_mut().find(|a| a.id == aid).expect("abstract num exists"); + a.levels[0].suffix = numbering::LevelSuffix::Nothing; + + let mut p1 = Paragraph::with_text("Apple", CharProps::default()); + p1.props.numbering = Some(props::NumRef { num: num_id, level: 0 }); + let mut p2 = Paragraph::with_text("Banana Cherry", CharProps::default()); + p2.props.numbering = Some(props::NumRef { num: num_id, level: 0 }); + d.body = vec![Arc::new(Block::Para(p1)), Arc::new(Block::Para(p2))]; + + // Label "1." and text "Apple" must each be counted as a separate word (2 words), + // and label "2." and text "Banana Cherry" as 3 words (total: 5 words), + // even though LevelSuffix::Nothing produces no visible separator between label and text. + assert_eq!(d.word_count(), 5); +} + #[test] fn sections_and_section_mut() { let mut d = Document::from_text("a\nb\nc"); diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 7529394..656fd77 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -753,7 +753,11 @@ fn word_count(s: &mut Session, v: &Value) -> CmdResult { } else { s.selected_text() }; - let words = wordcraft_doc::count_words(&text); + let words = if s.sel.is_collapsed() || super::objects::object_selection(s).is_some() { + stories.iter().map(|st| s.doc.story_word_count(*st)).sum() + } else { + wordcraft_doc::count_words(&text) + }; let chars = text.chars().filter(|c| *c != '\n').count(); let chars_no_spaces = text.chars().filter(|c| !c.is_whitespace()).count(); let paragraphs = text.split('\n').filter(|l| !l.trim().is_empty()).count(); diff --git a/crates/engine/src/sample.rs b/crates/engine/src/sample.rs index c8a2fcf..718765f 100644 --- a/crates/engine/src/sample.rs +++ b/crates/engine/src/sample.rs @@ -209,6 +209,7 @@ mod tests { #[test] fn samples_are_valid() { + assert_eq!(sample_document().word_count(), 361); for d in [sample_document(), letter(), resume(), report()] { assert!(d.word_count() > 5); let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 624b3aa..3ce8a19 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1957,6 +1957,25 @@ fn word_count_includes_text_boxes_by_default() { assert_eq!(s.word_count(), 2); } +#[test] +fn word_count_includes_numbered_list_labels_in_words_but_not_characters() { + let mut d = wordcraft_doc::Document::new(); + let num_id = d.numbering.add_list(wordcraft_doc::ListKind::Numbered); + let mut p = wordcraft_doc::Paragraph::with_text("Apple", wordcraft_doc::CharProps::default()); + p.props.numbering = Some(wordcraft_doc::props::NumRef { num: num_id, level: 0 }); + d.body = vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]; + let mut s = Session::new(d); + + let v = run(&mut s, "review.wordCount", json!({})); + // Generated label "1." + "Apple" = 2 words. + assert_eq!(v["words"].as_u64(), Some(2)); + assert_eq!(s.word_count(), 2); + // Generated list label "1." is formatting metadata and not run characters, + // so Word excludes it from character counts: "Apple" has 5 characters. + assert_eq!(v["characters"].as_u64(), Some(5)); + assert_eq!(v["charactersWithSpaces"].as_u64(), Some(5)); +} + /// The laid-out area of the only text box. fn box_area(s: &mut Session) -> wordcraft_layout::hit::ObjectHit { let l = s.layout();