/** * دوزبانه‌سازی خودکار متن مقاله — قاعده: متن لاتین همیشه «۲ پیکسل کوچک‌تر» از متن * فارسیِ همان بلوک است (تیتر۱ ۱۸→۱۶، تیتر۲ ۱۶→۱۴، تیتر۳ و پاراگراف ۱۴→۱۲) و در * مقاله انگلیسی برعکس، واژه‌های فارسی ۲ پیکسل بزرگ‌تر می‌شوند. * * اجرای غیرغالب متن با یک span جهت‌دار جدا می‌شود تا نه جهت متن به‌هم بریزد * (unicode-bidi: isolate) و نه خط پایه جابه‌جا شود؛ اندازه در CSS با * calc(1em - 2px) نسبت به همان بلوک حساب می‌شود. */ /** اجرای متن لاتین — حروف/ارقام لاتین به‌همراه فاصله و نشانه‌های داخلی */ const LATIN_PATTERN = "[A-Za-z0-9\\u00c0-\\u024f]+(?:[ \\t.,:;'\"()&/+%\\u2013\\u2014-]+[A-Za-z0-9\\u00c0-\\u024f]+)*" /** اجرای متن فارسی/عربی — با نیم‌فاصله و ارقام فارسی */ const RTL_PATTERN = '[\\u0590-\\u08ff\\u200c]+(?:[ \\t\\u200c]+[\\u0590-\\u08ff\\u200c]+)*' /** کلاس span متن لاتین و متن فارسی — همین‌ها در CSS اندازه ۲ پیکسل تفاضلی می‌گیرند */ export const LATIN_CLASS = 'latin' export const RTL_CLASS = 'persian' /** عناصری که متنشان دست‌نخورده می‌ماند (کد، مارک‌های قدیمی و اجرای بسته‌بندی‌شده) */ const SKIP_SELECTOR = 'code, pre, kbd, samp, script, style, .english-text, [data-small], .latin, .persian' /** همه اجراهای غیرغالب یک متن — با بازه نسبت به ابتدای همان متن */ export function findScriptRuns(text: string, dir: 'rtl' | 'ltr'): { from: number; to: number }[] { const scan = new RegExp(dir === 'rtl' ? LATIN_PATTERN : RTL_PATTERN, 'g') const runs: { from: number; to: number }[] = [] for (const match of text.matchAll(scan)) { const from = match.index ?? 0 runs.push({ from, to: from + match[0].length }) } return runs } /** * بسته‌بندی اجرای غیرغالب همه گره‌های متنی داخل یک ریشه HTML. * در مقاله فارسی اجرای لاتین و در مقاله انگلیسی اجرای فارسی جدا می‌شود. */ export function wrapScriptRuns(root: HTMLElement, dir: 'rtl' | 'ltr'): void { const document = root.ownerDocument const className = dir === 'rtl' ? LATIN_CLASS : RTL_CLASS const runDir = dir === 'rtl' ? 'ltr' : 'rtl' const texts: Text[] = [] const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) for (let node = walker.nextNode(); node; node = walker.nextNode()) { const text = node as Text if (!text.parentElement || text.parentElement.closest(SKIP_SELECTOR)) continue if (!text.data.trim()) continue if (findScriptRuns(text.data, dir).length) texts.push(text) } for (const text of texts) { const value = text.data const fragment = document.createDocumentFragment() let cursor = 0 for (const run of findScriptRuns(value, dir)) { if (run.from > cursor) fragment.append(value.slice(cursor, run.from)) const span = document.createElement('span') span.className = className span.setAttribute('dir', runDir) span.textContent = value.slice(run.from, run.to) fragment.append(span) cursor = run.to } if (cursor < value.length) fragment.append(value.slice(cursor)) text.replaceWith(fragment) } }