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:
116
src/lib/lineFit.ts
Normal file
116
src/lib/lineFit.ts
Normal 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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user