forked from Tadrisino/MathApp_frontend
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 { App } from './app/App';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import { unlockAudioOnFirstTouch } from './lib/sound';
|
import { unlockAudioOnFirstTouch } from './lib/sound';
|
||||||
|
import { startLineFit } from './lib/lineFit';
|
||||||
|
|
||||||
unlockAudioOnFirstTouch();
|
unlockAudioOnFirstTouch();
|
||||||
|
startLineFit();
|
||||||
|
|
||||||
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
// گالریِ شکلها فقط در حالتِ توسعه؛ در build اصلاً وارد نمیشود (import.meta.env.DEV ثابت است).
|
||||||
const DiagramGallery = import.meta.env.DEV
|
const DiagramGallery = import.meta.env.DEV
|
||||||
|
|||||||
Reference in New Issue
Block a user