forked from Tadrisino/MathApp_frontend
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<span dir="ltr" style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'center', alignItems: 'center', gap: '2px 8px', margin: '6px 0', maxWidth: '100%', unicodeBidi: 'isolate', fontWeight: 700 }}>
|
||||
{label && <span style={{ whiteSpace: 'nowrap' }}>{clean(label)}</span>}
|
||||
<span style={{ display: 'inline-grid', gridTemplateColumns: `repeat(${rows[0].cells.length}, auto)`, columnGap: 10, rowGap: 4, padding: '2px 8px', maxWidth: '100%', overflowX: 'auto', borderLeft: border, borderRight: border, borderRadius: 6 }}>
|
||||
{rows.flatMap((r, i) => r.cells.map((c, j) => <span key={`${i}-${j}`} style={{ textAlign: 'center', whiteSpace: 'nowrap', lineHeight: 1.7 }}>{clean(c)}</span>))}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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' ? <MathText key={k} text={p} display={display} /> : <Grid key={k} rows={p.rows} />))}</>;
|
||||
}
|
||||
if (!MATRIX.test(raw)) { MATRIX.lastIndex = 0; return <Inline raw={raw} display={display} />; }
|
||||
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(<span key={i++} style={{ ...LTR, display: 'block', textAlign: 'center', margin: '4px 0', fontWeight: 800 }}>{run}</span>);
|
||||
} else {
|
||||
out.push(<span key={i++} style={LTR}>{run}</span>);
|
||||
@@ -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' ? <Fragment key={`t${j}`}>{n}</Fragment> : n))}</>;
|
||||
return { nodes: out.map((n, j) => (typeof n === 'string' ? <Fragment key={`t${j}`}>{n}</Fragment> : n)), blocks };
|
||||
}
|
||||
|
||||
/** عمقِ پرانتز/کروشه/آکولاد پیش از هر نویسهی متن. */
|
||||
@@ -158,5 +233,16 @@ function trimUnpaired(run: string): { head: string; run: string } {
|
||||
head += m;
|
||||
run = run.slice(m.length);
|
||||
}
|
||||
// «…اگر i<j).»: پرانتزِ بستهی بیجفتِ تهِ تکه مالِ جملهی فارسی است. اگر داخلِ قفلِ چپبهراست بماند،
|
||||
// سمتِ راستِ j با شکلِ «)» دیده میشود، نه سمتِ چپ و آینهشده (گزارشِ مالک ۲۰۲۶-۱۰-۱۵).
|
||||
while (/[)\]}]\s*$/.test(run) && closes(run) > 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 };
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement>();
|
||||
const queue = new Set<HTMLElement>();
|
||||
@@ -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` : '';
|
||||
|
||||
Reference in New Issue
Block a user