/* 한글 — decomposition, composition, and a 두벌식 input method. No dependencies. Lifted from the artifact; the IME is unit-tested against 먹어 · 왔어 · 읽어 · 괜찮아 · 값 · 의사 · 뭐야 and backspace. */ export const CHO = "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ"; export const JUNG = "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ"; export const JONG = " ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ"; const VJOIN = {"ㅗㅏ":"ㅘ","ㅗㅐ":"ㅙ","ㅗㅣ":"ㅚ","ㅜㅓ":"ㅝ","ㅜㅔ":"ㅞ","ㅜㅣ":"ㅟ","ㅡㅣ":"ㅢ"}; const FJOIN = {"ㄱㅅ":"ㄳ","ㄴㅈ":"ㄵ","ㄴㅎ":"ㄶ","ㄹㄱ":"ㄺ","ㄹㅁ":"ㄻ","ㄹㅂ":"ㄼ", "ㄹㅅ":"ㄽ","ㄹㅌ":"ㄾ","ㄹㅍ":"ㄿ","ㄹㅎ":"ㅀ","ㅂㅅ":"ㅄ"}; const FSPLIT = Object.fromEntries(Object.entries(FJOIN).map(([k,v]) => [v, [k[0], k[1]]])); /** [initialIndex, medialIndex, finalIndex] or null if not a syllable block. */ export function decompose(ch) { const c = ch.charCodeAt(0) - 0xAC00; if (c < 0 || c > 11171) return null; return [Math.floor(c / 588), Math.floor((c % 588) / 28), c % 28]; } export function compose(i, m, f = 0) { return String.fromCharCode(0xAC00 + (i * 21 + m) * 28 + f); } export const isJamo = { initial: c => CHO.includes(c), medial: c => JUNG.includes(c), final: c => c && JONG.indexOf(c) > 0, }; /* ── 두벌식 IME ─────────────────────────────────────────── Hold one composing buffer per input. Feed jamo with key(), punctuation and spaces with text(), and Backspace with back(). Each call returns the full new value. */ export class Composer { constructor() { this.reset(); } reset() { this.cho = null; this.jung = null; this.jong = null; } get empty() { return !this.cho && !this.jung && !this.jong; } /** The syllable currently being assembled, as text. */ render() { if (this.cho && this.jung) { const i = CHO.indexOf(this.cho), m = JUNG.indexOf(this.jung); const f = this.jong ? JONG.indexOf(this.jong) : 0; if (i >= 0 && m >= 0 && f >= 0) return compose(i, m, f); } return (this.cho || "") + (this.jung || "") + (this.jong || ""); } /** Strip the composing tail off a value so it can be rebuilt. */ _base(value) { const cur = this.render(); return cur && value.endsWith(cur) ? value.slice(0, -cur.length) : value; } key(value, j) { let base = this._base(value); if (isJamo.medial(j)) { if (this.jong) { // final splits off to start a new block const parts = FSPLIT[this.jong]; let moved; if (parts) { this.jong = parts[0]; moved = parts[1]; } else { moved = this.jong; this.jong = null; } base += this.render(); this.cho = moved; this.jung = j; this.jong = null; } else if (this.jung) { const join = VJOIN[this.jung + j]; if (join) this.jung = join; else { base += this.render(); this.reset(); this.jung = j; } } else this.jung = j; } else { if (this.cho && this.jung) { if (this.jong) { const join = FJOIN[this.jong + j]; if (join) this.jong = join; else { base += this.render(); this.reset(); this.cho = j; } } else if (isJamo.final(j)) this.jong = j; else { base += this.render(); this.reset(); this.cho = j; } } else { if (!this.empty) base += this.render(); this.reset(); this.cho = j; } } return base + this.render(); } back(value) { let base = this._base(value); if (this.jong) { const parts = FSPLIT[this.jong]; this.jong = parts ? parts[0] : null; } else if (this.jung) { let peeled = null; for (const [k, v] of Object.entries(VJOIN)) if (v === this.jung) peeled = k[0]; this.jung = peeled; } else if (this.cho) { this.cho = null; } else { // pull a finished block back in const last = base.slice(-1); base = base.slice(0, -1); const d = last ? decompose(last) : null; if (d) { this.cho = CHO[d[0]]; this.jung = JUNG[d[1]]; this.jong = d[2] ? JONG[d[2]] : null; if (this.jong) { const p = FSPLIT[this.jong]; this.jong = p ? p[0] : null; } else this.jung = null; } } return base + this.render(); } /** Commit the buffer and append literal text (space, punctuation). */ text(value, t) { const base = this._base(value) + this.render(); this.reset(); return base + t; } } /** Standard 두벌식 layout, top row first. Shift gives the tense pairs. */ export const KEYBOARD = { rows: [ ["ㅂ","ㅈ","ㄷ","ㄱ","ㅅ","ㅛ","ㅕ","ㅑ","ㅐ","ㅔ"], ["ㅁ","ㄴ","ㅇ","ㄹ","ㅎ","ㅗ","ㅓ","ㅏ","ㅣ"], ["ㅋ","ㅌ","ㅊ","ㅍ","ㅠ","ㅜ","ㅡ"], ], shift: {"ㅂ":"ㅃ","ㅈ":"ㅉ","ㄷ":"ㄸ","ㄱ":"ㄲ","ㅅ":"ㅆ","ㅐ":"ㅒ","ㅔ":"ㅖ"}, }; /* ── Letter-level marking ────────────────────────────────────────────── A Hangul syllable is a single character, so a language model cannot see the letters inside it. Asked which letter a student got wrong it will reconstruct a plausible answer, and plausible is not the same as right: asked about 빫다 for 짧다 the artifact's tutor blamed the ㄼ batchim — which was identical in both — when the slip was the initial ㅉ→ㅃ. So the client computes the comparison and the prompt forbids the tutor from inferring one. Port this before anything else in the marking path; it is the difference between a tutor that teaches spelling and one that invents explanations. */ /** Two-consonant batchim clusters, unpacked for explanation. */ export const CLUSTER = { "ㄳ":"ㄱ+ㅅ", "ㄵ":"ㄴ+ㅈ", "ㄶ":"ㄴ+ㅎ", "ㄺ":"ㄹ+ㄱ", "ㄻ":"ㄹ+ㅁ", "ㄼ":"ㄹ+ㅂ", "ㄽ":"ㄹ+ㅅ", "ㄾ":"ㄹ+ㅌ", "ㄿ":"ㄹ+ㅍ", "ㅀ":"ㄹ+ㅎ", "ㅄ":"ㅂ+ㅅ" }; export const SLOT = ["first consonant", "vowel", "batchim"]; const finalOf = d => { const f = JONG[d[2]] === " " ? "" : JONG[d[2]]; return f; }; const slotValue = (d, slot) => slot === 0 ? CHO[d[0]] : slot === 1 ? JUNG[d[1]] : (finalOf(d) ? finalOf(d) + (CLUSTER[finalOf(d)] ? ` (${CLUSTER[finalOf(d)]})` : "") : "none"); /** "짧다" → "짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ" */ export function spellOut(word) { return String(word || "").split("").map(ch => { const d = decompose(ch); if (!d) return ch; const f = finalOf(d); return `${ch}=${CHO[d[0]]}+${JUNG[d[1]]}${f ? "+" + f + (CLUSTER[f] ? `(${CLUSTER[f]})` : "") : ""}`; }).join(" · "); } /** * The exact jamo difference between the word wanted and the word written. * Always names what was CORRECT as well — the tutor's failure mode is * calling a right letter wrong, so it must be told which ones to leave alone. */ export function letterDiff(expected, written) { const exp = String(expected || ""), got = String(written || ""); if (!exp || !got) return ""; if (exp === got) return "identical"; const out = []; if (exp.length !== got.length) out.push(`length differs: ${exp.length} syllables expected, ${got.length} written`); const n = Math.min(exp.length, got.length); for (let i = 0; i < n; i++) { if (exp[i] === got[i]) continue; const a = decompose(exp[i]), b = decompose(got[i]); if (!a || !b) { out.push(`syllable ${i + 1}: wrote ${got[i]} for ${exp[i]}`); continue; } const wrong = [], right = []; for (let s = 0; s < 3; s++) { if (a[s] === b[s]) right.push(`${SLOT[s]} ${slotValue(a, s)}`); else wrong.push(`${SLOT[s]}: wrote ${slotValue(b, s)}, should be ${slotValue(a, s)}`); } out.push(`syllable ${i + 1} (${got[i]} for ${exp[i]}) — WRONG: ${wrong.join("; ")}` + (right.length ? ` — CORRECT, do not call these mistakes: ${right.join(", ")}` : "")); } return out.join(" | "); } /** The block a marking message should carry. Empty when nothing differs. */ export function letterCheck(rows) { const an = rows.filter(r => r.expected && r.written && r.expected !== r.written) .map(r => `• ${r.prompt}\n wanted ${r.expected} [${spellOut(r.expected)}]\n` + ` wrote ${r.written} [${spellOut(r.written)}]\n ${letterDiff(r.expected, r.written)}`); if (!an.length) return ""; return "════ LETTER-LEVEL CHECK — computed by the app ════\n" + "This is the actual jamo comparison. Use it exactly. Do NOT work out for yourself " + "which letter was wrong, and never call a letter wrong that is listed as correct.\n" + an.join("\n"); }