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({run}); last = start + head.length + run.length; } } out.push(text.slice(last)); return <>{out.map((n, j) => (typeof n === 'string' ? {n} : 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 }; }