forked from Tadrisino/MathApp_frontend
129 lines
6.4 KiB
TypeScript
129 lines
6.4 KiB
TypeScript
/**
|
||
* شکستنِ بهترِ خط برای متنِ دوطرفچین.
|
||
*
|
||
* مرورگر خط را حریصانه میشکند: کلمهای که یک ذره جا نشود به خطِ بعد میرود و خطِ بالا برای
|
||
* پر شدن فاصلهی بینِ کلمهها را بیشازحد باز میکند. چاپِ حرفهای (الگوریتمِ 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));
|
||
}
|