Skip to content

Commit ab4c8e7

Browse files
Replace readability analysis with categorized document metrics (#25)
1 parent bd0441c commit ab4c8e7

File tree

7 files changed

+413
-362
lines changed

7 files changed

+413
-362
lines changed

RELEASES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
- **DOCX table formatting** — Header row is now properly bold in exported DOCX. Table spans full document width with column widths proportional to content length.
1010

1111
### Changed
12+
- **Document metrics** — Readability panel (Flesch-Kincaid score, passive voice) replaced with practical document metrics. Shows character counts (with and without spaces) and word counts broken down by content category: title page, headings, body text, lists, tables, and code blocks. Useful for formal documents with character or page limits where different elements may count differently. Badge in the preview header shows total character count. Heading hierarchy validation preserved.
1213
- **Honest title page preview** — Live preview now shows title page only from explicit YAML frontmatter instead of auto-generating phantom titles and dates from H1 headings and `new Date()`. When no frontmatter is present, a clickable placeholder guides users to add metadata. DOCX/PDF/HTML export retains auto-fallback behavior for professional output.
1314
- **Document Options** — "Export Options" renamed to "Document Options". Toggles (Title Page, ToC, Header, Footer) now affect both live preview and export, making the editor truly WYSIWYG.
1415

index.html

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -210,23 +210,41 @@
210210
max-height: 600px;
211211
}
212212

213-
.metric-row {
214-
display: flex;
215-
justify-content: space-between;
216-
align-items: center;
217-
padding: 0.375rem 0;
218-
border-bottom: 1px solid #f3f4f6;
213+
.metrics-table {
214+
width: 100%;
215+
border-collapse: collapse;
219216
font-size: 0.8rem;
220217
}
221-
.metric-row:last-child {
222-
border-bottom: none;
223-
}
224-
.metric-label {
218+
.metrics-table th {
219+
text-align: left;
225220
color: #6b7280;
221+
font-weight: 500;
222+
padding: 0.25rem 0.5rem;
223+
border-bottom: 2px solid #e5e7eb;
224+
font-size: 0.7rem;
225+
text-transform: uppercase;
226+
letter-spacing: 0.05em;
226227
}
227-
.metric-value {
228-
font-weight: 600;
228+
.metrics-table th:not(:first-child) {
229+
text-align: right;
230+
}
231+
.metrics-table td {
232+
padding: 0.2rem 0.5rem;
233+
border-bottom: 1px solid #f3f4f6;
234+
}
235+
.metrics-table td:not(:first-child) {
236+
text-align: right;
229237
font-family: monospace;
238+
font-weight: 500;
239+
}
240+
.metrics-table .metrics-total td {
241+
border-top: 2px solid #e5e7eb;
242+
border-bottom: none;
243+
font-weight: 700;
244+
padding-top: 0.375rem;
245+
}
246+
.metrics-table .metrics-zero td:not(:first-child) {
247+
color: #d1d5db;
230248
}
231249

232250
.toggle-switch {

src/ai/document-metrics.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { parseInlineSegments } from "../inline-formatting.js";
2+
3+
const RE_HEADING = /^h([1-4])$/;
4+
const RE_HAS_INLINE_MARKERS = /[*_`\[]/;
5+
6+
function zeroCounts() {
7+
return { chars: 0, charsNoSpaces: 0, words: 0 };
8+
}
9+
10+
function stripInlineMarkdown(text) {
11+
if (!RE_HAS_INLINE_MARKERS.test(text)) return text;
12+
const segments = parseInlineSegments(text);
13+
let result = "";
14+
for (const s of segments) result += s.text;
15+
return result;
16+
}
17+
18+
function countText(cleanText) {
19+
const len = cleanText.length;
20+
let charsNoSpaces = 0;
21+
let words = 0;
22+
let inWord = false;
23+
for (let i = 0; i < len; i++) {
24+
const ch = cleanText.charCodeAt(i);
25+
const isSpace = ch === 32 || ch === 9 || ch === 10 || ch === 13;
26+
if (!isSpace) {
27+
charsNoSpaces++;
28+
if (!inWord) {
29+
words++;
30+
inWord = true;
31+
}
32+
} else {
33+
inWord = false;
34+
}
35+
}
36+
return { chars: len, charsNoSpaces, words };
37+
}
38+
39+
function addCounts(target, source) {
40+
target.chars += source.chars;
41+
target.charsNoSpaces += source.charsNoSpaces;
42+
target.words += source.words;
43+
}
44+
45+
function countAndAdd(target, text, strip = true) {
46+
addCounts(target, countText(strip ? stripInlineMarkdown(text) : text));
47+
}
48+
49+
function validateHeadingHierarchy(elements) {
50+
const issues = [];
51+
let lastLevel = 0;
52+
for (const el of elements) {
53+
const match = el.type.match(RE_HEADING);
54+
if (!match) continue;
55+
const level = parseInt(match[1]);
56+
if (lastLevel > 0 && level > lastLevel + 1) {
57+
issues.push({
58+
content: el.content,
59+
expected: `h${lastLevel + 1}`,
60+
got: el.type,
61+
line: el.line,
62+
});
63+
}
64+
lastLevel = level;
65+
}
66+
return issues;
67+
}
68+
69+
export function analyzeDocumentMetrics(elements, metadata) {
70+
const categories = {
71+
titlePage: zeroCounts(),
72+
headings: zeroCounts(),
73+
body: zeroCounts(),
74+
lists: zeroCounts(),
75+
tables: zeroCounts(),
76+
code: zeroCounts(),
77+
};
78+
let diagrams = 0;
79+
80+
for (const el of elements) {
81+
if (RE_HEADING.test(el.type)) {
82+
countAndAdd(categories.headings, el.content);
83+
} else if (el.type === "paragraph") {
84+
countAndAdd(categories.body, el.content);
85+
} else if (el.type === "bulletlist" || el.type === "numlist") {
86+
for (const item of el.items) countAndAdd(categories.lists, item);
87+
} else if (el.type === "checklist") {
88+
for (const item of el.items) countAndAdd(categories.lists, item.text || "");
89+
} else if (el.type === "table") {
90+
for (const row of el.rows) {
91+
for (const cell of row) countAndAdd(categories.tables, cell);
92+
}
93+
} else if (el.type === "codeblock") {
94+
countAndAdd(categories.code, el.content, false);
95+
} else if (el.type === "mermaid") {
96+
diagrams++;
97+
}
98+
}
99+
100+
if (metadata?._hasExplicitFrontmatter) {
101+
const fields = [metadata.title, metadata.subtitle, metadata.author, metadata.date].filter(
102+
Boolean
103+
);
104+
categories.titlePage = countText(fields.join(" "));
105+
}
106+
107+
const totals = zeroCounts();
108+
for (const cat of Object.values(categories)) {
109+
addCounts(totals, cat);
110+
}
111+
112+
return {
113+
categories,
114+
totals,
115+
diagrams,
116+
headingIssues: validateHeadingHierarchy(elements),
117+
};
118+
}

0 commit comments

Comments
 (0)