13,689 new quiz questions in 160 practice islands (grades 3-12), Question.figure, replay rotates the quiz, LaTeX removed from lesson/question text

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 11:58:18 +03:30
parent f9eea56895
commit dd10ff83f4
14 changed files with 259891 additions and 640 deletions

96
content/plain_math.py Normal file
View File

@@ -0,0 +1,96 @@
"""
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