// Writing statistics computed from the document's plain text. These power the // expanded stats panel that opens when the writer clicks the word count. The // reading-level formulas are English-centric (Flesch); a document mixing in // Mandarin still gets sensible counts, with the level treated as approximate. export interface WritingStats { words: number characters: number charactersNoSpaces: number sentences: number paragraphs: number pages: number avgWordLength: number // letters per English word uniqueWords: number variety: number // type-token ratio, 0–1 (unique ÷ total English words) readingTimeMin: number gradeLevel: number // Flesch–Kincaid grade } // English word tokens (letters with internal apostrophes/hyphens) — used for the // letter-length, syllable, and variety measures, which only make sense for // alphabetic words. const ENGLISH_WORD_RE = /[A-Za-z]+(?:['’-][A-Za-z]+)*/g // Sentence terminators, including the CJK fullwidth forms. const SENTENCE_RE = /[.!?。!?]+/g // Words per page (a rough double-spaced manuscript page) and reading speed. const WORDS_PER_PAGE = 250 const WORDS_PER_MINUTE = 200 // countSyllables is the common vowel-group heuristic: count vowel runs, drop a // trailing silent "e"/"es"/"ed", and floor at one. Not perfect, but plenty // accurate for an at-a-glance reading level. function countSyllables(word: string): number { const w = word.toLowerCase().replace(/[^a-z]/g, '') if (w.length === 0) return 0 if (w.length <= 3) return 1 const trimmed = w.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '') const groups = trimmed.match(/[aeiouy]{1,2}/g) return groups ? groups.length : 1 } export function computeStats(text: string, wordCount: number): WritingStats { const trimmed = text.trim() const characters = [...text].length const charactersNoSpaces = text.replace(/\s/g, '').length const sentenceMatches = trimmed.match(SENTENCE_RE) const sentences = sentenceMatches ? sentenceMatches.length : trimmed ? 1 : 0 const paragraphBlocks = trimmed.split(/\n{2,}/).filter((p) => p.trim().length > 0) const paragraphs = paragraphBlocks.length const englishWords = trimmed.match(ENGLISH_WORD_RE) ?? [] const letters = englishWords.reduce((sum, w) => sum + w.length, 0) const avgWordLength = englishWords.length > 0 ? letters / englishWords.length : 0 const unique = new Set(englishWords.map((w) => w.toLowerCase())) const uniqueWords = unique.size const variety = englishWords.length > 0 ? uniqueWords / englishWords.length : 0 const syllables = englishWords.reduce((sum, w) => sum + countSyllables(w), 0) // Flesch–Kincaid grade level; needs at least one sentence and word to be real. let gradeLevel = 0 if (englishWords.length > 0 && sentences > 0) { gradeLevel = 0.39 * (englishWords.length / sentences) + 11.8 * (syllables / englishWords.length) - 15.59 if (gradeLevel < 0) gradeLevel = 0 } return { words: wordCount, characters, charactersNoSpaces, sentences, paragraphs, pages: wordCount / WORDS_PER_PAGE, avgWordLength, uniqueWords, variety, readingTimeMin: wordCount / WORDS_PER_MINUTE, gradeLevel, } } // gradeBand turns a Flesch–Kincaid grade into a friendly bilingual descriptor — // far more useful to an ESL writer than a bare number. export function gradeBand(grade: number): { zh: string; en: string } { if (grade <= 5) return { zh: '简单', en: 'Easy' } if (grade <= 8) return { zh: '标准', en: 'Standard' } if (grade <= 12) return { zh: '偏难', en: 'Fairly hard' } return { zh: '较难', en: 'Advanced' } }