From 09f6b781988be440e2d7fe9a054ddb45727b2643 Mon Sep 17 00:00:00 2001 From: AliReza Date: Thu, 15 Oct 2026 13:53:27 +0330 Subject: [PATCH] Text rendering: unpaired closing paren returns to the sentence, formulas in one sentence stay inline, matrix rows align as a grid, line fit also for short items and drops justify when gaps stay ugly Co-Authored-By: Claude Sonnet 5.5 --- src/lib/MathText.tsx | 88 +++++++++++++++++++++++++++++++++++++++++++- src/lib/lineFit.ts | 21 ++++++++--- 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/lib/MathText.tsx b/src/lib/MathText.tsx index 049634d..c14cfb2 100644 --- a/src/lib/MathText.tsx +++ b/src/lib/MathText.tsx @@ -62,7 +62,73 @@ function Matrix({ src }: { src: string }) { ); } +/** + * سطرهای ماتریس که خطِ‌به‌خط نوشته شده‌اند («b₁₁ b₁₂ ⋯ b(1n)» زیرِ «b₂₁ b₂₂ ⋯ b(2n)» و «⋮ ⋮ ⋱ ⋮»): + * بدونِ ستون‌بندی، نقطه‌های عمودی زیرِ درایه‌ها نمی‌افتند و فاصله‌ی دوتایی هم در HTML فرو می‌ریزد. + * دو خطِ پشتِ هم یا بیشتر که بدونِ حرفِ فارسی‌اند و تعدادِ توکنشان برابر (≥۳) است، یک جدول می‌شوند. + * سطرِ اول می‌تواند برچسب داشته باشد («B(m×n) =»). + */ +const PERSIAN = /[؀-ۿ]/; +function parseRow(line: string): { label: string; cells: string[] } | null { + const t = line.trim(); + if (!t || PERSIAN.test(t)) return null; + const m = /^(.*?\s=\s+)(\S.*)$/.exec(t); + const label = m ? m[1].trim() : ''; + const cells = (m ? m[2] : t).split(/\s+/); + // معادله («f(1) = 1² + 1 = 2») سطرِ ماتریس نیست: توکنِ عملگر یعنی ماتریس نیست. + // نمادِ تکی (+ = ⊆ → …) جز نقطه‌چین‌ها و شماره‌ی فهرست («۲)») هم یعنی ماتریس نیست. + if (cells.some((c) => (/^[^\p{L}\p{N}]$/u.test(c) && !'⋯⋮⋱…'.includes(c)) || /^[0-9۰-۹]+[.)]$/.test(c))) return null; + return cells.length >= 3 ? { label, cells } : null; +} + +function splitGrids(raw: string): (string | { rows: { label: string; cells: string[] }[] })[] | null { + const lines = raw.split('\n'); + const parts: (string | { rows: { label: string; cells: string[] }[] })[] = []; + let buf: string[] = [], found = false, i = 0; + while (i < lines.length) { + const first = parseRow(lines[i]); + let j = i; + const rows: { label: string; cells: string[] }[] = []; + while (first && j < lines.length) { + const r = parseRow(lines[j]); + if (!r || r.cells.length !== first.cells.length) break; + rows.push(r); + j++; + } + if (rows.length >= 2) { + if (buf.length) parts.push(buf.join('\n')); + buf = []; + parts.push({ rows }); + found = true; + i = j; + } else { + buf.push(lines[i]); + i++; + } + } + if (buf.length) parts.push(buf.join('\n')); + return found ? parts : null; +} + +function Grid({ rows }: { rows: { label: string; cells: string[] }[] }) { + const border = '2px solid currentColor'; + const label = rows.find((r) => r.label)?.label ?? ''; + const clean = (c: string) => plainPersian(plainMath(c)); + return ( + + {label && {clean(label)}} + + {rows.flatMap((r, i) => r.cells.map((c, j) => {clean(c)}))} + + + ); +} + export function MathText({ text: raw, display = false }: { text: string; display?: boolean }) { + const grids = splitGrids(raw); + if (grids) { + return <>{grids.map((p, k) => (typeof p === 'string' ? : ))}; + } if (!MATRIX.test(raw)) { MATRIX.lastIndex = 0; return ; } MATRIX.lastIndex = 0; const out: React.ReactNode[] = []; @@ -78,7 +144,15 @@ export function MathText({ text: raw, display = false }: { text: string; display } function Inline({ raw, display }: { raw: string; display: boolean }) { + const first = build(raw, display); + // دو فرمولِ بلوکی در یک جمله («a₁ = … و a₂ = …») هر کدام خطِ جدا می‌گرفتند و «و» و «.» تنها می‌ماندند. + const { nodes } = first.blocks > 1 ? build(raw, false) : first; + return <>{nodes}; +} + +function build(raw: string, display: boolean): { nodes: React.ReactNode[]; blocks: number } { const text = plainPersian(plainMath(raw)); + let blocks = 0; const out: React.ReactNode[] = []; const depth = bracketDepth(text); let last = 0, i = 0; @@ -103,6 +177,7 @@ function Inline({ raw, display }: { raw: string; display: boolean }) { const before = text.slice(0, start + head.length), after = text.slice(start + head.length + run.length); const inSentence = new RegExp(`[${LETTER}]`).test(before) || new RegExp(`[${LETTER}]`).test(after); if (display && inSentence && run.trim().length >= 4 && FORMULA.test(run) && depth[start + head.length] === 0) { + blocks++; out.push({run}); } else { out.push({run}); @@ -111,7 +186,7 @@ function Inline({ raw, display }: { raw: string; display: boolean }) { } } out.push(text.slice(last)); - return <>{out.map((n, j) => (typeof n === 'string' ? {n} : n))}; + return { nodes: out.map((n, j) => (typeof n === 'string' ? {n} : n)), blocks }; } /** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسه‌ی متن. */ @@ -158,5 +233,16 @@ function trimUnpaired(run: string): { head: string; run: string } { head += m; run = run.slice(m.length); } + // «…اگر i opens(run)) { + run = run.slice(0, run.length - /\s*[)\]}]\s*$/.exec(run)![0].length); + } + // و پرانتزِ بازِ بی‌جفتِ سرِ تکه («(مثلاً a=7 …» که بسته‌اش جای دیگری است) هم مالِ جمله است. + while (/^\s*[(\[{]/.test(run) && opens(run) > closes(run)) { + const m = /^\s*[(\[{]\s*/.exec(run)![0]; + head += m; + run = run.slice(m.length); + } return { head, run }; } diff --git a/src/lib/lineFit.ts b/src/lib/lineFit.ts index 941b34f..b014651 100644 --- a/src/lib/lineFit.ts +++ b/src/lib/lineFit.ts @@ -15,7 +15,9 @@ const CANDIDATES = [0, -0.02, -0.04, -0.06, -0.08]; const SPACE_EM = 0.26; const SELECTOR = 'p, li, .whitespace-pre-line'; -const MIN_CHARS = 40; +const MIN_CHARS = 14; +// بیشینه‌ی فاصله‌ی مجازِ بینِ کلمه‌ها نسبت به فاصله‌ی معمول، پیش از آن‌که دوطرف‌چین کنار گذاشته شود +const MAX_GAP = 2.2; const observed = new WeakSet(); const queue = new Set(); @@ -43,6 +45,7 @@ function lineWidths(el: HTMLElement, fontSize: number): number[] { export function fit(el: HTMLElement) { if (!el.isConnected) return; el.style.wordSpacing = ''; + el.style.textAlign = ''; const cs = getComputedStyle(el); if (cs.textAlign !== 'justify' || cs.display === 'flex' || cs.display === 'inline-flex' || cs.display === 'none') return; const text = el.textContent ?? ''; @@ -51,25 +54,33 @@ export function fit(el: HTMLElement) { const width = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight); if (!(width > 0)) return; const spaces = (text.match(/\s+/g) ?? []).length; - if (spaces < 4) return; + if (spaces < 2) return; const s0 = SPACE_EM * fontSize; el.style.textAlign = 'start'; - let best = 0, bestScore = Infinity; + let best = 0, bestScore = Infinity, bestWorst = 0; for (const c of CANDIDATES) { el.style.wordSpacing = `${c}em`; const w = lineWidths(el, fontSize); if (w.length < 2) { best = 0; break; } const total = w.reduce((a, b) => a + b, 0); - let score = 0; + let score = 0, worst = 0; for (let i = 0; i < w.length - 1; i++) { const k = Math.max(1, (spaces * w[i]) / total); // فاصله‌های این خط (تخمین) const gap = s0 + c * fontSize + Math.max(0, width - w[i]) / k; // فشردن گران‌تر از کشیدن است (چاپِ حرفه‌ای هم همین‌طور): فشرده‌شدنِ فاصله زودتر زشت دیده می‌شود const dev = (gap - s0) / s0; + worst = Math.max(worst, gap / s0); score += (dev < 0 ? 2.2 * dev : dev) ** 2; } - if (score < bestScore - 1e-6) { bestScore = score; best = c; } + if (score < bestScore - 1e-6) { bestScore = score; best = c; bestWorst = worst; } + } + // حتی بهترین شکستن فاصله‌ای بیش از ۲٫۲ برابرِ معمول می‌سازد (کلمه‌ی بلند که جا نمی‌شود): چاپِ + // حرفه‌ای هم در این حالت دوطرف‌چین نمی‌کند، چون لبه‌ی نامرتب از فاصله‌ی گشاد بهتر است. + if (bestWorst > MAX_GAP) { + el.style.wordSpacing = ''; + el.style.textAlign = 'start'; + return; } el.style.textAlign = ''; el.style.wordSpacing = best ? `${best}em` : '';