""" 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'(? 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'(? 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))