/** * models/engine/wordcount.ts — the word count that enforces the plan limits. * * DOCUMENTED ALGORITHM (the same one runs in the browser, in public/app.js, for * the live counter; the server's count is the one that decides): * * - UNICODE source text: Unicode word segmentation (UAX #29) through * Intl.Segmenter with the Odia locale ("or"), counting only segments the * segmenter marks as word-like. Odia vowel signs, virama and nukta stay inside * their word, and punctuation such as the danda is not a word. * Fallback when Intl.Segmenter is unavailable: split on Unicode whitespace and * count pieces that contain at least one letter, mark or digit (\p{L}\p{M}\p{N}). * * - LEGACY source text: legacy Odia fonts re-use ASCII and Latin-1 punctuation * (@ [ ] \ ^ _ ` ~ { | } and others) as Odia glyphs, so Unicode segmentation * would split one Odia word into several or drop parts of it. A word is * therefore any run of characters between whitespace. * * Whitespace means what JavaScript's \s matches (spaces, tabs, line breaks, * no-break space and the other Unicode space separators). */ export type CountMode = "unicode" | "legacy"; let segmenter: Intl.Segmenter | null | undefined; function getSegmenter(): Intl.Segmenter | null { if (segmenter === undefined) { try { segmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter("or", { granularity: "word" }) : null; } catch { segmenter = null; } } return segmenter; } export function countWords(text: string, mode: CountMode): number { if (!text) return 0; if (mode === "legacy") { const m = text.match(/\S+/gu); return m ? m.length : 0; } const seg = getSegmenter(); if (seg) { let n = 0; for (const s of seg.segment(text)) if (s.isWordLike) n++; return n; } return text.split(/\s+/u).filter((w) => /[\p{L}\p{M}\p{N}]/u.test(w)).length; }