Files
MathApp_frontend/src/lib/MathText.tsx
AliReza ca96a91dee No diacritics or written ezafe ی in the UI; map without the speech banner, companions cheering along the path; familiar payment sheet with gateway logos and per-channel methods; account deletion moved under «حساب کاربری»
- Text: kasra/fatha/damma/shadda/sukun/hamza-above and «ه‌ی» ezafe removed from every user-visible string (comments untouched); tanwin kept. Stale copy fixed (question packs are no longer sold), awkward onboarding sentences rewritten, سؤال → سوال. plainPersian() in MathText cleans the live tutor stream.
- Home: the mascot + speech bubble header is gone; the selected companion hops beside the current island and sits at every third bend (CSS transform animations only).
- Checkout: order summary, payment-method rows with Zarinpal/Bazaar icons and radio, amount on the pay button, lock trust line, darker overlay. paymentMethods in config.js per channel (web: zarinpal, app: zarinpal+bazaar by default).
- Profile: «ورود با حساب دیگر» removed (it was logout); new «حساب کاربری» sheet shows phone/ID and holds «حذف حساب کاربری»; logout is a red row. Delete sheet tells Bazaar subscribers to cancel renewal in Bazaar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:55:17 +03:30

113 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import { Fragment } from 'react';
import { plainMath, plainPersian } from './plainMath';
/**
* متنِ فارسی که ریاضی لایش است («عدد −۱ می‌شود»، «رأس (۱، −۴) است»).
*
* رقمِ فارسی در الگوریتمِ جهتِ یونیکد «عددِ عربی» است و در متنِ راست‌به‌چپ علامت‌های
* کنارش را جابه‌جا می‌کند: «−۱» به‌صورتِ «۱−» و «(۱، −۴)» به‌صورتِ «(−۴ ،۱)» دیده
* می‌شد. هر تکه‌ای که حرفِ فارسی ندارد ولی رقم دارد، اینجا با bidi-override چپ‌به‌راست
* قفل می‌شود؛ جمله‌ی فارسی دورش دست نمی‌خورد.
*
* فهرستِ داخلِ جمله («از −۳ به −۲، −۱، ۰ می‌رسیم»، «الگویِ ۲، ۵، ۸، …») مالِ جمله است و
* از راست خوانده می‌شود؛ پس «،» و «؛» بیرونِ پرانتز و آکولاد تکه را می‌شکنند و هر عضو
* جدا قفل می‌شود. داخلِ `{۱، ۲، ۳}` و `(۱، −۴)` ریاضی است و یک‌جا قفل می‌ماند. سنجیده
* با مختصاتِ رسم‌شده در Chrome روی همه‌ی سوال‌ها و گزینه‌های بانک و چند جوابِ دستیار
* (۲۰۲۶-۰۹-۲۹)؛ پیش از آن هر ۴۸۲ فهرستِ داخلِ جمله برعکس دیده می‌شد.
*
* آزموده روی Chrome (دسکتاپ و موبایلِ شبیه‌سازی‌شده)؛ روی Safariِ واقعیِ آیفون دیده نشده.
*/
// حروف، اِعراب (کسره‌ی «خودِ»)، و نیم‌فاصله؛ رقم‌های ۰–۹ عمداً بیرون‌اند.
const LETTER = '\\u0620-\\u065F\\u066E-\\u06D3\\u06FA-\\u06FF\\u200C';
// «نویسه‌ی ریاضی»: رقم یا حرفِ لاتین (نامِ تابع و متغیر: f، x، A). بدونِ حرفِ لاتین «−f(x)» تکه حساب
// نمی‌شد و در متنِ راست‌به‌چپ به‌صورتِ «f(x)−» دیده می‌شد (گزارشِ ۲۰۲۶-۱۰-۱۲).
const MATH_CH = '0-9۰-۹A-Za-z';
// تکه‌ای بدونِ حرفِ فارسی و بدونِ خطِ تازه که دستِ‌کم یک نویسه‌ی ریاضی دارد
const RUN = new RegExp(`[^${LETTER}\\n]*[${MATH_CH}][^${LETTER}\\n]*`, 'g');
const HAS_MATH = new RegExp(`[${MATH_CH}]`);
// نشانه‌هایی که مالِ جمله‌ی فارسی‌اند، نه ریاضی: از دو سرِ تکه بیرون می‌مانند
const EDGE_START = /^[\s،؛:!؟«»"'.=]+/;
const EDGE_END = /[\s،؛:!؟«»"'.=]+$/;
// isolate، نه embedding: تکه‌ی قفل‌شده از بیرون یک واحدِ خنثی است. با bidi-overrideِ ساده
// تکه‌ها از بیرون «حرفِ چپ‌به‌راست» حساب می‌شدند و «،»ِ بینِ دو عضوِ فهرست را هم به
// سمتِ خودشان می‌کشیدند، پس فهرست باز برعکس می‌شد.
const LTR = { unicodeBidi: 'isolate-override', direction: 'ltr' } as const;
export function MathText({ text: raw }: { text: string }) {
const text = plainPersian(plainMath(raw));
const out: React.ReactNode[] = [];
const depth = bracketDepth(text);
let last = 0, i = 0;
for (const m of text.matchAll(RUN)) {
for (const [start, piece] of splitList(m[0], m.index ?? 0, depth)) {
let run = piece;
// شماره‌ی مرحله در سرِ خط («۴) …») مالِ جمله‌ی فارسی است و باید سمتِ راست بماند.
const atLineStart = start === 0 || text[start - 1] === '\n';
const step = atLineStart ? /^\s*[0-9۰-۹]{1,2}\)\s*/.exec(run)?.[0] ?? '' : '';
const lead = step + (EDGE_START.exec(run.slice(step.length))?.[0] ?? '');
run = run.slice(lead.length);
const tail = EDGE_END.exec(run)?.[0] ?? '';
run = run.slice(0, run.length - tail.length);
// «(۱، −۴)» باید کامل قفل شود، ولی پرانتزِ بی‌جفت در لبه‌ی تکه مالِ جمله است:
// در «۱۰ − ۲ (با محاسبه…)» پرانتزِ باز جزوِ جمله‌ی فارسیِ بعدی است و اگر قفل شود
// به سمتِ غلط می‌رود.
const edge = trimUnpaired(run);
const head = lead + edge.head;
run = edge.run;
if (!run || !HAS_MATH.test(run)) continue;
out.push(text.slice(last, start + head.length));
out.push(<span key={i++} style={LTR}>{run}</span>);
last = start + head.length + run.length;
}
}
out.push(text.slice(last));
return <>{out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n))}</>;
}
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسه‌ی متن. */
function bracketDepth(text: string): number[] {
const depth: number[] = [];
let d = 0;
for (const ch of text) {
depth.push(d);
if ('([{'.includes(ch)) d++;
else if (')]}'.includes(ch)) d = Math.max(0, d - 1);
}
return depth;
}
/** تکه را روی «،» و «؛»ِ بیرونِ پرانتز می‌شکند؛ جداکننده خودش بیرون می‌ماند. */
function splitList(run: string, offset: number, depth: number[]): [number, string][] {
const pieces: [number, string][] = [];
let from = 0;
for (let k = 0; k < run.length; k++) {
if ((run[k] === '،' || run[k] === '؛') && !depth[offset + k]) {
pieces.push([offset + from, run.slice(from, k)]);
from = k + 1;
}
}
pieces.push([offset + from, run.slice(from)]);
return pieces;
}
const count = (s: string, ch: string) => s.split(ch).length - 1;
/**
* پرانتزِ بازِ بی‌جفت در تهِ تکه و بسته‌ی بی‌جفت در سرش را (با فاصله‌ی کنارش) کنار
* می‌گذارد؛ `head` یعنی چه مقدار از سرِ تکه به جمله‌ی فارسی برگشت.
*/
function trimUnpaired(run: string): { head: string; run: string } {
let head = '';
const opens = (r: string) => count(r, '(') + count(r, '[') + count(r, '{');
const closes = (r: string) => count(r, ')') + count(r, ']') + count(r, '}');
while (/[(\[{]\s*$/.test(run) && opens(run) > closes(run)) {
run = run.slice(0, run.length - /\s*[(\[{]\s*$/.exec(run)![0].length);
}
while (/^\s*[)\]}]/.test(run) && closes(run) > opens(run)) {
const m = /^\s*[)\]}]\s*/.exec(run)![0];
head += m;
run = run.slice(m.length);
}
return { head, run };
}