mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
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 <echelon@gmail.com>
This commit is contained in:
1 parent
dbfa2af68a
commit
fbacffd282
5 files changed
+110
-4
No files matched your search
+40
-3
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user