Files
MathApp_frontend/src/lib/lineFit.ts

129 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* شکستنِ بهترِ خط برای متنِ دوطرف‌چین.
*
* مرورگر خط را حریصانه می‌شکند: کلمه‌ای که یک ذره جا نشود به خطِ بعد می‌رود و خطِ بالا برای
* پر شدن فاصله‌ی بینِ کلمه‌ها را بیش‌ازحد باز می‌کند. چاپِ حرفه‌ای (الگوریتمِ 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 = 14;
// بیشینه‌ی فاصله‌ی مجازِ بینِ کلمه‌ها نسبت به فاصله‌ی معمول، پیش از آن‌که دوطرف‌چین کنار گذاشته شود
const MAX_GAP = 2.2;
const observed = new WeakSet<HTMLElement>();
/** پهنای طبیعیِ هر خط (با ترازِ 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 = '';
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 ?? '';
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 < 2) return;
const s0 = SPACE_EM * fontSize;
el.style.textAlign = 'start';
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, 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; bestWorst = worst; }
}
// حتی بهترین شکستن فاصله‌ای بیش از ۲٫۲ برابرِ معمول می‌سازد (کلمه‌ی بلند که جا نمی‌شود): چاپِ
// حرفه‌ای هم در این حالت دوطرف‌چین نمی‌کند، چون لبه‌ی نامرتب از فاصله‌ی گشاد بهتر است.
if (bestWorst > MAX_GAP) {
el.style.wordSpacing = '';
el.style.textAlign = 'start';
return;
}
el.style.textAlign = '';
el.style.wordSpacing = best ? `${best}em` : '';
}
// همه‌ی اجراها همزمان و پیش از اولین نقاشیِ صفحه‌اند (MutationObserver/ResizeObserver پیش از paint
// صدا زده می‌شوند)، پس متنِ خام و اصلاح‌نشده هرگز دیده نمی‌شود؛ تأخیرِ قبلی (۱۵۰ms) یک چشمک می‌ساخت.
const lastWidth = new WeakMap<HTMLElement, number>();
function watch(el: HTMLElement) {
if (observed.has(el)) return;
observed.add(el);
resizer.observe(el);
fit(el);
}
const resizer = new ResizeObserver((entries) => {
for (const e of entries) {
const el = e.target as HTMLElement;
const width = e.contentRect.width;
// تغییرِ ارتفاعِ خودِ fit نباید دوباره fit را راه بیندازد
if (lastWidth.get(el) === width) continue;
lastWidth.set(el, width);
fit(el);
}
});
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) => {
const changed = new Set<HTMLElement>();
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) changed.add(host);
}
changed.forEach(fit);
}).observe(root, { childList: true, subtree: true, characterData: true });
void document.fonts.ready.then(() => document.querySelectorAll<HTMLElement>(SELECTOR).forEach(fit));
}