Files
MathApp/content/plain_math.py

165 lines
12 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': '⋯', 'vdots': '⋮', 'ddots': '⋱', '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]_|sqrt\(|vdots|ddots|\*\*|__|`|^#{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'(?<![A-Za-z\\])sqrt\(', '√(', s) # sqrt(x) به‌شکلِ کد
s = re.sub(r'(?<![A-Za-z\\])(vdots|ddots)(?![A-Za-z])', lambda m: _SYMBOLS[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]+|(?<![A-Za-z])(?:vdots|ddots|cdots|ldots|dfrac|tfrac|sqrt\(|overrightarrow|widehat)(?![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
# اعراب (کسره، فتحه، ضمه، تشدید، سکون، همزه‌ی رویِ «ه») و «ی»ِ نوشته‌شده‌ی اضافه («پایه‌ی سوم» → «پایه سوم»):
# تصمیمِ شرکت (۲۰۲۶-۱۰-۱۴) این است که هیچ‌کدام به کاربر نرسد. تنوین («قبلاً») عمداً می‌ماند: جزئی از املای
# معیار است و بی‌آن غلطِ تایپی به نظر می‌رسد. «ه‌ی» فقط وقتی برداشته می‌شود که بعدش حرفِ فارسیِ دیگری نچسبیده باشد.
_FA_DIACRITICS = re.compile('[\u064C-\u0652\u0654]')
# «ه» + نیم‌فاصله + «ی» که بعدش حرفِ فارسی یا نیم‌فاصله‌ی دیگری نیست؛ با «ه» شروع می‌شود (نه lookbehind) تا موتورِ regex
# با جستجوی سریعِ پیشوند جلو برود.
_FA_EZAFE_YE = re.compile('\u0647\u200C\u06CC(?![\u0600-\u06FF\u200C])')
# همان دو قاعده روی بایت‌های UTF-8 (برای متنِ کاملِ پاسخِ JSON): اعراب D9 8C..D9 92 و D9 94 است، و D9 هرگز بایتِ
# ادامه نیست، پس با تکه‌ای از نویسه‌ی دیگر یکی نمی‌شود. حروفِ فارسی/عربی D8..DB و نیم‌فاصله E2 80 8C.
_FA_DIACRITICS_B = re.compile(rb'\xd9[\x8c-\x92\x94]')
_FA_EZAFE_YE_B = re.compile(rb'\xd9\x87\xe2\x80\x8c\xdb\x8c(?![\xd8-\xdb]|\xe2\x80\x8c)')
def plain_persian(text):
"""متنِ فارسی بی‌اعراب و بی «ی»ِ اضافه؛ غیرِ رشته دست‌نخورده. idempotent."""
if not isinstance(text, str) or text.isascii():
return text
return _FA_EZAFE_YE.sub('\u0647', _FA_DIACRITICS.sub('', text))
def plain_persian_utf8(body: bytes) -> bytes:
"""plain_persian روی بایت‌های UTF-8، بی decode. روی متنِ کاملِ یک پاسخِ JSON درست است: هیچ‌کدام از این نویسه‌ها
جزئی از نحوِ JSON نیستند. روی curriculum.jsonِ محلی (۵ مگابایت JSON) حدودِ ۲۷ میلی‌ثانیه، در برابرِ ۵۷ برای خودِ
JSONRenderer؛ پاسخِ معمولِ /api/home چند ده کیلوبایت است."""
return _FA_EZAFE_YE_B.sub(b'\xd9\x87', _FA_DIACRITICS_B.sub(b'', body))