From f2263e6dff0f71088be1fb3c35a9b9ce18604cd5 Mon Sep 17 00:00:00 2001 From: AliReza Date: Mon, 12 Oct 2026 12:15:31 +0330 Subject: [PATCH] MathText: last-line guard that turns stray LaTeX/markdown into plain Unicode --- src/lib/MathText.tsx | 4 ++- src/lib/plainMath.ts | 77 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/lib/plainMath.ts diff --git a/src/lib/MathText.tsx b/src/lib/MathText.tsx index 022a534..e3a9480 100644 --- a/src/lib/MathText.tsx +++ b/src/lib/MathText.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react'; +import { plainMath } from './plainMath'; /** * متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»). @@ -32,7 +33,8 @@ const EDGE_END = /[\s،؛:!؟«»"'.=]+$/; // سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد. const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const; -export function MathText({ text }: { text: string }) { +export function MathText({ text: raw }: { text: string }) { + const text = plainMath(raw); const out: React.ReactNode[] = []; const depth = bracketDepth(text); let last = 0, i = 0; diff --git a/src/lib/plainMath.ts b/src/lib/plainMath.ts new file mode 100644 index 0000000..53c0b1c --- /dev/null +++ b/src/lib/plainMath.ts @@ -0,0 +1,77 @@ +/** + * تورِ ایمنیِ نمایش: LaTeX و نشانه‌گذاریِ خامی که به هر دلیل به صفحه رسیده به یونیکدِ ساده برمی‌گردد. + * بک‌اند هم همین تبدیل را در seed، دیتابیس و پاسخِ دستیار انجام می‌دهد + * (`content/plain_math.py`، `rag/services/mathtext.py`)؛ این‌جا فقط آخرین لایه است تا متنِ کهنه یا + * پاسخِ ناخواسته‌ی مدل هرگز با `\frac` و `^{2}` دیده نشود. هر دو باید هم‌رفتار بمانند. + */ +const SUP: Record = { + ...Object.fromEntries([...'۰۱۲۳۴۵۶۷۸۹'].map((c, i) => [c, '⁰¹²³⁴⁵⁶⁷⁸⁹'[i]])), + ...Object.fromEntries([...'0123456789'].map((c, i) => [c, '⁰¹²³⁴⁵⁶⁷⁸⁹'[i]])), + '+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼', '(': '⁽', ')': '⁾', + ...Object.fromEntries([...'abcdefghijklmnoprstuvwxyzαβγδεθφχ'].map((c, i) => [c, [...'ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻᵅᵝᵞᵟᵋᶿᵠᵡ'][i]])), +}; +const SUB: Record = { + ...Object.fromEntries([...'۰۱۲۳۴۵۶۷۸۹'].map((c, i) => [c, '₀₁₂₃₄₅₆₇₈₉'[i]])), + ...Object.fromEntries([...'0123456789'].map((c, i) => [c, '₀₁₂₃₄₅₆₇₈₉'[i]])), + '+': '₊', '-': '₋', '−': '₋', '=': '₌', '(': '₍', ')': '₎', + ...Object.fromEntries([...'aehijklmnoprstuvx'].map((c, i) => [c, [...'ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ'][i]])), +}; +const SYMBOLS: Record = { + times: '×', cdot: '·', div: '÷', pm: '±', mp: '∓', theta: 'θ', pi: 'π', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', + Delta: 'Δ', lambda: 'λ', mu: 'μ', sigma: 'σ', Sigma: 'Σ', phi: 'φ', varphi: 'φ', omega: 'ω', epsilon: 'ε', rho: 'ρ', tau: 'τ', + infty: '∞', neq: '≠', ne: '≠', leq: '≤', le: '≤', geq: '≥', ge: '≥', to: '→', rightarrow: '→', leftarrow: '←', + Rightarrow: '⇒', implies: '⇒', Leftrightarrow: '⇔', iff: '⇔', perp: '⊥', parallel: '∥', circ: '°', degree: '°', ldots: '…', + dots: '…', cdots: '⋯', int: '∫', sum: 'Σ', prod: '∏', partial: '∂', in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', + supset: '⊃', cup: '∪', cap: '∩', emptyset: '∅', varnothing: '∅', forall: '∀', exists: '∃', angle: '∠', triangle: '△', + approx: '≈', equiv: '≡', sim: '∼', cong: '≅', mid: '|', vert: '|', neg: '¬', therefore: '∴', setminus: '−', + quad: ' ', qquad: ' ', prime: '′', + lim: 'lim', sin: 'sin', cos: 'cos', tan: 'tan', cot: 'cot', sec: 'sec', csc: 'csc', log: 'log', ln: 'ln', exp: 'exp', + max: 'max', min: 'min', det: 'det', gcd: 'gcd', arcsin: 'arcsin', arccos: 'arccos', arctan: 'arctan', +}; +const BLACKBOARD: Record = { R: 'ℝ', Q: 'ℚ', N: 'ℕ', Z: 'ℤ', C: 'ℂ' }; +const SYMBOL_RE = new RegExp('\\\\(' + Object.keys(SYMBOLS).sort((a, b) => b.length - a.length).join('|') + ')', 'g'); +const G = '\\{([^{}]*)\\}'; +const NEEDS = /[\\^$]|[A-Za-z)]_|\*\*|__|`|^#{1,6}\s/m; + +const atomic = (s: string) => /^[\w۰-۹.]+$/.test(s.trim()); +const wrap = (s: string) => (atomic(s) ? s.trim() : `(${s.trim()})`); +const tryMap = (body: string, table: Record) => { + const b = body.trim(); + return b && [...b].every((c) => c in table) ? [...b].map((c) => table[c]).join('') : null; +}; +const script = (body: string, table: Record, marker: string) => { + const mapped = tryMap(body, table); + if (mapped) return mapped; + const b = body.trim(); + if (marker === '^') return ` به توانِ (${b}) `; + return /^[\w۰-۹]{1,4}$/.test(b) ? b : `_(${b})`; +}; + +export function plainMath(text: string): string { + if (!text || !NEEDS.test(text)) return text; + let s = text; + s = s.replace(/\$\$([\s\S]+?)\$\$/g, '$1').replace(/(? BLACKBOARD[a] ?? a); + s = s.replace(new RegExp(`\\\\(?:text|mathrm|mathbf|operatorname|textbf)${G}`, 'g'), '$1'); + s = s.replace(new RegExp(`\\\\[dt]?frac${G}${G}`, 'g'), (_m, a: string, b: string) => `${wrap(a)}/${wrap(b)}`); + s = s.replace(new RegExp(`\\\\sqrt${G}`, 'g'), (_m, a: string) => `√${wrap(a)}`); + } + s = s.replace(new RegExp(`\\\\lim\\s*_${G}`, 'g'), 'lim($1)'); + s = s.replace(/\\(left|right)\s*/g, '').replace(/\\\|/g, '|').replace(/\\\{/g, '{').replace(/\\\}/g, '}').replace(/\\[, ]/g, ' '); + s = s.replace(SYMBOL_RE, (_m, n: string) => SYMBOLS[n]); + s = s.replace(new RegExp(`\\^${G}`, 'g'), (_m, a: string) => script(a, SUP, '^')); + s = s.replace(new RegExp(`_${G}`, 'g'), (_m, a: string) => script(a, SUB, '_')); + s = s.replace(/\^\(((?:[^()]|\([^()]*\))*)\)/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توانِ (${a.trim()}) `); + s = s.replace(/\^([+\-−]?[0-9۰-۹]+)/g, (m, a: string) => tryMap(a, SUP) ?? m); + s = s.replace(/(?<=[A-Za-z0-9۰-۹)₀-₉])\^([A-Za-zα-ωΑ-Ω□])(?![A-Za-z0-9۰-۹])/g, (_m, a: string) => tryMap(a, SUP) ?? ` به توانِ ${a} `); + s = s.replace(/(?<=[A-Za-z)∫Σ∏])_([0-9۰-۹]+|[A-Za-z]{1,4}[0-9۰-۹]?)/g, (_m, a: string) => [...a].map((c) => SUB[c] ?? c).join('')); + s = s.replace(/\^([+\-−])/g, (_m, a: string) => SUP[a]); + s = s.replace(/(?<=[A-Za-z)'])_([+\-−])/g, (_m, a: string) => SUB[a]); + s = s.replace(/\^(√[0-9۰-۹A-Za-z]+)/g, ' به توانِ $1 ').replace(/\^([A-Za-z]{2,3})(?![A-Za-z0-9])/g, ' به توانِ $1 '); + s = s.replace(/(?<=\S) {2,}(?=\S)/g, ' '); + s = s.replace(/\*\*([\s\S]+?)\*\*/g, '$1').replace(/(?