97 lines
5.4 KiB
Python
97 lines
5.4 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('⁰¹²³⁴⁵⁶⁷⁸⁹')}, '+': '⁺', '-': '⁻', '−': '⁻', '=': '⁼',
|
||
'(': '⁽', ')': '⁾', 'n': 'ⁿ', 'i': 'ⁱ', 'x': 'ˣ', 'y': 'ʸ', 'k': 'ᵏ', 'm': 'ᵐ', 'p': 'ᵖ', 't': 'ᵗ'}
|
||
_SUB = {**{str(i): c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, **{'۰۱۲۳۴۵۶۷۸۹'[i]: c for i, c in enumerate('₀₁₂₃₄₅₆₇₈₉')}, '+': '₊', '-': '₋', '−': '₋', '=': '₌',
|
||
'(': '₍', ')': '₎', 'n': 'ₙ', 'i': 'ᵢ', 'x': 'ₓ', 'k': 'ₖ', 'm': 'ₘ', 'p': 'ₚ', 't': 'ₜ'}
|
||
|
||
_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',
|
||
}
|
||
_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)
|
||
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 convert(text: str) -> str:
|
||
if not isinstance(text, str) or not re.search(r'[\\^]|[A-Za-z]_', text):
|
||
return text
|
||
s = text
|
||
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'\\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('\\ ', ' ')
|
||
s = re.sub(r'\\([A-Za-z]+)', lambda m: _SYMBOLS.get(m[1], m[0]), 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 m[0], s)
|
||
s = re.sub(r'\^([+\-−]?[0-9۰-۹]+|[nixykmpt])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUP) or m[0], s)
|
||
s = re.sub(r'(?<=[A-Za-z\)])_([0-9۰-۹]+|[nikmpt])(?![A-Za-z0-9۰-۹])', lambda m: _try(m[1], _SUB) or m[0], s)
|
||
return s
|
||
|
||
|
||
_LEFTOVER = re.compile(r'\\[A-Za-z]+|\\[()\[\]]|\$|[_^]\{')
|
||
|
||
|
||
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
|