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 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 13:31:52 +03:30
parent f214abff3c
commit 22bdb4d39f
2 changed files with 118 additions and 0 deletions

116
src/lib/lineFit.ts Normal file
View File

@@ -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<HTMLElement>();
const queue = new Set<HTMLElement>();
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<HTMLElement>(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<HTMLElement>(SELECTOR);
if (host) schedule(host);
}
}).observe(root, { childList: true, subtree: true, characterData: true });
void document.fonts.ready.then(() => document.querySelectorAll<HTMLElement>(SELECTOR).forEach(schedule));
}

View File

@@ -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