leaf_core/counts.rs
1//! Word, character, and paragraph counts over the text a *reader* sees.
2//!
3//! The numbers a status bar shows are a claim about the document, not about
4//! the markup that spells it: `**bold**` is one word and four characters, a
5//! link is its label and not its destination, and a picture is a picture. So
6//! the tally runs over a [`VisualMap`] — the same rendering the WYSIWYG view
7//! paints — rather than over the source string, and cannot drift from what is
8//! on the page the way a second, private markdown walk would.
9//!
10//! What it takes off that map is narrower than what the map draws. A glyph
11//! counts only if it is a caret stop ([`Glyph::stop`]) *and* its
12//! [`Role`] is one a reader would call text. The first half drops the
13//! scaffolding a proportional surface draws but nobody types into — a nested
14//! item's indent, a cell's alignment padding — and, because a stop opens a
15//! grapheme cluster and never sits inside one, makes "one stop" and "one
16//! character" the same statement. The second drops the synthetic furniture:
17//! a bullet, a quote's gutter, a rule, a hidden delimiter revealed under
18//! [`MarkupMode::Full`](crate::MarkupMode::Full), and the `🖼 alt` / `⧉ name`
19//! placeholder a block picture or directive stands in as.
20//!
21//! [`Glyph::stop`]: crate::wysiwyg::Glyph::stop
22
23use std::ops::Range;
24
25use unicode_segmentation::UnicodeSegmentation;
26
27use crate::style::Role;
28use crate::wysiwyg::{Glyph, VisualMap};
29
30/// Statistics over the text a reader sees. See [`Doc::counts`] for the rules
31/// each field is counted by, and [`crate::counts`] for what "sees" means.
32///
33/// [`Doc::counts`]: crate::Doc::counts
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
35pub struct TextCounts {
36 /// Words, by UAX#29 word segmentation: a segment holding at least one
37 /// alphabetic or numeric character. So `don't` is one, `3.14` is one, a
38 /// lone `—` is none — and `well-known` is **two**, which is what the
39 /// algorithm says and what every other UAX#29 counter reports.
40 pub words: usize,
41 /// Characters as a reader counts them — grapheme clusters, whitespace
42 /// included. An emoji family and an accented letter are each one.
43 pub characters: usize,
44 /// The same, less every whitespace grapheme.
45 pub characters_without_spaces: usize,
46 /// Block-level text containers holding at least one non-whitespace
47 /// character: a paragraph, a heading, each item of a list, each paragraph
48 /// inside a blockquote, a whole code block, a whole table.
49 pub paragraphs: usize,
50}
51
52impl TextCounts {
53 /// Fold one block-level container into the tally — its lines, already cut
54 /// down to visible text.
55 ///
56 /// Lines rather than one string because the breaks *between* them are not
57 /// characters: a table's cells and a code block's lines each arrive as
58 /// their own line, and gluing them with a `\n` would both invent a
59 /// character and let the last word of one run into the first of the next.
60 /// The block counts as one paragraph however many lines it has, and as
61 /// none at all when every line is blank — which is what keeps an empty
62 /// paragraph, a rule, and a picture's placeholder out of the count.
63 fn add_block(&mut self, lines: &[String]) {
64 let mut has_text = false;
65 for line in lines {
66 for cluster in line.graphemes(true) {
67 self.characters += 1;
68 if !cluster.chars().all(char::is_whitespace) {
69 self.characters_without_spaces += 1;
70 has_text = true;
71 }
72 }
73 self.words += line.unicode_words().count();
74 }
75 if has_text {
76 self.paragraphs += 1;
77 }
78 }
79}
80
81/// Tally `map`, or the part of it whose source offsets fall inside `range`.
82///
83/// The map is expected to be an *unwrapped* one — one row per block — which is
84/// what makes a row and a paragraph the same thing here. [`Doc::counts`]
85/// builds one for the purpose rather than borrowing the frontend's, so that a
86/// narrower window cannot mean more paragraphs.
87///
88/// [`Doc::counts`]: crate::Doc::counts
89pub(crate) fn tally(map: &VisualMap, range: Option<Range<usize>>) -> TextCounts {
90 let range = range.as_ref();
91 let mut counts = TextCounts::default();
92 let mut row = 0;
93 while row < map.rows.len() {
94 // A table is one paragraph, not one per cell — the reader sees a
95 // single object, and a two-column shopping list is not twelve
96 // paragraphs. Its text comes off the structural grid rather than off
97 // the box-drawn picture, which carries borders and column padding the
98 // author never wrote.
99 if let Some(table) = map.tables.iter().find(|t| t.rows_span.contains(&row)) {
100 let cells: Vec<String> = table
101 .grid
102 .iter()
103 .flat_map(|r| r.cells.iter())
104 .map(|c| visible(&c.glyphs, range))
105 .collect();
106 counts.add_block(&cells);
107 row = table.rows_span.end.max(row + 1);
108 continue;
109 }
110 // A code block is one paragraph too, however many lines it holds —
111 // the same call, for the same reason.
112 if let Some(code) = map.code_blocks.iter().find(|c| c.rows_span.contains(&row)) {
113 let lines: Vec<String> = map.rows[code.rows_span.clone()]
114 .iter()
115 .map(|r| visible(&r.glyphs, range))
116 .collect();
117 counts.add_block(&lines);
118 row = code.rows_span.end.max(row + 1);
119 continue;
120 }
121 // The blank gap a block boundary is drawn with is not a block.
122 if !map.rows[row].decoration {
123 counts.add_block(&[visible(&map.rows[row].glyphs, range)]);
124 }
125 row += 1;
126 }
127 counts
128}
129
130/// The text of one drawn line: its stop glyphs, less the synthetic ones, less
131/// anything whose source byte falls outside `range`.
132fn visible(glyphs: &[Glyph], range: Option<&Range<usize>>) -> String {
133 glyphs
134 .iter()
135 .filter(|g| g.stop && is_text(g.style.role))
136 .filter(|g| range.is_none_or(|r| r.contains(&g.src)))
137 .map(|g| g.ch)
138 .collect()
139}
140
141/// Whether a glyph in this role is text the reader is reading, as against
142/// furniture the renderer drew around it.
143///
144/// Spelled out arm by arm rather than as a list of exclusions, so that a new
145/// [`Role`] has to be answered for here instead of quietly joining whichever
146/// side the wildcard fell on.
147fn is_text(role: Role) -> bool {
148 match role {
149 Role::Body | Role::Heading(_) | Role::Code | Role::Link | Role::Mark(_) => true,
150 // A bullet, a quote's `│`, a thematic break's dashes and a table's
151 // borders: drawn by the renderer, not written by the author.
152 Role::ListMarker | Role::ListIndent | Role::QuoteGutter | Role::Rule => false,
153 // Raw markup a revealed line is showing — the source, not the text.
154 // `Doc::counts` builds its map with no revealed line, so this arm is
155 // the statement of intent rather than a live case.
156 Role::Delimiter => false,
157 // The `🖼 alt` / `⧉ name` stand-in for a block picture, movie, sound,
158 // or directive. A reader sees the thing, not the label, and a thing
159 // is not a word. (An *inline* image is different: leaf draws its alt
160 // text as ordinary prose in the line, and so counts it.)
161 Role::Image => false,
162 // A formula's atom or placeholder label, for the same reason: what the
163 // reader sees is a picture, and `Doc::counts` builds its map as a
164 // surface that paints one in a line, so an inline formula is an atom
165 // here and never its TeX. (Its TeX on the revealed line is `Code` and
166 // would count, but the count reveals nothing.)
167 Role::Math => false,
168 }
169}