// Faithful port of Scandroid_py_source/syllables.py's Syllabizer class (Paul // Holzer's method, Byte Feb 1986, adapted by Hartman with regex heuristics). // Public contract preserved exactly: Syllabize(word) returns an array of // syllable substrings of the ORIGINAL word, with the STRESSED syllable // UPPERCASE and all others left as given (lowercase, since callers pass // lowercase words in). Downstream code detects stress via `s === s.toUpperCase() // && s !== s.toLowerCase()` (the TS equivalent of Python's `str.isupper()`). import { SIBILANTS, MULTISUFFIX, STRESSSUFFIX, PREFIXES } from './constants.js'; function encode(ch: string): string { return String.fromCharCode(ch.charCodeAt(0) & 0x3f); } // decode() is defined in the original (chr(ord(ch) | 0x40)) but never actually // called anywhere in Syllabize's method bodies either — dead in the source too. function handleCiV(match: string): string { return encode(match[0]) + encode(match[1]) + match[2]; } function handleCC(match: string): string { let ret = encode(match[0]) + encode(match[1]); if (match.length > 2) ret += match[2]; return ret; } function handleVyV(match: string): string { return match[0] + encode(match[1]) + match[2]; } function isAlpha(ch: string | undefined): boolean { return !!ch && /[A-Za-z]/.test(ch); } // Regexes below are transliterated from syllables.py's re.VERBOSE patterns — // VERBOSE mode only strips insignificant whitespace/comments, so the // alternations and character classes are copied over unchanged. const SUFFIXES_RE = /[^aeiouhr]y\b|er\b|age|est|ing|ness\b|less|ful|ment\b|time\b|[st]ion|[ia]ble\b|[ct]ial|[ctg]iou|[ctg]ious/g; const LIQUIDTERM_RE = /[^aeiouy][rl]e\b/; const CiV_RE = /[st]i[aeiouy]/g; const CCPAIR_RE = /[cgprstw]h|gn|gu[aeiouy]|qu|ck/g; const VyV_RE = /[aeiou]y[aeiou]/g; const SYLVOWELS_RE = /[aeiu]o|[iu]a|iu/; const SPLIT_LEFT_PAIRS_RE = /[bdfk%02][rl]|g[rln]|[tw]r|p[rlsn]s[nml]/; // DivideCV's "unicode vowels" set: a e è i o u y (è = e-with-grave, syllabic // per the "an aged man" note in the original docstring). const VOWEL_GROUP_RE = /[aeèiouy]+/g; const CONSONANT_GROUP_RE = /[^aeèiouy]+/g; class Syllabizer { private wd = ''; private sylBounds: number[] = []; private isPast = false; private isPlural = false; private numSuffixes = 0; private forceStress = 0; Syllabize(word: string): string[] { if (word.length < 3) return [word.toUpperCase()]; this.wd = word.toLowerCase(); this.sylBounds = []; this.preliminaries(); this.specialCodes(); this.divideCV(); const stressed = this.stressGuesser(word); this.sylBounds.sort((a, b) => a - b); this.sylBounds.unshift(0); this.sylBounds.push(word.length); const listOfSyls: string[] = []; let i = 0; for (const s of this.sylBounds) { if (!s) continue; // skip the leading 0 sentinel, exactly as `if not s: continue` i += 1; const piece = word.slice(this.sylBounds[i - 1], s); listOfSyls.push(i === stressed ? piece.toUpperCase() : piece); } return listOfSyls; } private preliminaries(): void { // Python's str.find(sub, -2) searches from index len-2 onward; JS indexOf // clamps a negative fromIndex to 0, so the offset must be computed by hand. const fromIdx = Math.max(0, this.wd.length - 2); const apostrophe = this.wd.indexOf("'", fromIdx); if (apostrophe !== -1) { const last = this.wd[this.wd.length - 1]; const penult = this.wd[this.wd.length - 2]; if (last !== "'" && 'se'.includes(last) && SIBILANTS.includes(penult)) { this.sylBounds.push(apostrophe); } this.wd = this.wd.slice(0, apostrophe); } this.isPast = false; this.isPlural = false; if (/[^s]s\b/.test(this.wd)) this.isPlural = true; if (/ed\b/.test(this.wd)) this.isPast = true; if (this.isPast || this.isPlural) this.wd = this.wd.slice(0, -1); // final-syllable test does better work AFTER suffixes are cut off this.findSuffix(); if (this.wd.length > 3 && LIQUIDTERM_RE.test(this.wd)) { // swap the final two characters (numeric bound positions are unaffected; // the ORIGINAL word — not this working copy — is sliced for output) const n = this.wd.length; this.wd = this.wd.slice(0, n - 2) + this.wd[n - 1] + this.wd[n - 2]; } } private findSuffix(): void { this.numSuffixes = 0; this.forceStress = 0; const results: Array<[string, number]> = []; for (const m of this.wd.matchAll(SUFFIXES_RE)) { results.push([m[0], m.index ?? 0]); } if (results.length === 0) return; const last = results[results.length - 1]; if (last[1] + last[0].length < this.wd.length) return; // rightmost match must reach the end results.reverse(); for (const [text, start] of results) { if (!/[aeiouy]/.test(this.wd.slice(0, start))) break; // no vowel left before -> false suffix if (text === 'ing' && this.wd[start - 1] === this.wd[start - 2]) { this.sylBounds.push(start - 1); // absorb a doubled consonant into the suffix syllable } else { this.sylBounds.push(start); } this.wd = this.wd.slice(0, start); this.numSuffixes += 1; if (STRESSSUFFIX.includes(text)) this.forceStress = 0 - this.sylBounds.length; if (MULTISUFFIX.includes(text)) { this.sylBounds.push(start + 1); this.numSuffixes += 1; } } } private specialCodes(): void { if (/[^aeiouy]e\b/.test(this.wd)) { const penult = this.wd[this.wd.length - 2]; if ((!this.isPlural || !SIBILANTS.includes(penult)) && (!this.isPast || !'dt'.includes(penult))) { this.wd = this.wd.slice(0, -1) + encode(this.wd[this.wd.length - 1]); } if (!/[aeiouy]/.test(this.wd)) this.wd = this.wd.slice(0, -1) + 'e'; // undo if no vowel left } this.wd = this.wd.replace(CiV_RE, handleCiV); this.wd = this.wd.replace(CCPAIR_RE, handleCC); this.wd = this.wd.replace(VyV_RE, handleVyV); } private divideCV(): void { const vowelMatches = [...this.wd.matchAll(VOWEL_GROUP_RE)]; if (vowelMatches.length === 0) return; // defensive: original assumes at least one vowel group const firstvowel = vowelMatches[0].index ?? 0; let lastvowel = firstvowel; for (const v of vowelMatches) { const start = v.index ?? 0; lastvowel = start + v[0].length; const disyl = SYLVOWELS_RE.exec(v[0]); if (disyl) this.sylBounds.push(start + disyl.index + 1); } for (const cc of this.wd.matchAll(CONSONANT_GROUP_RE)) { const start = cc.index ?? 0; const end = start + cc[0].length; if (start < firstvowel || end >= lastvowel) continue; const numcons = cc[0].length; let pos: number; if (numcons < 3) pos = end - 1; else if (numcons > 3) pos = end - 2; else { const cg = cc[0]; if (cg[cg.length - 3] === cg[cg.length - 2] || SPLIT_LEFT_PAIRS_RE.test(cg)) pos = end - 2; else pos = end - 1; } if (!isAlpha(this.wd[pos - 1]) && !isAlpha(this.wd[pos])) this.sylBounds.push(pos - 1); else this.sylBounds.push(pos); } } /** Nessly's Default plus suffix/prefix twists. Returns a 1-BASED syllable index. */ private stressGuesser(origword: string): number { const numsyls = this.sylBounds.length + 1; if (numsyls === 1) return 1; this.sylBounds.sort((a, b) => a - b); if (this.forceStress) return numsyls + this.forceStress; if (numsyls - this.numSuffixes === 1) return 1; const isprefix = PREFIXES.includes(this.wd.slice(0, this.sylBounds[0])); if (numsyls - this.numSuffixes === 2) return isprefix ? 2 : 1; if (isprefix && numsyls - this.numSuffixes === 3) return 2; let retstress: number; const lastBoundChar = origword[this.sylBounds[this.sylBounds.length - 1] - 1]; if (!/[aeiouy]/.test(lastBoundChar ?? '')) retstress = numsyls - 1; // closed final syllable -> stress penult else retstress = numsyls - 2; // else antepenult if (this.numSuffixes === numsyls) retstress -= 1; return retstress; } } const shared = new Syllabizer(); /** Divide a word into syllables; the stressed syllable comes back UPPERCASE. */ export function syllabize(word: string): string[] { return shared.Syllabize(word); }