136 lines
8.9 KiB
Python
136 lines
8.9 KiB
Python
"""
|
||
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
|