Files
MathApp/content/plain_math.py

136 lines
8.9 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LaTeX → متنِ ساده‌ی یونیکد، برای متنِ درس‌ها و سوال‌ها.
چرا: فرانت (`MathText`) هیچ LaTeX‌ای رندر نمی‌کند؛ هر `\\( \\vec{a} \\)` که در متنِ درس بماند
عیناً با بک‌اسلش و آکولاد دیده می‌شود (گزارشِ مالک از درس‌های بردارِ دوازدهم، ۲۰۲۶-۱۰-۱۲).
قراردادِ متنِ کتاب‌ها: بردارها بی‌فلش («بردار a»، «|a|»)، توان با نویسه‌ی بالانویس (²، ⁿ).
فقط فرمان‌هایی که در seed دیده شد پشتیبانی می‌شود. فرمانِ ناشناس **تغییر نمی‌کند** و
`leftover()` آن را برمی‌گرداند تا تست بشکند، نه این‌که بی‌صدا حدس زده شود.
"""
import re
_SUP = {**{str(i): c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('⁰¹²³⁴⁵⁶⁷⁸⁹')}, '+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼',
'(': '⁽', ')': '⁾', **dict(zip('abcdefghijklmnoprstuvwxyzαβγδεθφχ', 'ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖʳˢᵗᵘᵛʷˣʸᶻᵅᵝᵞᵟᵋᶿᵠᵡ'))}
_SUB = {**{str(i): c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, '+': '₊', '-': '₋', '−': '₋', '=': '₌',
'(': '₍', ')': '₎', **dict(zip('aehijklmnoprstuvx', 'ₐₑₕᵢⱼₖₗₘₙₒₚᵣₛₜᵤᵥₓ'))}
_SYMBOLS = {
'times': '×', 'cdot': '·', 'div': '÷', 'pm': '±', 'theta': 'θ', 'pi': 'π', 'alpha': 'α', 'beta': 'β',
'infty': '∞', 'neq': '≠', 'ne': '≠', 'leq': '≤', 'le': '≤', 'geq': '≥', 'ge': '≥', 'to': '→',
'rightarrow': '→', 'Rightarrow': '⇒', 'perp': '⊥', 'parallel': '∥', 'circ': '°', 'ldots': '…', 'dots': '…',
'lim': 'lim', 'sin': 'sin', 'cos': 'cos', 'tan': 'tan', 'log': 'log', 'ln': 'ln',
'int': '∫', 'sum': 'Σ', 'prod': '∏', 'partial': '∂', 'in': '∈', 'notin': '∉', 'subset': '⊂', 'subseteq': '⊆',
'supset': '⊃', 'cup': '∪', 'cap': '∩', 'emptyset': '∅', 'varnothing': '∅', 'forall': '∀', 'exists': '∃',
'angle': '∠', 'triangle': '△', 'approx': '≈', 'equiv': '≡', 'sim': '∼', 'cong': '≅', 'mid': '|', 'vert': '|',
'Delta': 'Δ', 'delta': 'δ', 'gamma': 'γ', 'lambda': 'λ', 'mu': 'μ', 'sigma': 'σ', 'Sigma': 'Σ', 'phi': 'φ',
'varphi': 'φ', 'omega': 'ω', 'epsilon': 'ε', 'varepsilon': 'ε', 'rho': 'ρ', 'tau': 'τ', 'Leftrightarrow': '⇔',
'iff': '⇔', 'implies': '⇒', 'leftarrow': '←', 'cdots': '⋯', 'therefore': '∴', 'setminus': '−', 'neg': '¬',
'quad': ' ', 'qquad': ' ', 'max': 'max', 'min': 'min', 'cot': 'cot', 'sec': 'sec', 'csc': 'csc',
'arcsin': 'arcsin', 'arccos': 'arccos', 'arctan': 'arctan', 'exp': 'exp', 'det': 'det', 'gcd': 'gcd',
'degree': '°', 'prime': '′',
}
_BLACKBOARD = {'R': 'ℝ', 'Q': 'ℚ', 'N': 'ℕ', 'Z': 'ℤ', 'C': 'ℂ'}
_GROUP = r'\{([^{}]*)\}'
def _script(body: str, table: dict, marker: str) -> str:
body = body.strip()
if body and all(ch in table for ch in body):
return ''.join(table[ch] for ch in body)
if marker == '^':
# توانِ پیچیده (a^(γ−۲)، a^(۱/n)): نویسه‌ی بالانویس ندارد؛ به‌جای «^» می‌نویسیم «به توانِ».
return f' به توانِ ({body}) '
if marker == '_' and len(body) <= 4 and re.fullmatch(r'[\w۰-۹]+', body):
return body # اندیسِ بی‌نویسه‌ی زیرنویس (m_AB، x_A): بدونِ «_»، همان حروف می‌چسبند
return f'{marker}({body})'
def _try(body: str, table: dict):
body = body.strip()
return ''.join(table[ch] for ch in body) if body and all(ch in table for ch in body) else None
def _atomic(s: str) -> bool:
return bool(re.fullmatch(r'[\w۰-۹.]+', s.strip()))
def _wrap(s: str) -> str:
s = s.strip()
return s if _atomic(s) else f'({s})'
def _strip_markdown(s: str) -> str:
s = re.sub(r'\*\*(.+?)\*\*', r'\1', s, flags=re.S)
s = re.sub(r'(?<![_\w])__(.+?)__(?![_\w])', r'\1', s)
s = s.replace('`', '')
return re.sub(r'^\s{0,3}#{1,6}\s+', '', s, flags=re.M)
def convert(text: str) -> str:
if not isinstance(text, str) or not re.search(r'[\\^$ÂÊ]|[A-Za-z]_|\*\*|__|`|^#{1,6}\s', text, re.M):
return text
s = text
if re.search('[ÂÊ]', s):
# «ÂÊB» و «Â1»: زاویه‌ی کلاه‌دار که فونتِ گوشی درست نشان نمی‌دهد → ∠AEB و ∠A₁
s = re.sub(r'([A-Z]?)Â([A-Z]?)Ê([A-Z])', lambda m: '∠' + m[1] + 'A' + m[2] + 'E' + m[3], s)
s = re.sub(r'([A-Z])Ê([A-Z])', r'∠\1E\2', s)
s = re.sub(r'Â([0-9])', lambda m: '∠A' + '₀₁₂₃₄₅₆₇₈₉'[int(m[1])], s)
s = re.sub(r'\$\$(.+?)\$\$', r'\1', s, flags=re.S)
s = re.sub(r'(?<![\w$])\$([^$\n]+?)\$(?![\w$])', r'\1', s)
s = re.sub(r'\\[(\[]\s*|\s*\\[)\]]', '', s) # \( \) \[ \] با فاصله‌ی داخلشان
for _ in range(4): # آکولادِ تودرتو، از داخل به بیرون
s = re.sub(r'\\vec' + _GROUP, r'\1', s)
s = re.sub(r'\\(?:overrightarrow|overline|bar|hat)' + _GROUP, r'\1', s)
s = re.sub(r'\\mathbb' + _GROUP, lambda m: _BLACKBOARD.get(m[1], m[1]), s)
s = re.sub(r'\\(?:text|mathrm|operatorname)' + _GROUP, r'\1', s)
s = re.sub(r'\\[dt]?frac' + _GROUP + _GROUP, lambda m: f'{_wrap(m[1])}/{_wrap(m[2])}', s)
s = re.sub(r'\\sqrt' + _GROUP, lambda m: '√' + _wrap(m[1]), s)
s = re.sub(r'\\lim\s*_' + _GROUP, r'lim(\1)', s) # lim_{x→0} پایینِ حد است، نه زیرنویس
s = re.sub(r'\\(left|right)\s*', '', s)
s = s.replace('\\|', '|').replace('\\{', '{').replace('\\}', '}').replace('\\,', ' ').replace('\\ ', ' ')
# بلندترین نامِ شناخته‌شده؛ \cdot که بعد از باز شدنِ \vec{b} به «\cdotb» رسیده هم درست خوانده می‌شود.
s = re.sub(r'\\(' + '|'.join(sorted(map(re.escape, _SYMBOLS), key=len, reverse=True)) + r')', lambda m: _SYMBOLS[m[1]], s)
s = re.sub(r'\^' + _GROUP, lambda m: _script(m[1], _SUP, '^'), s)
s = re.sub(r'_' + _GROUP, lambda m: _script(m[1], _SUB, '_'), s)
# شکل‌های بی‌آکولاد که کتاب‌ها و مدل‌ها نوشته‌اند: a^2، a^n، a^(m-n)، x_1. هر چه نویسه‌ی
# بالانویس/زیرنویس ندارد (a^b، D_f) همان‌طور می‌ماند. «___» (جای خالی) دست نمی‌خورد.
s = re.sub(r'\^\(((?:[^()]|\([^()]*\))*)\)', lambda m: _try(m[1], _SUP) or f' به توانِ ({m[1].strip()}) ', s)
s = re.sub(r'(?<=[A-Za-z0-9۰-۹\)₀-₉ₐ-ₜᵢⱼ])\^([A-Za-zα-ωΑ-Ω□▢])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUP) or f' به توانِ {m[1]} ', s)
s = re.sub(r'\^([+\-−]?[0-9۰-۹]+)', lambda m: _try(m[1], _SUP) or m[0], s)
s = s = re.sub(r'((?<![A-Za-z0-9_])[A-Za-z]|log|ln|lim|[)∫Σ∏])_([0-9۰-۹]+[A-Za-z]?|[A-Za-z]{1,4}[0-9۰-۹]?)(?![A-Za-z0-9_])',
lambda m: (f'log پایه‌ی {m[2]}' if m[1] == 'log' and any(ch not in _SUB for ch in m[2]) else m[1] + ''.join(_SUB.get(ch, ch) for ch in m[2])), s)
s = re.sub(r'log_([A-Za-z])(?![A-Za-z0-9])', lambda m: m[0] if m[1] in _SUB else f'log پایه‌ی {m[1]}', s)
s = re.sub(r'(?<=\S) {2,}(?=\S)', ' ', s)
s = re.sub(r'(?<=[A-Za-z])_\(([^()]*)\)', r'(\1)', s)
s = re.sub(r'\^([+\-−])', lambda m: _SUP[m[1]], s)
s = re.sub(r"(?<=[A-Za-z)'])_([+\-−])", lambda m: _SUB[m[1]], s)
s = re.sub(r'\^(√[0-9۰-۹A-Za-z]+)', r' به توانِ \1 ', s)
s = re.sub(r'\^([A-Za-z]{2,3})(?![A-Za-z0-9])', r' به توانِ \1 ', s)
s = _strip_markdown(s)
return s
_LEFTOVER = re.compile(r'\\[A-Za-z]+|\\[()\[\]]|\$|[_^]\{|\^[0-9۰-۹(a-z−+-]|\*\*|(?<![_\s])__|`|^#{1,6}\s', re.M)
def leftover(text: str) -> list[str]:
return _LEFTOVER.findall(text) if isinstance(text, str) else []
# کلیدهایی که در `Lesson.figures` متنِ نمایشی‌اند. `expr` (فرمولِ نمودار) و نامِ نقطه‌ها
# (`point`/`from`/`to`) هرگز نباید بازنویسی شوند: `x^2` در expr را به «x²» ببری نمودار می‌شکند.
FIGURE_TEXT_KEYS = frozenset({'title', 'text', 'label', 'labels'})
def convert_figures(obj, _key=None):
"""فقط رشته‌های زیرِ `FIGURE_TEXT_KEYS` را (در هر عمقی) تبدیل می‌کند."""
if isinstance(obj, str):
return convert(obj) if _key in FIGURE_TEXT_KEYS else obj
if isinstance(obj, list):
return [convert_figures(v, _key) for v in obj]
if isinstance(obj, dict):
return {k: convert_figures(v, k) for k, v in obj.items()}
return obj