From 22bdb4d39f12bfc8703cc87f51335a3cfd931f19 Mon Sep 17 00:00:00 2001 From: AliReza Date: Thu, 15 Oct 2026 13:31:52 +0330 Subject: [PATCH] Line fitting for justified text: per-paragraph word-spacing chosen by measured gap badness (Knuth-Plass-style shrink) Co-Authored-By: Claude Sonnet 5.5 --- src/lib/lineFit.ts | 116 +++++++++++++++++++++++++++++++++++++++++++++ src/main.tsx | 2 + 2 files changed, 118 insertions(+) create mode 100644 src/lib/lineFit.ts diff --git a/src/lib/lineFit.ts b/src/lib/lineFit.ts new file mode 100644 index 0000000..941b34f --- /dev/null +++ b/src/lib/lineFit.ts @@ -0,0 +1,116 @@ +/** + * شکستنِ بهترِ خط برای متنِ دوطرف‌چین. + * + * مرورگر خط را حریصانه می‌شکند: کلمه‌ای که یک ذره جا نشود به خطِ بعد می‌رود و خطِ بالا برای + * پر شدن فاصله‌ی بینِ کلمه‌ها را بیش‌ازحد باز می‌کند. چاپِ حرفه‌ای (الگوریتمِ Knuth–Plass) برای + * این‌که کلمه‌ای از خطِ پایین به بالا بیاید، فاصله‌ها را کمی فشرده می‌کند. این‌جا همان را با + * `word-spacing`ِ منفیِ کوچک می‌کنیم: برای هر پاراگراف چند مقدار امتحان می‌شود، هر بار خط‌ها + * اندازه‌گیری می‌شوند و مقداری می‌ماند که فاصله‌ی نهاییِ خط‌ها به فاصله‌ی معمولی نزدیک‌تر است. + * + * فقط `style.wordSpacing` عوض می‌شود، نه DOM؛ پس با React و قفل‌های جهتِ `MathText` درگیر نمی‌شود. + * آزموده: Chrome (سرِ بی‌سر، فونتِ واقعیِ Vazirmatn)؛ روی Safari و WebViewِ اندروید دیده نشده. + */ + +// برحسبِ em. فاصله‌ی معمولیِ Vazirmatn حدودِ ۰٫۲۶em است؛ تا ۰٫۰۸em فشردن (~۳۰٪) حدِ چاپِ حرفه‌ای است. +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 observed = new WeakSet(); +const queue = new Set(); +let frame = 0; + +/** پهنای طبیعیِ هر خط (با ترازِ start) از مستطیل‌های Range. */ +function lineWidths(el: HTMLElement, fontSize: number): number[] { + const range = document.createRange(); + range.selectNodeContents(el); + const rects = [...range.getClientRects()].filter((r) => r.width > 0.5).sort((a, b) => a.top + a.height / 2 - (b.top + b.height / 2)); + const lines: { y: number; left: number; right: number }[] = []; + for (const r of rects) { + const y = r.top + r.height / 2; + const last = lines[lines.length - 1]; + if (last && Math.abs(y - last.y) < fontSize * 0.7) { + last.left = Math.min(last.left, r.left); + last.right = Math.max(last.right, r.right); + } else { + lines.push({ y, left: r.left, right: r.right }); + } + } + return lines.map((l) => l.right - l.left); +} + +export function fit(el: HTMLElement) { + if (!el.isConnected) return; + el.style.wordSpacing = ''; + const cs = getComputedStyle(el); + if (cs.textAlign !== 'justify' || cs.display === 'flex' || cs.display === 'inline-flex' || cs.display === 'none') return; + const text = el.textContent ?? ''; + if (text.length < MIN_CHARS) return; + const fontSize = parseFloat(cs.fontSize); + 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; + const s0 = SPACE_EM * fontSize; + + el.style.textAlign = 'start'; + let best = 0, bestScore = Infinity; + 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; + 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; + score += (dev < 0 ? 2.2 * dev : dev) ** 2; + } + if (score < bestScore - 1e-6) { bestScore = score; best = c; } + } + el.style.textAlign = ''; + el.style.wordSpacing = best ? `${best}em` : ''; +} + +function schedule(el: HTMLElement) { + queue.add(el); + if (frame) return; + frame = window.setTimeout(() => { + frame = 0; + const batch = [...queue]; + queue.clear(); + for (const el of batch) fit(el); + }, 150); +} + +function watch(el: HTMLElement) { + if (observed.has(el)) return; + observed.add(el); + resizer.observe(el); +} + +const resizer = new ResizeObserver((entries) => { + for (const e of entries) schedule(e.target as HTMLElement); +}); + +function scan(node: ParentNode) { + if (node instanceof HTMLElement && node.matches(SELECTOR)) watch(node); + node.querySelectorAll(SELECTOR).forEach(watch); +} + +export function startLineFit() { + const root = document.getElementById('root')!; + scan(root); + new MutationObserver((records) => { + for (const r of records) { + r.addedNodes.forEach((n) => { if (n instanceof HTMLElement) scan(n); }); + // متنِ داخلِ پاراگراف عوض شده (مثلاً پاسخِ در حالِ پخش) + const host = (r.target instanceof HTMLElement ? r.target : r.target.parentElement)?.closest(SELECTOR); + if (host) schedule(host); + } + }).observe(root, { childList: true, subtree: true, characterData: true }); + void document.fonts.ready.then(() => document.querySelectorAll(SELECTOR).forEach(schedule)); +} diff --git a/src/main.tsx b/src/main.tsx index 58e7c49..4db3be6 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,8 +3,10 @@ import { createRoot } from 'react-dom/client'; import { App } from './app/App'; import './styles/index.css'; import { unlockAudioOnFirstTouch } from './lib/sound'; +import { startLineFit } from './lib/lineFit'; unlockAudioOnFirstTouch(); +startLineFit(); // گالریِ شکل‌ها فقط در حالتِ توسعه؛ در build اصلاً وارد نمی‌شود (import.meta.env.DEV ثابت است). const DiagramGallery = import.meta.env.DEV