From dc840055c2b27035bad4d3bee7f12d8d8d50889f Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Wed, 2 Sep 2026 04:11:35 +0300 Subject: [PATCH 1/6] fix(v2): keep the RTL caret on the boundary WebKit will not measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebKit returns no client rects for a collapsed Range at the end of a text node on a bidi or preserved-whitespace boundary, and an all-zero rect from getBoundingClientRect. The caret resolver falls back to an adjacent character's left-to-right edge, so in a right-to-left paragraph the caret is painted one position behind where typing continues — between every two words, for a Hebrew or Arabic author. Supply the measurement the browser withholds: answer such a collapsed range from the neighbouring character's rect and that character's own direction, following the bidirectional algorithm for numbers, for terminators that join a number, and for neutrals, which take the paragraph's direction rather than the run span's. Confined to text inside a mounted SuperDoc runtime, installed only after the quirk is measured in the live browser, and unable to throw during startup. Fixes #3943 --- .../src/core/v2-integration/v2-integration.js | 7 + .../v2-integration/v2-integration.test.js | 20 +- .../webkit-collapsed-caret-rect.js | 475 ++++++++++++++++ .../webkit-collapsed-caret-rect.test.js | 513 ++++++++++++++++++ 4 files changed, 1014 insertions(+), 1 deletion(-) create mode 100644 packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js create mode 100644 packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js diff --git a/packages/superdoc/src/core/v2-integration/v2-integration.js b/packages/superdoc/src/core/v2-integration/v2-integration.js index b8c70cd7cb..ec371da55c 100644 --- a/packages/superdoc/src/core/v2-integration/v2-integration.js +++ b/packages/superdoc/src/core/v2-integration/v2-integration.js @@ -14,6 +14,7 @@ // constructed (e.g. a non-DOM context); it never selects v1. import { defineComponent, h, onMounted } from 'vue'; +import { installWebKitCollapsedCaretRectFix } from './webkit-collapsed-caret-rect.js'; /** @type {() => Promise} */ let engineModuleLoader = () => import('@superdoc/docx-engine'); /** @type {Promise | null} */ @@ -39,6 +40,12 @@ export function configureDefaultV2IntegrationLoader(loader) { /** Load the engine before Vue evaluates the synchronous integration seam. */ export async function loadDefaultV2Integration() { + // Every distribution mode reaches the engine through this function, so it is + // the one place that guarantees the workaround is installed before the engine + // can paint a caret. The quirk probe measures a throwaway element of its own, + // so no editor document has to exist yet. It never throws: a caret nicety must + // not be able to reject this promise and drop the editor to its stub. + if (typeof window !== 'undefined') installWebKitCollapsedCaretRectFix(window); if (!engineModulePromise) { const loadPromise = Promise.resolve() .then(() => engineModuleLoader()) diff --git a/packages/superdoc/src/core/v2-integration/v2-integration.test.js b/packages/superdoc/src/core/v2-integration/v2-integration.test.js index 8b919de484..dc5ac3ccdb 100644 --- a/packages/superdoc/src/core/v2-integration/v2-integration.test.js +++ b/packages/superdoc/src/core/v2-integration/v2-integration.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vite-plus/test'; +import { describe, it, expect, vi } from 'vite-plus/test'; import { createDefaultV2Integration, loadDefaultV2Integration, @@ -9,6 +9,24 @@ import { isV2SyntheticTrackedChangeRow, } from './v2-integration.js'; +// The WebKit caret-rect workaround is wired in here and nowhere else, so the +// call is what the fix ships on. Mocked so the wiring itself is asserted rather +// than the workaround's own behaviour, which its module tests cover. +vi.mock('./webkit-collapsed-caret-rect.js', () => ({ + installWebKitCollapsedCaretRectFix: vi.fn(() => null), +})); + +describe('WebKit caret-rect workaround wiring', () => { + it('installs the workaround when the engine is loaded', async () => { + const { installWebKitCollapsedCaretRectFix } = await import('./webkit-collapsed-caret-rect.js'); + installWebKitCollapsedCaretRectFix.mockClear(); + + await loadDefaultV2Integration(); + + expect(installWebKitCollapsedCaretRectFix).toHaveBeenCalledWith(window); + }); +}); + // V2 branch: the integration is the single DOCX Engine runtime. There is no // customer-provided integration and no v1 fallback selection. describe('createDefaultV2Integration', () => { diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js new file mode 100644 index 0000000000..000247281a --- /dev/null +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js @@ -0,0 +1,475 @@ +// WebKit browser-bug workaround for the V2 caret. +// +// WebKit returns an EMPTY client-rect list for a **collapsed** `Range` placed at +// the end of a text node, in a range of situations where Chromium and Firefox +// both report the caret correctly. `Range.getBoundingClientRect()` is no better +// in that state: it reports an all-zero rect. A *non-collapsed* range over the +// same character is measured correctly by every engine, WebKit included, which +// is what makes a repair possible at all. +// +// Measured triggers (`dir` x `white-space` x trailing character sweep), all at +// the end-of-text-node boundary: +// - RTL text with preserved whitespace ending in a space or tab, +// - RTL text ending in a digit or a Latin letter, in ANY white-space mode, +// - LTR text ending in a digit, in ANY white-space mode. +// A trailing NBSP is measured correctly everywhere. What these share is a +// boundary the bidi algorithm treats as a level or whitespace edge, so +// installation keys off the *measurement failing*, never off a character class. +// +// The engine's caret layer resolves the caret from that collapsed range and, +// when it comes back empty, falls back to an adjacent character's rect. Its +// fallback picks LTR edges (the *right* edge of the preceding character), so in +// an RTL paragraph the caret is painted at the boundary *before* the trailing +// space — one position behind where typing actually continues. Hebrew and Arabic +// authors hit this between every two words, which is most of the time they are +// typing. See https://github.com/superdoc/docx-editor/issues/3943. (The LTR +// trailing-digit case is invisible only because the LTR edge happens to be the +// right answer there.) +// +// This module restores the missing measurement at its source: it wraps +// `Range.getClientRects` / `Range.getBoundingClientRect` so that a collapsed +// range inside a text node the browser refuses to measure is answered from the +// neighbouring character's rect plus that character's own bidi direction. +// +// Three deliberate choices, because this patches a DOM built-in in a library +// that is embedded in someone else's page: +// - It only ever answers for text inside a mounted SuperDoc runtime. A range +// anywhere else in the host page gets the browser's own result, byte for +// byte, so host code that reads "no rects" as "not rendered" keeps seeing +// exactly what it sees today. +// - It is installed only after the quirk is observed in the live browser, so +// Chromium, Firefox, a fixed future WebKit, and non-layout environments +// (jsdom, SSR) run entirely unpatched. +// - Installation can never throw. A caret nicety must not be able to reject +// the engine-load promise and drop the editor to its fail-closed stub, so +// every step is guarded and failure degrades to "not installed". +// +// Once installed it stays for the page's lifetime rather than being undone on +// `destroy()`: the patch belongs to the realm, not to one editor, and tearing it +// down would regress any other live instance. The returned uninstall exists for +// tests and for a host that wants explicit control. +// +// The durable fix belongs in the engine's caret resolver, which should pick the +// *logical* edge of the neighbouring glyph instead of assuming LTR. This shim +// can be deleted once that ships and the engine floor is raised past it. + +import { RUNTIME_ROOT_ATTRIBUTE } from '../editor-runtime/root-marker.js'; + +/** + * Boundaries to probe for the defect, each `[direction, whiteSpace, text]`. + * + * Detecting only the reported RTL trailing-space case would silently drop the + * workaround for the others the moment WebKit fixes that one boundary alone, so + * every measured trigger family is probed and any single failure installs. + */ +const PROBE_CASES = [ + ['rtl', 'pre', 'שלום '], + ['rtl', 'normal', 'שלום 1'], + ['ltr', 'normal', 'abc 1'], +]; + +/** Marks a patched `Range.prototype` so a second install is a no-op. */ +const INSTALLED_FLAG = '__superdocWebkitCollapsedCaretRectFix'; + +/** Selector for the shell-owned wrapper around a mounted runtime. */ +const RUNTIME_ROOT_SELECTOR = `[${RUNTIME_ROOT_ATTRIBUTE}]`; + +/** + * Windows already measured and found correct, so repeated editor construction + * on Chromium does not re-probe — each probe costs a forced layout and delivers + * mutation records to any host observing `document.body`. A window that could + * not be measured at all is deliberately NOT cached: it may simply have had no + * layout yet, and re-probing costs less than never installing. + * + * @type {WeakSet} + */ +const measuredCleanWindows = new WeakSet(); + +/** + * Blocks whose *letters* are written right-to-left: Hebrew, Arabic and their + * neighbours, the Arabic/Hebrew presentation forms, and the two supplementary + * planes holding the remaining RTL scripts (Phoenician, Kharoshthi, Old + * Hungarian, Adlam, Hanifi Rohingya, Arabic mathematical letters). + * + * Consulted only for letters and marks. These blocks also contain digits and + * punctuation that are NOT laid out right-to-left, so membership on its own + * would misplace the caret after an Arabic-Indic digit. + */ +const RTL_LETTER_BLOCK = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; + +/** Letters and combining marks — the characters that carry a script direction. */ +const LETTER_OR_MARK_CHAR = /[\p{L}\p{M}]/u; + +/** + * Characters laid out as part of a number, and so left-to-right whichever + * direction surrounds them: the Unicode Bidirectional Algorithm raises both + * European and Arabic numbers to an even embedding level (rules I1/I2). Arabic- + * Indic digits are numbers exactly like Latin ones, plus the two Arabic + * separators that sit inside a number without being `\p{N}`. + */ +const NUMBER_ORDERED_CHAR = /[\p{N}\u066B\u066C]/u; + +/** + * Arabic-Indic digits and the separators that belong with them. They are + * numbers, but a terminator next to one does NOT join it: UBA rule W5 attaches + * terminators to *European* numbers only, and W6 then leaves the terminator + * neutral. Extended Arabic-Indic digits are deliberately absent — those are + * European numbers and a terminator does join them. + */ +const ARABIC_NUMBER_CHAR = /[\u0660-\u0669\u066B\u066C\u{10E60}-\u{10E7E}]/u; + +/** + * Terminators that join an adjacent European number's run (UBA rule W5), so + * "50%" and "100" with a currency sign stay one left-to-right run inside an RTL + * paragraph. Separators such as `,` and `.` are deliberately absent: a trailing + * one is not part of the number and takes the paragraph direction. + */ +const NUMBER_TERMINATOR_CHAR = /[\p{Sc}%#\u2030\u2031\u00B0\u2032\u2033\u060A\u066A]/u; + +/** + * First rect with height, read by index so a long list is never copied — this + * runs on every `getClientRects()` call in the page once installed. + * + * A zero-*width* rect is legitimate here: a caret rect has no width. + * + * @param {DOMRectList | DOMRect[] | null | undefined} rects + * @returns {DOMRect | null} + */ +const firstRectWithHeight = (rects) => { + const length = rects?.length ?? 0; + for (let index = 0; index < length; index += 1) { + const rect = rects[index]; + if (rect && rect.height > 0) return rect; + } + return null; +}; + +/** + * First rect that can be a *glyph*, which additionally requires width. + * + * A one-character range whose glyph opens a new line or a new bidi run returns + * two rects in WebKit: a zero-width sentinel parked at the end of the previous + * line, then the glyph itself. Accepting the sentinel would take its edges and + * its `top`, painting the caret on the wrong line. + * + * @param {DOMRectList | DOMRect[] | null | undefined} rects + * @returns {DOMRect | null} + */ +const firstGlyphRect = (rects) => { + const length = rects?.length ?? 0; + for (let index = 0; index < length; index += 1) { + const rect = rects[index]; + if (rect && rect.height > 0 && rect.width > 0) return rect; + } + return null; +}; + +/** + * The whole character at `index`, so a surrogate pair is classified as the + * character it encodes rather than as half of one. + * + * @param {string} text + * @param {number} index + * @returns {string} + */ +function characterAt(text, index) { + const code = text.codePointAt(index); + if (code === undefined) return ''; + const landedInsidePair = code >= 0xdc00 && code <= 0xdfff && index > 0; + return String.fromCodePoint(landedInsidePair ? (text.codePointAt(index - 1) ?? code) : code); +} + +/** + * Whether the character at `index` is laid out right-to-left. + * + * A letter or mark carries its script's direction wherever it sits, which is + * the case geometry alone cannot recover: a lone Latin letter at the end of an + * RTL paragraph is a one-character left-to-right run whose rect sits exactly + * where a continuing right-to-left run's rect would sit. + * + * Numbers are the reason this cannot be a plain block test. Arabic-Indic digits + * live inside the Arabic block but are laid out left-to-right like any other + * number, so an Arabic page number ("صفحة ٥") ends in a left-to-right run. + * + * Everything left is neutral, and a neutral at the end of a line takes the + * paragraph's direction (UBA rule L1) — except a terminator glued to a European + * number, which joins that number's run. + * + * @param {string} text + * @param {number} index + * @param {() => boolean} resolveParagraphIsRtl Paragraph direction, read only for neutrals since it forces a style recalc. + * @returns {boolean} + */ +function characterIsRtl(text, index, resolveParagraphIsRtl) { + const char = characterAt(text, index); + if (NUMBER_ORDERED_CHAR.test(char)) return false; + if (NUMBER_TERMINATOR_CHAR.test(char) && index > 0) { + const before = characterAt(text, index - 1); + if (NUMBER_ORDERED_CHAR.test(before) && !ARABIC_NUMBER_CHAR.test(before)) return false; + } + if (LETTER_OR_MARK_CHAR.test(char)) return RTL_LETTER_BLOCK.test(char); + return resolveParagraphIsRtl(); +} + +/** + * Resolve the caret x for a boundary the browser would not measure, from the + * rect of the character next to it and that character's direction. + * + * A caret sits at the *logical end* of the character before it — the right edge + * of a left-to-right character, the left edge of a right-to-left one — or, at + * the very start of the text, at the logical start of the character after it. + * + * The direction comes from the character rather than from the rects because + * neighbouring rects cannot distinguish a one-character run from a continuing + * run of the opposite direction: they are geometrically identical. It also + * cannot come from the paragraph alone, because an RTL paragraph ending in a + * Latin word or a digit has its last characters laid out left-to-right. Reading + * the character keeps the answer independent of zoom, of sub-pixel rounding, and + * of how the browser rounds adjacent glyph rects. + * + * @param {number} offset Caret offset within the text node. + * @param {string} text The text node's data. + * @param {(index: number) => DOMRect | null} measureCharRect Rect of the single character at `index`. + * @param {() => boolean} resolveParagraphIsRtl Direction of the containing paragraph, for neutral characters. + * @returns {{ x: number, top: number, height: number } | null} + */ +export function resolveCollapsedCaretGeometry(offset, text, measureCharRect, resolveParagraphIsRtl) { + const textLength = text?.length ?? 0; + if (!Number.isInteger(offset) || offset < 0 || offset > textLength) return null; + + const previous = offset > 0 ? measureCharRect(offset - 1) : null; + if (previous) { + const runIsRtl = characterIsRtl(text, offset - 1, resolveParagraphIsRtl); + return { x: runIsRtl ? previous.left : previous.right, top: previous.top, height: previous.height }; + } + + const next = offset < textLength ? measureCharRect(offset) : null; + if (next) { + const runIsRtl = characterIsRtl(text, offset, resolveParagraphIsRtl); + return { x: runIsRtl ? next.right : next.left, top: next.top, height: next.height }; + } + + return null; +} + +/** + * Whether a range is a collapsed caret inside a text node that a mounted + * SuperDoc runtime owns — the only shape this workaround ever answers for. + * + * Everything else in the host page, including SuperDoc's own chrome outside a + * runtime root, keeps the browser's native answer. + * + * @param {Range} range + * @returns {boolean} + */ +const isOwnedCollapsedTextRange = (range) => { + if (!range?.collapsed) return false; + const node = range.startContainer; + if (node?.nodeType !== 3 /* Node.TEXT_NODE */) return false; + const parent = node.parentElement; + return typeof parent?.closest === 'function' && parent.closest(RUNTIME_ROOT_SELECTOR) != null; +}; + +/** + * Build the caret rect for a collapsed text range using only native measurement. + * + * @param {Range} range + * @param {(this: Range) => DOMRectList} nativeGetClientRects Unpatched accessor, so measurement cannot recurse. + * @returns {DOMRect | null} + */ +function synthesizeCollapsedCaretRect(range, nativeGetClientRects) { + const node = /** @type {Text} */ (range.startContainer); + const doc = node.ownerDocument; + const view = doc?.defaultView; + if (!doc || typeof doc.createRange !== 'function' || typeof view?.DOMRect !== 'function') return null; + + const text = node.data ?? ''; + if (text.length === 0) return null; + + const probe = doc.createRange(); + /** @type {Map} */ + const measured = new Map(); + const measureCharRect = (index) => { + if (measured.has(index)) return measured.get(index) ?? null; + let rect = null; + try { + probe.setStart(node, index); + probe.setEnd(node, index + 1); + rect = firstGlyphRect(nativeGetClientRects.call(probe)); + } catch { + rect = null; + } + measured.set(index, rect); + return rect; + }; + + /** @type {boolean | undefined} */ + let paragraphIsRtl; + const resolveParagraphIsRtl = () => { + if (paragraphIsRtl === undefined) { + // Climb past inline wrappers. The painter puts `dir` on individual run + // spans, but a neutral at the end of a line takes the direction of the + // block that contains it, not of the span it happens to sit in. + let element = node.parentElement; + let style = element ? view.getComputedStyle(element) : null; + while (element && (style?.display === 'inline' || style?.display === 'contents')) { + element = element.parentElement; + style = element ? view.getComputedStyle(element) : null; + } + paragraphIsRtl = style ? style.direction === 'rtl' : false; + } + return paragraphIsRtl; + }; + + const geometry = resolveCollapsedCaretGeometry(range.startOffset, text, measureCharRect, resolveParagraphIsRtl); + if (!geometry) return null; + return new view.DOMRect(geometry.x, geometry.top, 0, geometry.height); +} + +/** + * Present a single rect the way callers read a `DOMRectList`: by `length`, by + * index, through `item()`, and by iteration. + * + * Deliberately not an `Array`. The patch is global, so host code that branches + * on the shape of the result should not suddenly see `Array.isArray` pass. + * + * @param {DOMRect} rect + * @returns {DOMRectList} + */ +function toRectList(rect) { + const list = { + length: 1, + 0: rect, + item: (index) => (index === 0 ? rect : null), + [Symbol.iterator]: function* iterate() { + yield rect; + }, + }; + return /** @type {unknown} */ (list); +} + +/** + * Detect the quirk by measuring it, never by sniffing the user agent. + * + * Each probed boundary is paired with a *control* one character earlier, which + * no engine gets wrong. Environments with no layout — jsdom, SSR, a detached + * document — fail every control and are reported as `'unknown'` rather than as + * a quirk. + * + * @param {Document | null | undefined} doc + * @returns {'quirk' | 'clean' | 'unknown'} + */ +export function detectCollapsedCaretRectQuirk(doc) { + let container = null; + try { + const host = doc?.body ?? doc?.documentElement; + if (!doc || !host || typeof doc.createRange !== 'function') return 'unknown'; + + container = doc.createElement('div'); + container.setAttribute('aria-hidden', 'true'); + container.style.cssText = 'position:fixed;top:-9999px;left:-9999px;font:16px sans-serif;'; + + const probes = PROBE_CASES.map(([direction, whiteSpace, text]) => { + const probe = doc.createElement('div'); + probe.style.cssText = `direction:${direction};white-space:${whiteSpace};`; + probe.textContent = text; + container.appendChild(probe); + return { probe, length: text.length }; + }); + host.appendChild(container); + + const range = doc.createRange(); + const boundaryIsMeasurable = (node, offset) => { + range.setStart(node, offset); + range.collapse(true); + return firstRectWithHeight(range.getClientRects()) != null; + }; + + let sawLayout = false; + for (const { probe, length } of probes) { + const node = probe.firstChild; + if (!node || !boundaryIsMeasurable(node, length - 1)) continue; // No layout: cannot tell. + sawLayout = true; + if (!boundaryIsMeasurable(node, length)) return 'quirk'; + } + return sawLayout ? 'clean' : 'unknown'; + } catch { + return 'unknown'; + } finally { + try { + container?.remove(); + } catch { + /* A host that broke `remove()` must not break editor startup. */ + } + } +} + +/** + * Install the workaround on a window, if that window needs it. + * + * Never throws: a frozen `Range.prototype` (SES/Lockdown), an instrumented + * `createElement`/`appendChild`, or a non-HTML document all resolve to "not + * installed" rather than to a rejected engine load. + * + * Safe to call repeatedly: an already-patched realm returns the no-op uninstall, + * and a realm already measured as correct is not probed again. Takes the window + * explicitly so a future iframe-hosted surface can install into its own realm. + * + * @param {(Window & typeof globalThis) | null | undefined} win + * @returns {(() => void) | null} Uninstall function, or `null` when not installed. + */ +export function installWebKitCollapsedCaretRectFix(win) { + try { + const rangePrototype = win?.Range?.prototype; + if (!rangePrototype || typeof rangePrototype.getClientRects !== 'function') return null; + if (rangePrototype[INSTALLED_FLAG]) return () => {}; + if (measuredCleanWindows.has(win)) return null; + + const status = detectCollapsedCaretRectQuirk(win.document); + if (status === 'clean') measuredCleanWindows.add(win); + if (status !== 'quirk') return null; + + const nativeGetClientRects = rangePrototype.getClientRects; + const nativeGetBoundingClientRect = rangePrototype.getBoundingClientRect; + + /** @this {Range} */ + function patchedGetClientRects() { + const native = nativeGetClientRects.call(this); + if (firstRectWithHeight(native)) return native; + if (!isOwnedCollapsedTextRange(this)) return native; + const rect = synthesizeCollapsedCaretRect(this, nativeGetClientRects); + return rect ? toRectList(rect) : native; + } + + /** @this {Range} */ + function patchedGetBoundingClientRect() { + const native = nativeGetBoundingClientRect.call(this); + if (native && native.height > 0) return native; + if (!isOwnedCollapsedTextRange(this)) return native; + return synthesizeCollapsedCaretRect(this, nativeGetClientRects) ?? native; + } + + const restore = () => { + try { + rangePrototype.getClientRects = nativeGetClientRects; + rangePrototype.getBoundingClientRect = nativeGetBoundingClientRect; + delete rangePrototype[INSTALLED_FLAG]; + } catch { + /* Nothing left to do: the realm refuses writes. */ + } + }; + + try { + rangePrototype.getClientRects = patchedGetClientRects; + rangePrototype.getBoundingClientRect = patchedGetBoundingClientRect; + Object.defineProperty(rangePrototype, INSTALLED_FLAG, { value: true, configurable: true }); + } catch { + restore(); + return null; + } + + return restore; + } catch { + return null; + } +} diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js new file mode 100644 index 0000000000..b00f6d0384 --- /dev/null +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js @@ -0,0 +1,513 @@ +import { afterEach, describe, expect, it } from 'vite-plus/test'; + +import { + detectCollapsedCaretRectQuirk, + installWebKitCollapsedCaretRectFix, + resolveCollapsedCaretGeometry, +} from './webkit-collapsed-caret-rect.js'; + +const CHAR_WIDTH = 10; +const LINE_HEIGHT = 16; + +/** @returns {DOMRect} */ +const rect = (left, right, top = 0) => ({ + left, + right, + top, + bottom: top + LINE_HEIGHT, + width: right - left, + height: LINE_HEIGHT, + x: left, + y: top, +}); + +/** Characters painted right-to-left from x=100: logical char 0 is the rightmost. */ +const rtlRun = + (length, rightEdge = 100) => + (index) => + index >= 0 && index < length ? rect(rightEdge - CHAR_WIDTH * (index + 1), rightEdge - CHAR_WIDTH * index) : null; + +/** Characters painted left-to-right from x=0: logical char 0 is the leftmost. */ +const ltrRun = + (length, leftEdge = 0) => + (index) => + index >= 0 && index < length ? rect(leftEdge + CHAR_WIDTH * index, leftEdge + CHAR_WIDTH * (index + 1)) : null; + +const RTL = () => true; +const LTR = () => false; + +const HEBREW = 'שלום'; +const HEBREW_SPACE = `${HEBREW} `; + +/** + * An RTL paragraph whose Hebrew word is followed by a space and then a + * left-to-right tail. The tail is painted to the LEFT of the space, so its rects + * sit exactly where a continuing right-to-left run's rects would sit. + */ +const rtlThenLtrTail = (tail, head = HEBREW_SPACE) => { + const rtlHead = rtlRun(head.length); + const tailStart = 100 - CHAR_WIDTH * (head.length + tail.length); + const ltrTail = ltrRun(tail.length, tailStart); + return { + text: head + tail, + charRect: (index) => (index < head.length ? rtlHead(index) : ltrTail(index - head.length)), + tailStart, + }; +}; + +describe('resolveCollapsedCaretGeometry', () => { + it('places the end-of-text caret at the logical end of an RTL run (issue #3943)', () => { + // "שלום " — the boundary WebKit refuses to measure. The caret belongs at the + // LEFT edge of the trailing space; taking its right edge (the LTR answer) + // paints the caret one character behind, which is the reported bug. + const geometry = resolveCollapsedCaretGeometry(5, HEBREW_SPACE, rtlRun(5), RTL); + expect(geometry?.x).toBe(100 - CHAR_WIDTH * 5); + }); + + it('places the end-of-text caret at the logical end of an LTR run', () => { + expect(resolveCollapsedCaretGeometry(6, 'hello ', ltrRun(6), LTR)?.x).toBe(CHAR_WIDTH * 6); + }); + + it('keeps the caret after a lone digit ending an RTL paragraph', () => { + // "שלום 1" — "עמוד 5" in the wild. The digit is a one-character + // left-to-right run, so the caret belongs at its RIGHT edge. Its rects are + // indistinguishable from a continuing RTL run, so only the character itself + // carries the answer. + const { text, charRect, tailStart } = rtlThenLtrTail('1'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + }); + + it('keeps the caret after a lone Latin letter ending an RTL paragraph', () => { + const { text, charRect, tailStart } = rtlThenLtrTail('A'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + }); + + it('keeps the caret after an Arabic-Indic digit, which is a number and orders LTR', () => { + // "صفحة ٥" — a page number in an Arabic document. The digit lives inside the + // Arabic block but is laid out left-to-right like any other number, so a + // plain block test would put the caret before it. + const { text, charRect, tailStart } = rtlThenLtrTail('٥', 'مرحبا '); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + }); + + it('keeps the caret after an extended Arabic-Indic digit', () => { + const { text, charRect, tailStart } = rtlThenLtrTail('۵', 'صفحه '); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + }); + + it('places the caret at the logical end of a trailing Arabic letter', () => { + const text = 'مرحبا'; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('treats a terminator glued to a number as part of that number (UBA W5)', () => { + // "50%" is one left-to-right run, so the caret belongs after the sign. + const { text, charRect, tailStart } = rtlThenLtrTail('50%'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH * 3); + }); + + it('does not join a terminator to an Arabic-Indic number, which UBA W5 excludes', () => { + // "نسبة ٥٠٪" — the percent sign follows Arabic-Indic digits, so it stays + // neutral and takes the paragraph's direction rather than the number's run. + const text = 'نسبة ٥٠٪'; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('gives a separator after a number the paragraph direction, not the number run', () => { + // A trailing comma is not part of the number, so it stays neutral. + const text = `${HEBREW_SPACE}1,`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('follows the character, not the paragraph, when an RTL paragraph ends in an LTR word', () => { + const { text, charRect, tailStart } = rtlThenLtrTail('Word'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH * 4); + }); + + it('places the caret at the logical end of a trailing Hebrew letter', () => { + const text = `${HEBREW} עולם`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('puts an interior caret at the logical end of the character before it', () => { + expect(resolveCollapsedCaretGeometry(2, HEBREW_SPACE, rtlRun(5), RTL)?.x).toBe(100 - CHAR_WIDTH * 2); + expect(resolveCollapsedCaretGeometry(2, 'hello ', ltrRun(6), LTR)?.x).toBe(CHAR_WIDTH * 2); + }); + + it('places the start-of-text caret at the logical start of the first character', () => { + expect(resolveCollapsedCaretGeometry(0, HEBREW_SPACE, rtlRun(5), RTL)?.x).toBe(100); + expect(resolveCollapsedCaretGeometry(0, 'hello ', ltrRun(6), LTR)?.x).toBe(0); + }); + + it('takes the paragraph direction for a neutral character, which carries none of its own', () => { + expect(resolveCollapsedCaretGeometry(1, ' ', rtlRun(1), RTL)?.x).toBe(100 - CHAR_WIDTH); + expect(resolveCollapsedCaretGeometry(1, ' ', ltrRun(1), LTR)?.x).toBe(CHAR_WIDTH); + }); + + it('does not read the paragraph direction for a character that carries its own', () => { + // Reading it forces a style recalc, and this runs per synthesized caret. + let reads = 0; + const countingDirection = () => { + reads += 1; + return true; + }; + resolveCollapsedCaretGeometry(4, HEBREW_SPACE, rtlRun(5), countingDirection); + expect(reads).toBe(0); + }); + + it('classifies a surrogate pair as one character rather than as half of one', () => { + // An astral RTL letter (Phoenician alf) closing an LTR paragraph. Reading + // only the low surrogate would see no letter at all, fall through to the + // paragraph, and put the caret on the wrong edge. + const text = 'abc𐤀'; + expect(resolveCollapsedCaretGeometry(text.length, text, ltrRun(text.length), LTR)?.x).toBe( + CHAR_WIDTH * (text.length - 1), + ); + }); + + it('carries the glyph vertical metrics onto the caret', () => { + expect(resolveCollapsedCaretGeometry(5, HEBREW_SPACE, rtlRun(5), RTL)).toMatchObject({ + top: 0, + height: LINE_HEIGHT, + }); + }); + + it('returns nothing when no glyph can be measured or the offset is out of range', () => { + expect(resolveCollapsedCaretGeometry(0, '', () => null, RTL)).toBeNull(); + expect(resolveCollapsedCaretGeometry(2, 'ab', () => null, RTL)).toBeNull(); + expect(resolveCollapsedCaretGeometry(3, 'ab', rtlRun(2), RTL)).toBeNull(); + expect(resolveCollapsedCaretGeometry(-1, 'ab', rtlRun(2), RTL)).toBeNull(); + }); +}); + +/** + * A window whose ranges reproduce the WebKit defect: RTL text laid out + * right-to-left, with no client rects for a collapsed range at the end of a text + * node the browser refuses to measure. + * + * @param {{ isBrokenBoundary?: (text: string) => boolean, phantomGlyphRects?: boolean }} [options] + */ +function createFakeWindow({ isBrokenBoundary = (text) => /[ \t]$/.test(text), phantomGlyphRects = false } = {}) { + const bodyChildren = []; + + class FakeDOMRect { + constructor(x, y, width, height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + this.left = x; + this.right = x + width; + this.top = y; + this.bottom = y + height; + } + } + + class FakeRange { + setStart(node, offset) { + this.startContainer = node; + this.startOffset = offset; + this.endContainer = node; + this.endOffset = offset; + this.collapsed = true; + } + setEnd(node, offset) { + this.endContainer = node; + this.endOffset = offset; + this.collapsed = this.startOffset === offset; + } + collapse() { + this.endOffset = this.startOffset; + this.collapsed = true; + } + // The browser's own measurement. Both public methods read it directly, so + // neither can be satisfied by the other one's patch. + measure() { + const text = this.startContainer?.data ?? ''; + if (!this.collapsed) { + const glyph = rtlRun(text.length)(this.startOffset); + if (!glyph) return []; + // WebKit prefixes a zero-width sentinel parked on the previous line when + // a glyph opens a new line or a new bidi run. + return phantomGlyphRects ? [rect(999, 999, 100), glyph] : [glyph]; + } + if (this.startOffset === text.length && isBrokenBoundary(text)) return []; + return [rect(100 - CHAR_WIDTH * this.startOffset, 100 - CHAR_WIDTH * this.startOffset)]; + } + getClientRects() { + return this.measure(); + } + getBoundingClientRect() { + return this.measure()[0] ?? new FakeDOMRect(0, 0, 0, 0); + } + } + + const createTextNode = (data, parentElement) => ({ nodeType: 3, data, parentElement, ownerDocument: null }); + + const createElement = () => { + const element = { + style: { cssText: '' }, + firstChild: null, + setAttribute: () => {}, + appendChild: (child) => child, + remove: () => {}, + set textContent(value) { + this.firstChild = createTextNode(value, this); + this.firstChild.ownerDocument = document; + }, + }; + return element; + }; + + const document = { + createRange: () => new FakeRange(), + createElement, + body: { + appendChild: (element) => { + bodyChildren.push(element); + }, + }, + }; + + const window = { + Range: FakeRange, + DOMRect: FakeDOMRect, + document, + getComputedStyle: (element) => ({ + display: element?.display ?? 'block', + direction: element?.direction ?? 'rtl', + }), + }; + document.defaultView = window; + + /** + * A text node painted inside (or outside) a mounted SuperDoc runtime. + * + * `runDirection` wraps it in an inline run span carrying its own `dir`, which + * is what the painter emits around a numeric or Latin run. + */ + const textNode = (data, { owned = true, runDirection = null, paragraphDirection = 'rtl' } = {}) => { + const runtimeRoot = { tagName: 'DIV' }; + const closest = (selector) => (owned && selector === '[data-superdoc-runtime-id]' ? runtimeRoot : null); + const paragraph = { tagName: 'DIV', display: 'block', direction: paragraphDirection, closest }; + const parentElement = runDirection + ? { tagName: 'SPAN', display: 'inline', direction: runDirection, parentElement: paragraph, closest } + : paragraph; + const node = createTextNode(data, parentElement); + node.ownerDocument = document; + return node; + }; + + const caretAt = (node, offset) => { + const range = new FakeRange(); + range.setStart(node, offset); + range.collapse(); + return range; + }; + + return { window, document, FakeRange, textNode, caretAt, bodyChildren }; +} + +describe('detectCollapsedCaretRectQuirk', () => { + it('reports the quirk when the control boundary measures but the target one does not', () => { + expect(detectCollapsedCaretRectQuirk(createFakeWindow().document)).toBe('quirk'); + }); + + it('reports the quirk from any probed boundary, not only the trailing-space one', () => { + // A WebKit that fixed only RTL trailing whitespace still misplaces the caret + // after a trailing digit, so detection must not hinge on a single case. + const partiallyFixed = createFakeWindow({ isBrokenBoundary: (text) => /\d$/.test(text) }); + expect(detectCollapsedCaretRectQuirk(partiallyFixed.document)).toBe('quirk'); + }); + + it('reports a correct engine as clean', () => { + expect(detectCollapsedCaretRectQuirk(createFakeWindow({ isBrokenBoundary: () => false }).document)).toBe('clean'); + }); + + it('reports an environment without layout as unknown, not as a quirk', () => { + // happy-dom returns no client rects at all, exactly like jsdom and SSR. An + // engine cannot be called quirky for measuring nothing anywhere. + expect(detectCollapsedCaretRectQuirk(document)).toBe('unknown'); + expect(detectCollapsedCaretRectQuirk(null)).toBe('unknown'); + expect(detectCollapsedCaretRectQuirk(undefined)).toBe('unknown'); + }); + + it('reports unknown instead of throwing when the host has broken the DOM it needs', () => { + const { document: doc } = createFakeWindow(); + doc.createElement = () => { + throw new Error('host instrumentation'); + }; + expect(detectCollapsedCaretRectQuirk(doc)).toBe('unknown'); + }); +}); + +describe('installWebKitCollapsedCaretRectFix', () => { + // The suite asserts against the real test-env window; make sure a runner that + // ever reports layout cannot leave this realm patched for other suites. + const nativeGetClientRects = Range.prototype.getClientRects; + const nativeGetBoundingClientRect = Range.prototype.getBoundingClientRect; + afterEach(() => { + Range.prototype.getClientRects = nativeGetClientRects; + Range.prototype.getBoundingClientRect = nativeGetBoundingClientRect; + }); + + it('does not patch a browser that measures the boundary correctly', () => { + expect(installWebKitCollapsedCaretRectFix(window)).toBeNull(); + expect(installWebKitCollapsedCaretRectFix(createFakeWindow({ isBrokenBoundary: () => false }).window)).toBeNull(); + expect(installWebKitCollapsedCaretRectFix(null)).toBeNull(); + expect(installWebKitCollapsedCaretRectFix({})).toBeNull(); + }); + + it('answers the unmeasurable caret with the logical end of the trailing space', () => { + const { window: quirky, textNode, caretAt } = createFakeWindow(); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + expect(uninstall).toBeTypeOf('function'); + + const range = caretAt(textNode(HEBREW_SPACE), 5); + const rects = Array.from(range.getClientRects()); + expect(rects).toHaveLength(1); + // Logical end of the RTL run: the LEFT edge of the trailing space. + expect(rects[0].left).toBe(100 - CHAR_WIDTH * 5); + expect(rects[0].width).toBe(0); + expect(rects[0].height).toBe(LINE_HEIGHT); + expect(range.getBoundingClientRect().left).toBe(100 - CHAR_WIDTH * 5); + + uninstall(); + }); + + it('reads the paragraph direction, not the direction of the inline run span', () => { + // The painter puts `dir` on individual run spans, but a neutral at the end + // of a line takes the direction of the block that contains it. + const { window: quirky, textNode, caretAt } = createFakeWindow(); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + + const inLtrRunSpan = caretAt(textNode(HEBREW_SPACE, { runDirection: 'ltr' }), 5); + expect(Array.from(inLtrRunSpan.getClientRects())[0].left).toBe(100 - CHAR_WIDTH * 5); + + uninstall(); + }); + + it('leaves text outside a mounted SuperDoc runtime to the browser', () => { + // The patch is global, so the host page's own ranges must keep the browser's + // answer byte for byte — including "no rects", which hosts read as + // "not rendered". + const { window: quirky, textNode, caretAt } = createFakeWindow(); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + + const outside = caretAt(textNode(HEBREW_SPACE, { owned: false }), 5); + expect(Array.from(outside.getClientRects())).toHaveLength(0); + expect(outside.getBoundingClientRect().height).toBe(0); + + uninstall(); + }); + + it('ignores the zero-width sentinel WebKit puts in front of a glyph', () => { + const { window: quirky, textNode, caretAt } = createFakeWindow({ phantomGlyphRects: true }); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + + const caret = Array.from(caretAt(textNode(HEBREW_SPACE), 5).getClientRects())[0]; + // Taking the sentinel would put the caret at x=999 on the previous line. + expect(caret.left).toBe(100 - CHAR_WIDTH * 5); + expect(caret.top).toBe(0); + + uninstall(); + }); + + it('passes a boundary the browser does measure straight through', () => { + const { window: quirky, textNode, caretAt } = createFakeWindow(); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + + expect(Array.from(caretAt(textNode(HEBREW_SPACE), 3).getClientRects())[0].left).toBe(100 - CHAR_WIDTH * 3); + uninstall(); + }); + + it('leaves a non-collapsed range and a non-text range alone', () => { + const { window: quirky, document: doc, FakeRange, textNode } = createFakeWindow(); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + + const spanning = new FakeRange(); + spanning.setStart(textNode(HEBREW_SPACE), 0); + spanning.setEnd(spanning.startContainer, 5); + expect(Array.from(spanning.getClientRects())).toHaveLength(1); + + const element = new FakeRange(); + element.setStart({ nodeType: 1, ownerDocument: doc }, 0); + element.collapse(); + expect(Array.from(element.getClientRects())).toHaveLength(1); + + uninstall(); + }); + + it('returns a DOMRectList-like result rather than an Array', () => { + const { window: quirky, textNode, caretAt } = createFakeWindow(); + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + + const rects = caretAt(textNode(HEBREW_SPACE), 5).getClientRects(); + expect(Array.isArray(rects)).toBe(false); + expect(rects.length).toBe(1); + expect(rects[0]).toBeDefined(); + expect(rects.item(0)).toBe(rects[0]); + expect(rects.item(1)).toBeNull(); + expect([...rects]).toHaveLength(1); + expect(Array.from(rects)).toHaveLength(1); + + uninstall(); + }); + + it('installs once and restores the native methods on uninstall', () => { + const { window: quirky, FakeRange } = createFakeWindow(); + const native = FakeRange.prototype.getClientRects; + + const uninstall = installWebKitCollapsedCaretRectFix(quirky); + expect(FakeRange.prototype.getClientRects).not.toBe(native); + + const patched = FakeRange.prototype.getClientRects; + const second = installWebKitCollapsedCaretRectFix(quirky); + expect(FakeRange.prototype.getClientRects).toBe(patched); + second?.(); + + uninstall?.(); + expect(FakeRange.prototype.getClientRects).toBe(native); + }); + + it('probes a correct engine only once, however many editors are constructed', () => { + // The probe forces a layout and delivers mutation records to any host + // observing document.body, so it must not repeat per editor. + const { window: clean, bodyChildren } = createFakeWindow({ isBrokenBoundary: () => false }); + installWebKitCollapsedCaretRectFix(clean); + installWebKitCollapsedCaretRectFix(clean); + installWebKitCollapsedCaretRectFix(clean); + expect(bodyChildren).toHaveLength(1); + }); + + it('declines instead of throwing when the realm refuses the patch', () => { + // SES/Lockdown and similar hardened embeds freeze built-in prototypes. A + // caret workaround must never reject the engine-load promise, which would + // drop the editor to its fail-closed stub. + const { window: quirky, FakeRange } = createFakeWindow(); + const native = FakeRange.prototype.getClientRects; + Object.freeze(FakeRange.prototype); + + expect(installWebKitCollapsedCaretRectFix(quirky)).toBeNull(); + expect(FakeRange.prototype.getClientRects).toBe(native); + }); + + it('declines instead of throwing when the host has instrumented the DOM', () => { + const { window: quirky, document: doc, FakeRange } = createFakeWindow(); + const native = FakeRange.prototype.getClientRects; + doc.body.appendChild = () => { + throw new Error('host instrumentation'); + }; + + expect(installWebKitCollapsedCaretRectFix(quirky)).toBeNull(); + expect(FakeRange.prototype.getClientRects).toBe(native); + }); +}); From 3991f7dbf4cae443a54c38d8ef4bea85b2f04b76 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Wed, 2 Sep 2026 18:13:02 +0300 Subject: [PATCH 2/6] fix(v2): resolve the caret edge from the character's real bidi class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The review found three places where the direction rule approximated the Unicode Bidirectional Algorithm by general category or code-point block instead of reading it. Measured against Chromium's own caret for the same boundary, in a browser that implements the algorithm: - `\p{N}` stood in for "orders left-to-right", which NKo and Adlam digits are not (Bidi_Class R), and `½` and `①` are not either (neutral). Five cases, 8.6px to 16px out. - A combining mark was classified by its own block rather than inheriting from the character it sits on. `שלום ❤️` was 22px out, and WebKit refuses that boundary, so it was live. Note that the example given in review — an Arabic or Hebrew letter followed by its own diacritic — was already correct: those marks sit inside ֐-ࣿ, so the block test agreed with W1 by construction. - Astral characters were classified as whole code points but measured as one UTF-16 code unit. This one does not reproduce: both engines widen a range that splits a surrogate pair, and the one-unit rect came back identical to the whole-code-point rect in each. The range now spans the pair anyway, since the DOM counts offsets in code units and is not obliged to widen. Hardening, not a bug fix. Following the same thread found more of the same kind, all live and all measured: rule N0 was missing entirely, so `שלום abc(def)` — a Latin parenthetical inside Hebrew — was 5.3px out and the full-width and CJK pairs 16px; Hebrew and Arabic punctuation is strongly right-to-left without being a letter; the Arabic comma and 45 other code points sit inside a right-to-left block without being right-to-left; private use, Roman numerals and Indic spacing marks are left-to-right without being letters; and rule W2 makes a European number Arabic after an Arabic letter, so `مرحبا 50%` ends right-to-left where `שלום 50%` does not. The classes are now derived mechanically from DerivedBidiClass.txt and BidiBrackets.txt (Unicode 17.0.0), and the rule applies W1, W2, W5, I1/I2, N0, N1/N2 and L1 in order. Separately, a zero-width character before the caret has no glyph box to read, and WebKit refuses the boundary after a trailing space followed by a zero-width space, an RLM or an LRM — so the workaround was reached, found nothing to measure, and left the caret where the bug put it. It now looks past neighbours with no box, bounded because each step forces a layout. Patching a DOM built-in inside an embedded library also needed its own pass: - The patched methods had no exception guard. `getClientRects` is specified never to throw for a valid range, and a host that has instrumented `closest` or `getComputedStyle` could turn every Range on the page into a throwing API. - `closest()` does not cross a shadow boundary, so text inside a shadow root under the runtime root was not recognised and the bug survived there. Ownership now climbs out through shadow hosts. - The installed mark sat on the prototype, so a host that replaced `getClientRects` rather than wrapping it silently undid the workaround for the life of the page. It sits on the function now. - `length` and `item` were enumerable own properties, where a real DOMRectList has neither. - A window that can never be measured was re-probed on every editor construction, each probe forcing a layout and delivering two childList records to any host observer. Now bounded. Measured: 35/35 on the general boundary set and 21/21 on the bracket set against Chromium, up from 25/35 and 14/21; and 8528 caret positions driven through the engine's own resolution in Chromium and WebKit — 58 cases at eight zoom levels — with all 45 boundaries WebKit refuses repaired at every zoom and nothing the browser already measured moved. Refs #3943 --- .../webkit-collapsed-caret-rect.js | 557 +++++++++++++++--- .../webkit-collapsed-caret-rect.test.js | 299 +++++++++- 2 files changed, 765 insertions(+), 91 deletions(-) diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js index 000247281a..bc0c7fe342 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js @@ -68,9 +68,24 @@ const PROBE_CASES = [ ['ltr', 'normal', 'abc 1'], ]; -/** Marks a patched `Range.prototype` so a second install is a no-op. */ +/** Marks this module's own patched methods, so a second install is a no-op. */ const INSTALLED_FLAG = '__superdocWebkitCollapsedCaretRectFix'; +/** + * How many times a window that cannot be measured at all is re-probed before the + * workaround gives up on it. + * + * A window is deliberately not cached as clean while it answers "unknown": it + * may simply have had no layout yet, and re-probing costs less than never + * installing. But each probe forces a layout and delivers two childList records + * to any host observing `document.body`, so a page that constructs many editors + * in an environment without layout should not pay it indefinitely. + */ +const MAX_UNKNOWN_PROBES = 4; + +/** @type {WeakMap} Unmeasurable probes spent per window. */ +const probeCounts = new WeakMap(); + /** Selector for the shell-owned wrapper around a mounted runtime. */ const RUNTIME_ROOT_SELECTOR = `[${RUNTIME_ROOT_ATTRIBUTE}]`; @@ -85,47 +100,6 @@ const RUNTIME_ROOT_SELECTOR = `[${RUNTIME_ROOT_ATTRIBUTE}]`; */ const measuredCleanWindows = new WeakSet(); -/** - * Blocks whose *letters* are written right-to-left: Hebrew, Arabic and their - * neighbours, the Arabic/Hebrew presentation forms, and the two supplementary - * planes holding the remaining RTL scripts (Phoenician, Kharoshthi, Old - * Hungarian, Adlam, Hanifi Rohingya, Arabic mathematical letters). - * - * Consulted only for letters and marks. These blocks also contain digits and - * punctuation that are NOT laid out right-to-left, so membership on its own - * would misplace the caret after an Arabic-Indic digit. - */ -const RTL_LETTER_BLOCK = /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; - -/** Letters and combining marks — the characters that carry a script direction. */ -const LETTER_OR_MARK_CHAR = /[\p{L}\p{M}]/u; - -/** - * Characters laid out as part of a number, and so left-to-right whichever - * direction surrounds them: the Unicode Bidirectional Algorithm raises both - * European and Arabic numbers to an even embedding level (rules I1/I2). Arabic- - * Indic digits are numbers exactly like Latin ones, plus the two Arabic - * separators that sit inside a number without being `\p{N}`. - */ -const NUMBER_ORDERED_CHAR = /[\p{N}\u066B\u066C]/u; - -/** - * Arabic-Indic digits and the separators that belong with them. They are - * numbers, but a terminator next to one does NOT join it: UBA rule W5 attaches - * terminators to *European* numbers only, and W6 then leaves the terminator - * neutral. Extended Arabic-Indic digits are deliberately absent — those are - * European numbers and a terminator does join them. - */ -const ARABIC_NUMBER_CHAR = /[\u0660-\u0669\u066B\u066C\u{10E60}-\u{10E7E}]/u; - -/** - * Terminators that join an adjacent European number's run (UBA rule W5), so - * "50%" and "100" with a currency sign stay one left-to-right run inside an RTL - * paragraph. Separators such as `,` and `.` are deliberately absent: a trailing - * one is not part of the number and takes the paragraph direction. - */ -const NUMBER_TERMINATOR_CHAR = /[\p{Sc}%#\u2030\u2031\u00B0\u2032\u2033\u060A\u066A]/u; - /** * First rect with height, read by index so a long list is never copied — this * runs on every `getClientRects()` call in the page once installed. @@ -164,6 +138,99 @@ const firstGlyphRect = (rects) => { return null; }; +/** + * Character sets for the Unicode Bidirectional Algorithm classes this module + * has to tell apart. Each is mechanically derived from `DerivedBidiClass.txt` + * (Unicode 17.0.0) rather than approximated by a general category, because the + * two disagree in exactly the places that matter here: Arabic-Indic digits are + * numbers inside a right-to-left block, NKo and Adlam digits are right-to-left + * despite being digits, and `½` is a number that is not ordered as one. + * + * `֐-ࣿ` and the other block ranges are a superset of Bidi_Class R and AL, + * narrowed by RTL_BLOCK_NEUTRAL below. Every character they cover that is not + * R or AL is either resolved before the block is consulted (marks, numbers, + * terminators) or listed there; that has been checked against the whole of + * Unicode, so the pair is exact. + */ +const RTL_SCRIPT_BLOCK = /[\u0590-\u08FF\u200F\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; + +/** + * The 46 assigned code points inside those blocks that are NOT Bidi_Class R or + * AL: the Arabic comma, the ornate parentheses, the Arabic ligature symbols, + * the NKo punctuation, and a handful of others. They are neutral, so they take + * the paragraph's direction like any other neutral. + */ +const RTL_BLOCK_NEUTRAL = + /[\u0606-\u0607\u060C\u060E-\u060F\u06DE\u06E9\u07F6-\u07F9\uFB29\uFD3E-\uFD4F\uFDCF\uFDFD-\uFDFF\uFEFF\u{1091F}\u{10B39}-\u{10B3F}\u{10D6E}\u{1EEF0}-\u{1EEF1}]/u; + +/** Bidi_Class EN — European numbers, ordered left-to-right at any embedding level. */ +const EUROPEAN_NUMBER_CHAR = + /[\u0030-\u0039\u00B2-\u00B3\u00B9\u06F0-\u06F9\u2070\u2074-\u2079\u2080-\u2089\u2488-\u249B\uFF10-\uFF19\u{102E1}-\u{102FB}\u{1CCF0}-\u{1CCF9}\u{1D7CE}-\u{1D7FF}\u{1F100}-\u{1F10A}\u{1FBF0}-\u{1FBF9}]/u; + +/** Bidi_Class AN — Arabic numbers, also ordered left-to-right (rules I1/I2). */ +const ARABIC_NUMBER_CHAR = + /[\u0600-\u0605\u0660-\u0669\u066B-\u066C\u06DD\u0890-\u0891\u08E2\u{10D30}-\u{10D39}\u{10D40}-\u{10D49}\u{10E60}-\u{10E7E}]/u; + +/** Bidi_Class ET — terminators that a neighbouring European number absorbs (rule W5). */ +const NUMBER_TERMINATOR_CHAR = + /[\u0023-\u0025\u00A2-\u00A5\u00B0-\u00B1\u058F\u0609-\u060A\u066A\u09F2-\u09F3\u09FB\u0AF1\u0BF9\u0E3F\u17DB\u2030-\u2034\u20A0-\u20C1\u212E\u2213\uA838-\uA839\uFE5F\uFE69-\uFE6A\uFF03-\uFF05\uFFE0-\uFFE1\uFFE5-\uFFE6\u{11FDD}-\u{11FE0}\u{1E2FF}]/u; + +/** Bidi_Class AL — Arabic letters, which turn a following European number Arabic (rule W2). */ +const ARABIC_LETTER_CHAR = + /[\u0608-\u060B\u060D\u061B-\u06D5\u06E5-\u06E6\u06EE-\u07B1\u0860-\u08C9\uFB50-\uFD3D\uFD50-\uFDC7\uFDF0-\uFDFC\uFE70-\uFEFC\u{10D00}-\u{10D23}\u{10EC2}-\u{10EC7}\u{10F30}-\u{10F59}\u{1EC71}-\u{1EEBB}]/u; + +/** + * Bidi_Class NSM — non-spacing marks, which take the class of the character + * before them (rule W1). The Unicode Character Database defines NSM as exactly + * the characters of general category Mn or Me, so this needs no table. + */ +const MARK_CHAR = /[\p{Mn}\p{Me}]/u; + +/** + * Bidi_Class L, for what is left once the classes above are resolved: letters, + * letter numbers, spacing marks, and private use. + * + * Private use earns its place: a .docx symbol run (Wingdings, Symbol) maps to + * U+F0xx, the Unicode default for private use is left-to-right, and Chromium + * lays it out that way. What remains misread as neutral is punctuation and + * symbols belonging to left-to-right scripts — 1.3% of assigned code points, + * and visible only inside a right-to-left paragraph. + */ +const STRONG_LTR_CHAR = /[\p{L}\p{Nl}\p{Mc}\p{Co}]/u; + +const HIGH_SURROGATE_START = 0xd800; +const HIGH_SURROGATE_END = 0xdbff; +const LOW_SURROGATE_START = 0xdc00; +const LOW_SURROGATE_END = 0xdfff; + +/** + * Index of the first UTF-16 unit of the code point covering `index`, so an + * offset that lands on a low surrogate refers to the whole pair. + * + * @param {string} text + * @param {number} index + * @returns {number} + */ +function codePointStart(text, index) { + const unit = text.charCodeAt(index); + if (!(unit >= LOW_SURROGATE_START && unit <= LOW_SURROGATE_END) || index <= 0) return index; + const before = text.charCodeAt(index - 1); + return before >= HIGH_SURROGATE_START && before <= HIGH_SURROGATE_END ? index - 1 : index; +} + +/** + * Index just past the code point covering `index`. + * + * @param {string} text + * @param {number} index + * @returns {number} + */ +function codePointEnd(text, index) { + const start = codePointStart(text, index); + const code = text.codePointAt(start); + return start + (code !== undefined && code > 0xffff ? 2 : 1); +} + /** * The whole character at `index`, so a surrogate pair is classified as the * character it encodes rather than as half of one. @@ -173,42 +240,306 @@ const firstGlyphRect = (rects) => { * @returns {string} */ function characterAt(text, index) { - const code = text.codePointAt(index); - if (code === undefined) return ''; - const landedInsidePair = code >= 0xdc00 && code <= 0xdfff && index > 0; - return String.fromCodePoint(landedInsidePair ? (text.codePointAt(index - 1) ?? code) : code); + const code = text.codePointAt(codePointStart(text, index)); + return code === undefined ? '' : String.fromCodePoint(code); } +const CLASS_RTL = 1; +const CLASS_LTR = 2; +const CLASS_NUMBER = 3; +const CLASS_TERMINATOR = 4; +const CLASS_NEUTRAL = 5; +const CLASS_MARK = 6; + /** - * Whether the character at `index` is laid out right-to-left. + * Coarse Bidi_Class of one character: the six groups this module has to + * distinguish, in the order the algorithm resolves them. * - * A letter or mark carries its script's direction wherever it sits, which is - * the case geometry alone cannot recover: a lone Latin letter at the end of an - * RTL paragraph is a one-character left-to-right run whose rect sits exactly - * where a continuing right-to-left run's rect would sit. + * @param {string} char + * @returns {number} + */ +function classOf(char) { + if (MARK_CHAR.test(char)) return CLASS_MARK; + if (EUROPEAN_NUMBER_CHAR.test(char) || ARABIC_NUMBER_CHAR.test(char)) return CLASS_NUMBER; + if (NUMBER_TERMINATOR_CHAR.test(char)) return CLASS_TERMINATOR; + if (RTL_SCRIPT_BLOCK.test(char)) return RTL_BLOCK_NEUTRAL.test(char) ? CLASS_NEUTRAL : CLASS_RTL; + if (STRONG_LTR_CHAR.test(char)) return CLASS_LTR; + return CLASS_NEUTRAL; +} + +/** + * Rule W2: a European number is re-read as an Arabic number when the nearest + * strong character before it is an Arabic letter. Only European numbers absorb + * a terminator, so this is what makes "%" part of the number in Hebrew + * ("שלום 50%") but not in Arabic ("مرحبا 50%"), which both engines confirm. * - * Numbers are the reason this cannot be a plain block test. Arabic-Indic digits - * live inside the Arabic block but are laid out left-to-right like any other - * number, so an Arabic page number ("صفحة ٥") ends in a left-to-right run. + * @param {string} text + * @param {number} index Index of the European number. + * @returns {boolean} + */ +function europeanNumberKeepsEuropeanRun(text, index) { + for (let at = index; at > 0;) { + at = codePointStart(text, at - 1); + const char = characterAt(text, at); + const charClass = classOf(char); + if (charClass === CLASS_RTL) return !ARABIC_LETTER_CHAR.test(char); + if (charClass === CLASS_LTR) return true; + } + return true; +} + +/** + * Rule W5: a run of terminators touching a European number joins that number, + * on either side, so both "$50" and "50%" stay one left-to-right run. * - * Everything left is neutral, and a neutral at the end of a line takes the - * paragraph's direction (UBA rule L1) — except a terminator glued to a European - * number, which joins that number's run. + * @param {string} text + * @param {number} index Index of the terminator. + * @returns {boolean} + */ +function terminatorTouchesEuropeanNumber(text, index) { + for (const step of [-1, 1]) { + for (let at = index; ;) { + if (step < 0) { + if (at === 0) break; + at = codePointStart(text, at - 1); + } else { + at = codePointEnd(text, at); + if (at >= text.length) break; + } + const char = characterAt(text, at); + const charClass = classOf(char); + if (charClass === CLASS_TERMINATOR || charClass === CLASS_MARK) continue; + if (charClass !== CLASS_NUMBER || !EUROPEAN_NUMBER_CHAR.test(char)) break; + if (europeanNumberKeepsEuropeanRun(text, at)) return true; + break; + } + } + return false; +} + +/** + * Direction of the nearest strong character on one side of a neutral, with the + * paragraph direction standing in past either end of the text (sor / eor). + * Rule N1 has numbers influence a neighbouring neutral as though they were + * right-to-left, which is why CLASS_NUMBER answers `true` here even though a + * number is itself ordered left-to-right. * * @param {string} text * @param {number} index - * @param {() => boolean} resolveParagraphIsRtl Paragraph direction, read only for neutrals since it forces a style recalc. + * @param {number} step -1 to look back, 1 to look forward. + * @param {boolean} paragraphIsRtl + * @returns {boolean} + */ +function strongSideIsRtl(text, index, step, paragraphIsRtl) { + for (let at = index; ;) { + if (step < 0) { + if (at === 0) return paragraphIsRtl; + at = codePointStart(text, at - 1); + } else { + at = codePointEnd(text, at); + if (at >= text.length) return paragraphIsRtl; + } + const charClass = classOf(characterAt(text, at)); + if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) return true; + if (charClass === CLASS_LTR) return false; + } +} + +/** + * The 64 bracket pairs of `BidiBrackets.txt` (Unicode 17.0.0), index-aligned: + * the closing bracket for `BRACKET_OPENINGS[i]` is `BRACKET_CLOSINGS[i]`. + */ +const BRACKET_OPENINGS = + '\u0028\u005B\u007B\u0F3A\u0F3C\u169B\u2045\u207D\u208D\u2308\u230A\u2329\u2768\u276A\u276C\u276E\u2770\u2772\u2774\u27C5\u27E6\u27E8\u27EA\u27EC\u27EE\u2983\u2985\u2987\u2989\u298B\u298D\u298F\u2991\u2993\u2995\u2997\u29D8\u29DA\u29FC\u2E22\u2E24\u2E26\u2E28\u2E55\u2E57\u2E59\u2E5B\u3008\u300A\u300C\u300E\u3010\u3014\u3016\u3018\u301A\uFE59\uFE5B\uFE5D\uFF08\uFF3B\uFF5B\uFF5F\uFF62'; +const BRACKET_CLOSINGS = + '\u0029\u005D\u007D\u0F3B\u0F3D\u169C\u2046\u207E\u208E\u2309\u230B\u232A\u2769\u276B\u276D\u276F\u2771\u2773\u2775\u27C6\u27E7\u27E9\u27EB\u27ED\u27EF\u2984\u2986\u2988\u298A\u298C\u2990\u298E\u2992\u2994\u2996\u2998\u29D9\u29DB\u29FD\u2E23\u2E25\u2E27\u2E29\u2E56\u2E58\u2E5A\u2E5C\u3009\u300B\u300D\u300F\u3011\u3015\u3017\u3019\u301B\uFE5A\uFE5C\uFE5E\uFF09\uFF3D\uFF5D\uFF60\uFF63'; + +/** BD16 caps its stack at 63 pairs and stops looking for pairs beyond that. */ +const MAX_BRACKET_PAIRS = 63; + +/** + * U+2329 and U+232A are canonically equivalent to U+3008 and U+3009, and BD16 + * matches brackets across that equivalence. They are the only two in the table. + * + * @param {string} char + * @returns {string} + */ +function canonicalBracket(char) { + if (char === '\u2329') return '\u3008'; + if (char === '\u232A') return '\u3009'; + return char; +} + +/** + * BD16: the bracket pair that `index` belongs to, or null when it is in none. + * + * @param {string} text + * @param {number} index + * @returns {{ open: number, close: number } | null} + */ +function bracketPairAt(text, index) { + /** @type {{ closing: string, at: number }[]} */ + const stack = []; + for (let at = 0; at < text.length; at = codePointEnd(text, at)) { + const char = characterAt(text, at); + // Only a bracket that is still neutral takes part; one that a preceding rule + // already resolved is not a bracket for N0's purposes. + if (classOf(char) !== CLASS_NEUTRAL) continue; + const canonical = canonicalBracket(char); + const opening = BRACKET_OPENINGS.indexOf(canonical); + if (opening >= 0) { + if (stack.length >= MAX_BRACKET_PAIRS) return null; + stack.push({ closing: BRACKET_CLOSINGS[opening], at }); + continue; + } + if (BRACKET_CLOSINGS.indexOf(canonical) < 0) continue; + for (let depth = stack.length - 1; depth >= 0; depth -= 1) { + if (stack[depth].closing !== canonical) continue; + const pair = { open: stack[depth].at, close: at }; + if (pair.open === index || pair.close === index) return pair; + stack.length = depth; + break; + } + } + return null; +} + +/** + * N0: a bracket pair takes the direction of the strong text it encloses. + * + * "שלום abc(def)" is the case that matters — a Latin parenthetical inside Hebrew, + * which Hebrew technical and legal writing is full of. The brackets enclose + * left-to-right text and follow left-to-right text, so they join it; without this + * they would be neutrals taking the paragraph's direction, and the caret after + * the closing bracket would sit on its other edge. + * + * Returns null when the rule does not apply — an unpaired bracket, or a pair + * enclosing nothing strong — leaving the character neutral for N1/N2. + * + * @param {string} text + * @param {number} index + * @param {boolean} paragraphIsRtl + * @returns {boolean | null} + */ +function bracketPairIsRtl(text, index, paragraphIsRtl) { + const pair = bracketPairAt(text, index); + if (!pair) return null; + + let enclosesParagraphDirection = false; + let enclosesOppositeDirection = false; + for (let at = codePointEnd(text, pair.open); at < pair.close; at = codePointEnd(text, at)) { + const charClass = classOf(characterAt(text, at)); + // N0 counts numbers as right-to-left, exactly as N1 does. + const isRtl = charClass === CLASS_RTL || charClass === CLASS_NUMBER; + if (!isRtl && charClass !== CLASS_LTR) continue; + if (isRtl === paragraphIsRtl) enclosesParagraphDirection = true; + else enclosesOppositeDirection = true; + } + + if (enclosesParagraphDirection) return paragraphIsRtl; + if (!enclosesOppositeDirection) return null; + // The pair runs against the paragraph, so the text before it decides whether + // the brackets join that run or fall back to the paragraph. + const opposite = !paragraphIsRtl; + return strongSideIsRtl(text, pair.open, -1, paragraphIsRtl) === opposite ? opposite : paragraphIsRtl; +} + +/** + * Whether the character at `index` is laid out right-to-left, following the + * Unicode Bidirectional Algorithm. + * + * Direction has to come from the character because neighbouring rects cannot + * distinguish a one-character run from a continuing run of the opposite + * direction — they are geometrically identical — and it cannot come from the + * paragraph alone, because a right-to-left paragraph ending in a Latin word or + * a number has its last characters laid out left-to-right. Reading the + * character keeps the answer independent of zoom, of sub-pixel rounding, and of + * how the browser rounds adjacent glyph rects. + * + * The rules applied, in order: W1 (a mark inherits from the character before + * it), I1/I2 (numbers are raised to an even, left-to-right level at both + * paragraph directions), W2 and W5 (a terminator joins an adjacent European + * number), N0 for a paired bracket, then N1/N2 and L1 for anything neutral, + * which at the end of the text is always the paragraph's own direction. + * + * @param {string} text + * @param {number} index + * @param {() => boolean} resolveParagraphIsRtl Paragraph direction, read only when needed since it forces a style recalc. * @returns {boolean} */ function characterIsRtl(text, index, resolveParagraphIsRtl) { - const char = characterAt(text, index); - if (NUMBER_ORDERED_CHAR.test(char)) return false; - if (NUMBER_TERMINATOR_CHAR.test(char) && index > 0) { - const before = characterAt(text, index - 1); - if (NUMBER_ORDERED_CHAR.test(before) && !ARABIC_NUMBER_CHAR.test(before)) return false; + let at = codePointStart(text, index); + let char = characterAt(text, at); + + // W1: a non-spacing mark takes the class of the character before it, and the + // paragraph direction when there is none. + while (classOf(char) === CLASS_MARK) { + if (at === 0) return resolveParagraphIsRtl(); + at = codePointStart(text, at - 1); + char = characterAt(text, at); + } + + const charClass = classOf(char); + if (charClass === CLASS_RTL) return true; + if (charClass === CLASS_LTR) return false; + if (charClass === CLASS_NUMBER) return false; + if (charClass === CLASS_TERMINATOR && terminatorTouchesEuropeanNumber(text, at)) return false; + + const paragraphIsRtl = resolveParagraphIsRtl(); + + // N0: a paired bracket resolves from what its pair encloses, before the + // general neutral rules see it. + const canonical = canonicalBracket(char); + if (BRACKET_OPENINGS.indexOf(canonical) >= 0 || BRACKET_CLOSINGS.indexOf(canonical) >= 0) { + const paired = bracketPairIsRtl(text, at, paragraphIsRtl); + if (paired !== null) return paired; + } + + const before = strongSideIsRtl(text, at, -1, paragraphIsRtl); + const after = strongSideIsRtl(text, at, 1, paragraphIsRtl); + return before === after ? before : paragraphIsRtl; +} + +/** + * How far to look past characters that have no glyph box — a zero-width space, a + * bidi mark, a joiner, a soft hyphen — for the neighbour whose edge the caret + * sits on. + * + * WebKit refuses the caret after "שלום " followed by any of those, and the + * character immediately before it then has nothing to measure, so without this + * the repair would decline and leave the caret where the bug put it. Chromium + * places it at the logical end of the space, which is the first neighbour that + * does have a box. + * + * Bounded because every step is a forced layout, and a text node made only of + * format controls would otherwise turn one caret into thousands of them. Past + * the bound the workaround declines, which is the behaviour it has for every + * boundary it cannot measure. + */ +const MAX_INVISIBLE_NEIGHBOURS = 16; + +/** + * The nearest character on one side of `offset` that has a glyph box, together + * with the index it was found at. + * + * Both neighbours are addressed by the first UTF-16 unit of their code point, so + * a caller measuring the character never receives half a surrogate pair. + * + * @param {number} offset Caret offset within the text node. + * @param {string} text The text node's data. + * @param {(index: number) => DOMRect | null} measureCharRect + * @param {number} step -1 to look back from the caret, 1 to look forward. + * @returns {{ index: number, rect: DOMRect } | null} + */ +function nearestMeasuredCharacter(offset, text, measureCharRect, step) { + let index = step < 0 ? (offset > 0 ? codePointStart(text, offset - 1) : -1) : codePointStart(text, offset); + for (let steps = 0; steps < MAX_INVISIBLE_NEIGHBOURS; steps += 1) { + if (index < 0 || index >= text.length) return null; + const rect = measureCharRect(index); + if (rect) return { index, rect }; + index = step < 0 ? (index > 0 ? codePointStart(text, index - 1) : -1) : codePointEnd(text, index); } - if (LETTER_OR_MARK_CHAR.test(char)) return RTL_LETTER_BLOCK.test(char); - return resolveParagraphIsRtl(); + return null; } /** @@ -229,7 +560,7 @@ function characterIsRtl(text, index, resolveParagraphIsRtl) { * * @param {number} offset Caret offset within the text node. * @param {string} text The text node's data. - * @param {(index: number) => DOMRect | null} measureCharRect Rect of the single character at `index`. + * @param {(index: number) => DOMRect | null} measureCharRect Rect of the whole code point starting at `index`. * @param {() => boolean} resolveParagraphIsRtl Direction of the containing paragraph, for neutral characters. * @returns {{ x: number, top: number, height: number } | null} */ @@ -237,16 +568,18 @@ export function resolveCollapsedCaretGeometry(offset, text, measureCharRect, res const textLength = text?.length ?? 0; if (!Number.isInteger(offset) || offset < 0 || offset > textLength) return null; - const previous = offset > 0 ? measureCharRect(offset - 1) : null; + const previous = nearestMeasuredCharacter(offset, text, measureCharRect, -1); if (previous) { - const runIsRtl = characterIsRtl(text, offset - 1, resolveParagraphIsRtl); - return { x: runIsRtl ? previous.left : previous.right, top: previous.top, height: previous.height }; + const runIsRtl = characterIsRtl(text, previous.index, resolveParagraphIsRtl); + const rect = previous.rect; + return { x: runIsRtl ? rect.left : rect.right, top: rect.top, height: rect.height }; } - const next = offset < textLength ? measureCharRect(offset) : null; + const next = nearestMeasuredCharacter(offset, text, measureCharRect, 1); if (next) { - const runIsRtl = characterIsRtl(text, offset, resolveParagraphIsRtl); - return { x: runIsRtl ? next.right : next.left, top: next.top, height: next.height }; + const runIsRtl = characterIsRtl(text, next.index, resolveParagraphIsRtl); + const rect = next.rect; + return { x: runIsRtl ? rect.right : rect.left, top: rect.top, height: rect.height }; } return null; @@ -266,8 +599,20 @@ const isOwnedCollapsedTextRange = (range) => { if (!range?.collapsed) return false; const node = range.startContainer; if (node?.nodeType !== 3 /* Node.TEXT_NODE */) return false; - const parent = node.parentElement; - return typeof parent?.closest === 'function' && parent.closest(RUNTIME_ROOT_SELECTOR) != null; + + // `closest()` stops at a shadow boundary, and SuperDoc mounts painter content + // inside one in at least one supported embedding — which is why the shell + // reads pointer targets through `composedPath()`. Climb out through each + // shadow host so text in that tree is recognised as the runtime's own; without + // this the workaround would quietly decline exactly there. + for (let element = node.parentElement; element;) { + if (typeof element.closest !== 'function') return false; + if (element.closest(RUNTIME_ROOT_SELECTOR)) return true; + const root = typeof element.getRootNode === 'function' ? element.getRootNode() : null; + const host = root && root !== element.ownerDocument ? root.host : null; + element = host?.nodeType === 1 /* Node.ELEMENT_NODE */ ? host : null; + } + return false; }; /** @@ -290,16 +635,20 @@ function synthesizeCollapsedCaretRect(range, nativeGetClientRects) { /** @type {Map} */ const measured = new Map(); const measureCharRect = (index) => { - if (measured.has(index)) return measured.get(index) ?? null; + // Measure the whole code point. Both engines widen a range that splits a + // surrogate pair, but the DOM counts range offsets in UTF-16 units and is + // not obliged to, so the pair is spanned explicitly. + const start = codePointStart(text, index); + if (measured.has(start)) return measured.get(start) ?? null; let rect = null; try { - probe.setStart(node, index); - probe.setEnd(node, index + 1); + probe.setStart(node, start); + probe.setEnd(node, codePointEnd(text, start)); rect = firstGlyphRect(nativeGetClientRects.call(probe)); } catch { rect = null; } - measured.set(index, rect); + measured.set(start, rect); return rect; }; @@ -338,13 +687,17 @@ function synthesizeCollapsedCaretRect(range, nativeGetClientRects) { */ function toRectList(rect) { const list = { - length: 1, 0: rect, - item: (index) => (index === 0 ? rect : null), [Symbol.iterator]: function* iterate() { yield rect; }, }; + // On a real DOMRectList `length` is a non-enumerable accessor and `item` lives + // on the prototype, so neither shows up in `Object.keys` or `JSON.stringify`. + // Defining them the same way keeps host logging and deep-equality assertions + // seeing the shape this API has everywhere else. + Object.defineProperty(list, 'length', { value: 1 }); + Object.defineProperty(list, 'item', { value: (index) => (index === 0 ? rect : null) }); return /** @type {unknown} */ (list); } @@ -422,47 +775,71 @@ export function installWebKitCollapsedCaretRectFix(win) { try { const rangePrototype = win?.Range?.prototype; if (!rangePrototype || typeof rangePrototype.getClientRects !== 'function') return null; - if (rangePrototype[INSTALLED_FLAG]) return () => {}; + // The mark goes on the function rather than on the prototype, so that a + // host which replaces `getClientRects` outright — rather than wrapping it — + // is noticed and the workaround reinstates itself. Reinstating over a host's + // own wrapper is harmless: the inner patch has already answered, so the + // outer one sees rects and passes them through. + if (rangePrototype.getClientRects[INSTALLED_FLAG]) return () => {}; if (measuredCleanWindows.has(win)) return null; + const probesSoFar = probeCounts.get(win) ?? 0; + if (probesSoFar >= MAX_UNKNOWN_PROBES) return null; const status = detectCollapsedCaretRectQuirk(win.document); if (status === 'clean') measuredCleanWindows.add(win); + if (status === 'unknown') probeCounts.set(win, probesSoFar + 1); if (status !== 'quirk') return null; const nativeGetClientRects = rangePrototype.getClientRects; const nativeGetBoundingClientRect = rangePrototype.getBoundingClientRect; + // The native call stays outside the guard so that a range the browser + // itself rejects fails exactly as it does unpatched. Everything after it is + // guarded: `getClientRects` is specified never to throw for a valid range, + // and a host that has instrumented `closest` or `getComputedStyle` — an + // extension, a hardened realm, a test stub — must not be able to turn every + // Range on the page into a throwing API. Failure falls back to the + // browser's own answer, which is the unpatched behaviour. + /** @this {Range} */ function patchedGetClientRects() { const native = nativeGetClientRects.call(this); - if (firstRectWithHeight(native)) return native; - if (!isOwnedCollapsedTextRange(this)) return native; - const rect = synthesizeCollapsedCaretRect(this, nativeGetClientRects); - return rect ? toRectList(rect) : native; + try { + if (firstRectWithHeight(native)) return native; + if (!isOwnedCollapsedTextRange(this)) return native; + const rect = synthesizeCollapsedCaretRect(this, nativeGetClientRects); + return rect ? toRectList(rect) : native; + } catch { + return native; + } } /** @this {Range} */ function patchedGetBoundingClientRect() { const native = nativeGetBoundingClientRect.call(this); - if (native && native.height > 0) return native; - if (!isOwnedCollapsedTextRange(this)) return native; - return synthesizeCollapsedCaretRect(this, nativeGetClientRects) ?? native; + try { + if (native && native.height > 0) return native; + if (!isOwnedCollapsedTextRange(this)) return native; + return synthesizeCollapsedCaretRect(this, nativeGetClientRects) ?? native; + } catch { + return native; + } } const restore = () => { try { rangePrototype.getClientRects = nativeGetClientRects; rangePrototype.getBoundingClientRect = nativeGetBoundingClientRect; - delete rangePrototype[INSTALLED_FLAG]; } catch { /* Nothing left to do: the realm refuses writes. */ } }; try { + Object.defineProperty(patchedGetClientRects, INSTALLED_FLAG, { value: true }); + Object.defineProperty(patchedGetBoundingClientRect, INSTALLED_FLAG, { value: true }); rangePrototype.getClientRects = patchedGetClientRects; rangePrototype.getBoundingClientRect = patchedGetBoundingClientRect; - Object.defineProperty(rangePrototype, INSTALLED_FLAG, { value: true, configurable: true }); } catch { restore(); return null; diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js index b00f6d0384..57b28a51c5 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js @@ -168,11 +168,214 @@ describe('resolveCollapsedCaretGeometry', () => { // only the low surrogate would see no letter at all, fall through to the // paragraph, and put the caret on the wrong edge. const text = 'abc𐤀'; + expect(resolveCollapsedCaretGeometry(text.length, text, ltrRun(text.length), LTR)?.x).toBe(CHAR_WIDTH * 3); + }); + + it('asks for the whole code point, never for one half of a surrogate pair', () => { + // Range offsets are UTF-16 units, so a caller handed the low surrogate could + // measure half a pair. Both offsets inside the pair resolve to its start. + const text = 'abc𐤀'; + const asked = []; + const record = (index) => { + asked.push(index); + return ltrRun(text.length)(index); + }; + resolveCollapsedCaretGeometry(text.length, text, record, LTR); + resolveCollapsedCaretGeometry(text.length - 1, text, record, LTR); + expect(asked).toEqual([3, 3]); + }); + + it('keeps the caret after an NKo digit, which is a digit written right-to-left', () => { + // Not every digit orders left-to-right: NKo and Adlam write theirs + // right-to-left, so a general-category test for "number" picks the wrong + // edge. Chromium lays this one out right-to-left. + const text = `${HEBREW_SPACE}߅`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('keeps the caret after an Adlam digit, which is astral and right-to-left', () => { + const text = `${HEBREW_SPACE}𞥕`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length - 1), RTL)?.x).toBe( + 100 - CHAR_WIDTH * (text.length - 1), + ); + }); + + it('keeps the caret after an Imperial Aramaic number, which is right-to-left', () => { + const text = `${HEBREW_SPACE}𐡘`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length - 1), RTL)?.x).toBe( + 100 - CHAR_WIDTH * (text.length - 1), + ); + }); + + it('gives a number symbol that is not ordered as a number the paragraph direction', () => { + // "½" and "①" are numbers by general category but neutral to the bidi + // algorithm, so they take the paragraph's direction rather than a run of + // their own. + for (const tail of ['½', '①']) { + const text = `${HEBREW_SPACE}${tail}`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + } + }); + + it('keeps the caret after a Roman numeral, which is a left-to-right letter number', () => { + const { text, charRect, tailStart } = rtlThenLtrTail('Ⅷ'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + }); + + it('keeps the caret after a private-use character, as a .docx symbol run paints', () => { + // A Wingdings or Symbol run maps to U+F0xx. Unicode defaults private use to + // left-to-right and Chromium lays it out that way. + const { text, charRect, tailStart } = rtlThenLtrTail(''); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + }); + + it('gives a combining mark the direction of the character it sits on (UBA W1)', () => { + // The acute belongs to the Latin "e", which is left-to-right even though the + // mark's own block is not. Its code-point block says nothing about it. + const { text, charRect, tailStart } = rtlThenLtrTail('é'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH * 2); + }); + + it('gives a combining mark on a Hebrew letter the right-to-left direction', () => { + const text = `${HEBREW_SPACE}אֱ`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('gives a variation selector the direction of the emoji it follows', () => { + // "☺️" ends a Hebrew line. The selector is a mark, the emoji is neutral, + // so the pair takes the paragraph's direction. + const text = `${HEBREW_SPACE}☺️`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('gives a mark with nothing before it the paragraph direction (UBA W1 at sor)', () => { + expect(resolveCollapsedCaretGeometry(1, '́', rtlRun(1), RTL)?.x).toBe(100 - CHAR_WIDTH); + expect(resolveCollapsedCaretGeometry(1, '́', ltrRun(1), LTR)?.x).toBe(CHAR_WIDTH); + }); + + it('does not join a terminator to a number that Arabic letters made Arabic (UBA W2)', () => { + // "مرحبا 50%": the digits follow an Arabic letter, so they order as an + // Arabic number, and W5 no longer attaches the sign to them. Hebrew before + // the same digits leaves them European and the sign does attach, which is + // the case above. Both engines agree. + const text = 'مرحبا 50%'; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('joins a terminator that comes before its number (UBA W5 reads both sides)', () => { + const { text, charRect, tailStart } = rtlThenLtrTail('$50'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH * 3); + }); + + it('gives a neutral between two right-to-left runs their direction (UBA N1)', () => { + // A Hebrew phrase inside an English paragraph: the full stop sits between two + // Hebrew words, so it joins them rather than taking the paragraph. + const text = 'abc שלום. עולם'; + const dot = text.indexOf('.'); + expect(resolveCollapsedCaretGeometry(dot + 1, text, rtlRun(text.length), LTR)?.x).toBe( + 100 - CHAR_WIDTH * (dot + 1), + ); + }); + + it('keeps the caret after Hebrew punctuation inside a left-to-right paragraph', () => { + // Gershayim is right-to-left without being a letter, so a letters-only test + // would hand it to the paragraph. + const text = 'abc ״'; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), LTR)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + }); + + it('gives the Arabic comma the paragraph direction, since it is neutral', () => { + // One of the 46 code points that sit inside a right-to-left block without + // being right-to-left themselves. + const text = 'abc ،'; expect(resolveCollapsedCaretGeometry(text.length, text, ltrRun(text.length), LTR)?.x).toBe( - CHAR_WIDTH * (text.length - 1), + CHAR_WIDTH * text.length, + ); + }); + + it('looks past a zero-width character to the neighbour that has a glyph box', () => { + // WebKit refuses the caret after "שלום " + ZWSP, and the zero-width space it + // would have measured has no box. Chromium puts the caret at the logical end + // of the space, which is the first neighbour that does have one. + const text = `${HEBREW_SPACE}\u200b`; + const glyphs = rtlRun(HEBREW_SPACE.length); + expect(resolveCollapsedCaretGeometry(text.length, text, glyphs, RTL)?.x).toBe( + 100 - CHAR_WIDTH * HEBREW_SPACE.length, + ); + }); + + it('looks past a run of bidi marks, which also have no glyph box', () => { + const text = `${HEBREW_SPACE}\u200f\u200e\u200f`; + const glyphs = rtlRun(HEBREW_SPACE.length); + expect(resolveCollapsedCaretGeometry(text.length, text, glyphs, RTL)?.x).toBe( + 100 - CHAR_WIDTH * HEBREW_SPACE.length, + ); + }); + + it('declines rather than scanning an unbounded run of invisible characters', () => { + // Every step is a forced layout, so the search is bounded and gives up + // instead, which leaves the caret exactly where the unpatched browser puts it. + let measurements = 0; + const text = `${HEBREW_SPACE}${'\u200b'.repeat(64)}`; + const glyphs = (index) => { + measurements += 1; + return rtlRun(HEBREW_SPACE.length)(index); + }; + expect(resolveCollapsedCaretGeometry(text.length, text, glyphs, RTL)).toBeNull(); + expect(measurements).toBeLessThanOrEqual(32); + }); + + it('joins a bracket pair to the left-to-right text it encloses (UBA N0)', () => { + // "שלום abc(def)" — a Latin parenthetical inside Hebrew, which Hebrew + // technical and legal writing is full of. The pair encloses left-to-right + // text and follows left-to-right text, so the brackets join that run rather + // than taking the paragraph's direction. + for (const [open, close] of [ + ['(', ')'], + ['[', ']'], + ['{', '}'], + ['\uff08', '\uff09'], + ['\u3008', '\u3009'], + ]) { + const { text, charRect, tailStart } = rtlThenLtrTail(`abc${open}def${close}`); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH * 8); + } + }); + + it('matches a bracket across canonical equivalence, as BD16 requires', () => { + // U+2329 is canonically equivalent to U+3008, so it pairs with U+3009. + const { text, charRect, tailStart } = rtlThenLtrTail('abc\u2329def\u3009'); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH * 8); + }); + + it('gives a bracket pair the paragraph direction when it encloses that direction', () => { + const text = `${HEBREW_SPACE}(עולם)`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, ); }); + it('leaves an unpaired bracket, and one enclosing nothing strong, to the neutral rules', () => { + for (const tail of ['abc)', '()']) { + const text = `${HEBREW_SPACE}${tail}`; + expect(resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL)?.x).toBe( + 100 - CHAR_WIDTH * text.length, + ); + } + }); + it('carries the glyph vertical metrics onto the caret', () => { expect(resolveCollapsedCaretGeometry(5, HEBREW_SPACE, rtlRun(5), RTL)).toMatchObject({ top: 0, @@ -488,6 +691,100 @@ describe('installWebKitCollapsedCaretRectFix', () => { expect(bodyChildren).toHaveLength(1); }); + it('answers the browser rather than throwing when the host has broken the DOM it reads', () => { + // `getClientRects` is specified never to throw for a valid range. A host that + // has instrumented `closest` or `getComputedStyle` — an extension, a hardened + // realm, a test stub — must not be able to turn every Range on the page into + // a throwing API through this patch. + for (const breakage of ['closest', 'getComputedStyle']) { + const { window, textNode, caretAt } = createFakeWindow(); + installWebKitCollapsedCaretRectFix(window); + const node = textNode(HEBREW_SPACE); + if (breakage === 'closest') { + node.parentElement.closest = () => { + throw new Error('host instrumentation'); + }; + } else { + window.getComputedStyle = () => { + throw new Error('host instrumentation'); + }; + } + const caret = caretAt(node, HEBREW_SPACE.length); + expect(() => caret.getClientRects()).not.toThrow(); + expect(() => caret.getBoundingClientRect()).not.toThrow(); + expect(Array.from(caret.getClientRects())).toHaveLength(0); + } + }); + + it('recognises text inside a shadow root under the runtime, which closest() cannot reach', () => { + // SuperDoc mounts painter content inside a shadow root in at least one + // supported embedding, which is why the shell reads pointer targets through + // composedPath(). `closest()` stops at that boundary. + const { window, textNode, caretAt } = createFakeWindow(); + installWebKitCollapsedCaretRectFix(window); + const node = textNode(HEBREW_SPACE, { owned: false }); + const runtimeRoot = { tagName: 'DIV' }; + const host = { + nodeType: 1, + tagName: 'DIV', + closest: (selector) => (selector === '[data-superdoc-runtime-id]' ? runtimeRoot : null), + }; + node.parentElement.getRootNode = () => ({ host }); + expect(Array.from(caretAt(node, HEBREW_SPACE.length).getClientRects())).toHaveLength(1); + }); + + it('leaves a text node with no parent element to the browser', () => { + const { window, textNode, caretAt } = createFakeWindow(); + installWebKitCollapsedCaretRectFix(window); + const node = textNode(HEBREW_SPACE); + node.parentElement = null; + expect(Array.from(caretAt(node, HEBREW_SPACE.length).getClientRects())).toHaveLength(0); + }); + + it('hides length and item from enumeration, as a real DOMRectList does', () => { + // A real DOMRectList exposes `length` as a non-enumerable accessor and `item` + // on its prototype, so neither appears in Object.keys or JSON.stringify. Host + // logging and deep-equality assertions compare against that shape. + const { window, textNode, caretAt } = createFakeWindow(); + installWebKitCollapsedCaretRectFix(window); + const rects = caretAt(textNode(HEBREW_SPACE), HEBREW_SPACE.length).getClientRects(); + expect(Object.keys(rects)).toEqual(['0']); + expect(JSON.parse(JSON.stringify(rects))).not.toHaveProperty('length'); + expect(rects.length).toBe(1); + expect(typeof rects.item).toBe('function'); + }); + + it('reinstates itself when a host replaces the patched method outright', () => { + // Wrapping composes; replacing does not, and the workaround would otherwise + // be gone for the rest of the page's life with no way to notice. + const { window, FakeRange, textNode, caretAt } = createFakeWindow(); + installWebKitCollapsedCaretRectFix(window); + const native = FakeRange.prototype.measure; + FakeRange.prototype.getClientRects = function replaced() { + return native.call(this); + }; + expect(Array.from(caretAt(textNode(HEBREW_SPACE), HEBREW_SPACE.length).getClientRects())).toHaveLength(0); + + installWebKitCollapsedCaretRectFix(window); + expect(Array.from(caretAt(textNode(HEBREW_SPACE), HEBREW_SPACE.length).getClientRects())).toHaveLength(1); + }); + + it('stops re-probing a window that can never be measured', () => { + // Each probe forces a layout and delivers two childList records to any host + // observing document.body, so a page building many editors without layout + // must not pay it for every one of them. + const { window, bodyChildren } = createFakeWindow(); + window.document.createRange = () => ({ + setStart: () => {}, + setEnd: () => {}, + collapse: () => {}, + getClientRects: () => [], + getBoundingClientRect: () => null, + }); + for (let attempt = 0; attempt < 20; attempt += 1) installWebKitCollapsedCaretRectFix(window); + expect(bodyChildren.length).toBeLessThanOrEqual(8); + }); + it('declines instead of throwing when the realm refuses the patch', () => { // SES/Lockdown and similar hardened embeds freeze built-in prototypes. A // caret workaround must never reject the engine-load promise, which would From 1adf262734ad624a162d11bc3608c8a45e815f73 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Wed, 2 Sep 2026 18:56:28 +0300 Subject: [PATCH 3/6] fix(v2): classify every code point by Bidi_Class, not by category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second review pass found the classification was still guessing in places, and one of those guesses was live. Decimal digits were the serious one. Only ASCII, Persian, fullwidth and a few enclosed forms are European numbers, and only the Arabic-Indic, Rumi and Hanifi ones are Arabic numbers; NKo and Adlam digits are right-to-left. That leaves 620 decimal digits — Devanagari, Bengali, Thai, Lao, Khmer, Myanmar and forty more scripts — as plain Bidi_Class L, and they were falling through to "neutral" and taking the paragraph's direction. Measured in Chromium, "שלום ६" put the caret 7.4px out, and WebKit refuses that boundary, so it was reached and answered wrongly. The wider problem was the shape of the test. Guessing which characters are left-to-right from general categories cannot be made exact, because the categories cut across Bidi_Class: `½` and `①` are numbers that are neutral, private use and Indic spacing marks are left-to-right without being letters. So the last test is inverted — the neutral classes are listed, and everything else is left-to-right, which is what Unicode gives every code point it does not say otherwise about. The two blocks where the default is something else, the right-to-left scripts and the currency symbols, are both resolved before it. An audit over all 297334 assigned code points now reports the module classifying every one of them exactly as DerivedBidiClass.txt says. The previous pass claimed the same and was wrong: it decided which code points were assigned with a table that shipped one Unicode version behind the data file, which hid 66 code points inside the right-to-left blocks that are not right-to-left — the Arabic honorific ligatures, the Arabic Extended-C signs and the noncharacters among them. Nothing reads a second source for that any more. Also from the same pass: - The neutral, terminator, bracket and mark searches were unbounded and linear, and the caret is resolved once per placement, so an unbroken run of neutrals made key-repeat quadratic: 20,000 characters of them measured at 750 seconds. They are bounded now, and past the bound the paragraph decides, which is what the neutral rules give for a run that long anyway. - `resolveCollapsedCaretGeometry` threw on a non-string `text` instead of returning null, though its own length handling implied it should tolerate one. Not reachable from the caret path, which never passes one, but it is an exported function. - `strongSideIsRtl` looped forever on a negative index, because it tested `at === 0` while walking away from zero. Also unreachable — the public entry point rejects a negative offset — but a landmine. - Bidi_Class NSM is general category Mn or Me except for five Indic vowel signs that are Mn with Bidi_Class L; they are excluded now. One known gap stays, and is worth naming rather than hiding: the workaround sees one text node, so a number split across formatting runs — "1," in one span and "234" in the next — is resolved from the part it can see, and rule W4 cannot join them. Measured at 4.5px on that shape. Closing it means giving the rule the text on both sides of the node boundary, which is a larger change than this one. Refs #3943 --- .../webkit-collapsed-caret-rect.js | 88 ++++++++++++++----- .../webkit-collapsed-caret-rect.test.js | 32 +++++++ 2 files changed, 97 insertions(+), 23 deletions(-) diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js index bc0c7fe342..681b31de64 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js @@ -155,13 +155,14 @@ const firstGlyphRect = (rects) => { const RTL_SCRIPT_BLOCK = /[\u0590-\u08FF\u200F\uFB1D-\uFDFF\uFE70-\uFEFF\u{10800}-\u{10FFF}\u{1E800}-\u{1EFFF}]/u; /** - * The 46 assigned code points inside those blocks that are NOT Bidi_Class R or - * AL: the Arabic comma, the ornate parentheses, the Arabic ligature symbols, - * the NKo punctuation, and a handful of others. They are neutral, so they take - * the paragraph's direction like any other neutral. + * The 112 code points inside those blocks that are NOT Bidi_Class R or AL: the + * Arabic comma, the ornate parentheses, the Arabic ligature and honorific + * symbols, the NKo punctuation, the Arabic Extended-C signs, and the + * noncharacters. They are neutral, so they take the paragraph's direction like + * any other neutral. */ const RTL_BLOCK_NEUTRAL = - /[\u0606-\u0607\u060C\u060E-\u060F\u06DE\u06E9\u07F6-\u07F9\uFB29\uFD3E-\uFD4F\uFDCF\uFDFD-\uFDFF\uFEFF\u{1091F}\u{10B39}-\u{10B3F}\u{10D6E}\u{1EEF0}-\u{1EEF1}]/u; + /[\u0606-\u0607\u060C\u060E-\u060F\u06DE\u06E9\u07F6-\u07F9\uFB29\uFBC3-\uFBD2\uFD3E-\uFD4F\uFD90-\uFD91\uFDC8-\uFDEF\uFDFD-\uFDFF\uFEFF\u{1091F}\u{10B39}-\u{10B3F}\u{10D6E}\u{10ED0}-\u{10ED8}\u{1EEF0}-\u{1EEF1}]/u; /** Bidi_Class EN — European numbers, ordered left-to-right at any embedding level. */ const EUROPEAN_NUMBER_CHAR = @@ -173,22 +174,41 @@ const ARABIC_NUMBER_CHAR = /** Bidi_Class ET — terminators that a neighbouring European number absorbs (rule W5). */ const NUMBER_TERMINATOR_CHAR = - /[\u0023-\u0025\u00A2-\u00A5\u00B0-\u00B1\u058F\u0609-\u060A\u066A\u09F2-\u09F3\u09FB\u0AF1\u0BF9\u0E3F\u17DB\u2030-\u2034\u20A0-\u20C1\u212E\u2213\uA838-\uA839\uFE5F\uFE69-\uFE6A\uFF03-\uFF05\uFFE0-\uFFE1\uFFE5-\uFFE6\u{11FDD}-\u{11FE0}\u{1E2FF}]/u; + /[\u0023-\u0025\u00A2-\u00A5\u00B0-\u00B1\u058F\u0609-\u060A\u066A\u09F2-\u09F3\u09FB\u0AF1\u0BF9\u0E3F\u17DB\u2030-\u2034\u20A0-\u20CF\u212E\u2213\uA838-\uA839\uFE5F\uFE69-\uFE6A\uFF03-\uFF05\uFFE0-\uFFE1\uFFE5-\uFFE6\u{11FDD}-\u{11FE0}\u{1E2FF}]/u; /** Bidi_Class AL — Arabic letters, which turn a following European number Arabic (rule W2). */ const ARABIC_LETTER_CHAR = - /[\u0608-\u060B\u060D\u061B-\u06D5\u06E5-\u06E6\u06EE-\u07B1\u0860-\u08C9\uFB50-\uFD3D\uFD50-\uFDC7\uFDF0-\uFDFC\uFE70-\uFEFC\u{10D00}-\u{10D23}\u{10EC2}-\u{10EC7}\u{10F30}-\u{10F59}\u{1EC71}-\u{1EEBB}]/u; + /[\u0608\u060B\u060D\u061B-\u064A\u066D-\u066F\u0671-\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u0710\u0712-\u072F\u074B-\u07A5\u07B1-\u07BF\u0860-\u088F\u0892-\u0896\u08A0-\u08C9\uFB50-\uFBC2\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFC\uFE70-\uFEFE\u{10D00}-\u{10D23}\u{10D28}-\u{10D2F}\u{10D3A}-\u{10D3F}\u{10EC0}-\u{10ECF}\u{10ED9}-\u{10EF9}\u{10F30}-\u{10F45}\u{10F51}-\u{10F6F}\u{1EC70}-\u{1ECBF}\u{1ED00}-\u{1ED4F}\u{1EE00}-\u{1EEEF}\u{1EEF2}-\u{1EEFF}]/u; /** * Bidi_Class NSM — non-spacing marks, which take the class of the character - * before them (rule W1). The Unicode Character Database defines NSM as exactly - * the characters of general category Mn or Me, so this needs no table. + * before them (rule W1). Every NSM character is general category Mn or Me, and + * all but five characters of those categories are NSM, so this needs only the + * category test and the short exception list below. */ const MARK_CHAR = /[\p{Mn}\p{Me}]/u; /** - * Bidi_Class L, for what is left once the classes above are resolved: letters, - * letter numbers, spacing marks, and private use. + * The five characters that are general category Mn without being + * Bidi_Class NSM — U+0CBF and U+0CC6 (Kannada), U+11A07 and U+11A08 + * (Zanabazar Square) and U+11C3F (Bhaiksuki). They are Bidi_Class L, so they + * carry their own direction rather than inheriting the previous character's. + */ +const MARK_EXCEPTION_CHAR = /[\u0CBF\u0CC6\u{11A07}\u{11A08}\u{11C3F}]/u; + +/** + * The neutral classes — ON, WS, BN, B, S, CS, ES and the explicit formatting + * codes — for what is left once the classes above are resolved. + * + * This is the last test, so everything it does not match is Bidi_Class L. That + * is the right default: Unicode gives L to every code point it does not say + * otherwise about, unassigned ones included, and the two blocks where the + * default is something else — the right-to-left scripts and the currency + * symbols — are both resolved above. Listing the neutrals rather than guessing + * at the left-to-right ones is what makes the classification exact: general + * categories cut across Bidi_Class badly here, since `½` and `①` are numbers + * that are neutral, 620 decimal digits are plain left-to-right, and private use + * and Indic spacing marks are left-to-right without being letters. * * Private use earns its place: a .docx symbol run (Wingdings, Symbol) maps to * U+F0xx, the Unicode default for private use is left-to-right, and Chromium @@ -196,7 +216,22 @@ const MARK_CHAR = /[\p{Mn}\p{Me}]/u; * symbols belonging to left-to-right scripts — 1.3% of assigned code points, * and visible only inside a right-to-left paragraph. */ -const STRONG_LTR_CHAR = /[\p{L}\p{Nl}\p{Mc}\p{Co}]/u; +const NEUTRAL_CHAR = + /[\u0000-\u0040\u005B-\u0060\u007B-\u00A9\u00AB-\u00B4\u00B6-\u00B8\u00BB-\u00BF\u00D7\u00F7\u02B9-\u02BA\u02C2-\u02CF\u02D2-\u02DF\u02E5-\u02ED\u02EF-\u02FF\u0374-\u0375\u037E\u0384-\u0385\u0387\u03F6\u058A\u058D-\u07F9\u0BF3-\u0BFA\u0C78-\u0C7E\u0F3A-\u0F3D\u1390-\u1399\u1400\u1680\u169B-\u169C\u17F0-\u17F9\u1800-\u180E\u1940\u1944-\u1945\u19DE-\u19FF\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD-\u1FFE\u2000-\u200D\u2010-\u206F\u207A-\u207E\u208A-\u208E\u2100-\u2101\u2103-\u2106\u2108-\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u213A-\u213B\u2140-\u2144\u214A-\u214D\u2150-\u215F\u2189-\u218B\u2190-\u2335\u237B-\u2394\u2396-\u2429\u2440-\u244A\u2460-\u2487\u24EA-\u26AB\u26AD-\u27FF\u2900-\u2B73\u2B76-\u2BFF\u2CE5-\u2CEA\u2CF9-\u2CFF\u2E00-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u3004\u3008-\u3020\u3030\u3036-\u3037\u303D-\u303F\u309B-\u309C\u30A0\u30FB\u31C0-\u31E5\u31EF\u321D-\u321E\u3250-\u325F\u327C-\u327E\u32B1-\u32BF\u32CC-\u32CF\u3377-\u337A\u33DE-\u33DF\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA60D-\uA60F\uA673-\uA67F\uA700-\uA721\uA788\uA828-\uA82B\uA874-\uA877\uAB6A-\uAB6B\uFB29-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFEFF\uFF01-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\uFFE2-\uFFE4\uFFE8-\uFFEE\uFFF0-\uFFFF\u{10101}\u{10140}-\u{1018C}\u{10190}-\u{1019C}\u{101A0}\u{1091F}-\u{10ED8}\u{11052}-\u{11065}\u{11660}-\u{1166C}\u{11FD5}-\u{11FF1}\u{16FE2}\u{1BCA0}-\u{1BCA3}\u{1CC00}-\u{1CCD5}\u{1CCFA}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1D173}-\u{1D17A}\u{1D1E9}-\u{1D1EA}\u{1D200}-\u{1D245}\u{1D300}-\u{1D356}\u{1D6C1}\u{1D6DB}\u{1D6FB}\u{1D715}\u{1D735}\u{1D74F}\u{1D76F}\u{1D789}\u{1D7A9}\u{1D7C3}\u{1EEF0}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F10B}-\u{1F10F}\u{1F12F}\u{1F16A}-\u{1F16F}\u{1F1AD}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}-\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{1FFFE}-\u{1FFFF}\u{2FFFE}-\u{2FFFF}\u{3FFFE}-\u{3FFFF}\u{4FFFE}-\u{4FFFF}\u{5FFFE}-\u{5FFFF}\u{6FFFE}-\u{6FFFF}\u{7FFFE}-\u{7FFFF}\u{8FFFE}-\u{8FFFF}\u{9FFFE}-\u{9FFFF}\u{AFFFE}-\u{AFFFF}\u{BFFFE}-\u{BFFFF}\u{CFFFE}-\u{CFFFF}\u{DFFFE}-\u{E0FFF}\u{EFFFE}-\u{EFFFF}\u{FFFFE}-\u{FFFFF}\u{10FFFE}-\u{10FFFF}]/u; + +/** + * How far the neutral, terminator and bracket searches look before giving up and + * taking the paragraph's direction. + * + * Each search is linear in the text it walks, and the caret is resolved once per + * placement, so an unbroken run of neutrals — pasted financial data, a wall of + * combining marks — turns key-repeat or drag-select into quadratic work on the + * main thread. Measured at roughly 1.5µs per character walked, this bound keeps + * a single resolution under about a millisecond. A run longer than this has no + * strong character near enough to matter, and the paragraph's direction is what + * N2 and L1 would give anyway. + */ +const MAX_SCAN_CHARACTERS = 512; const HIGH_SURROGATE_START = 0xd800; const HIGH_SURROGATE_END = 0xdbff; @@ -259,12 +294,12 @@ const CLASS_MARK = 6; * @returns {number} */ function classOf(char) { - if (MARK_CHAR.test(char)) return CLASS_MARK; + if (MARK_CHAR.test(char) && !MARK_EXCEPTION_CHAR.test(char)) return CLASS_MARK; if (EUROPEAN_NUMBER_CHAR.test(char) || ARABIC_NUMBER_CHAR.test(char)) return CLASS_NUMBER; if (NUMBER_TERMINATOR_CHAR.test(char)) return CLASS_TERMINATOR; if (RTL_SCRIPT_BLOCK.test(char)) return RTL_BLOCK_NEUTRAL.test(char) ? CLASS_NEUTRAL : CLASS_RTL; - if (STRONG_LTR_CHAR.test(char)) return CLASS_LTR; - return CLASS_NEUTRAL; + if (NEUTRAL_CHAR.test(char)) return CLASS_NEUTRAL; + return CLASS_LTR; } /** @@ -298,7 +333,8 @@ function europeanNumberKeepsEuropeanRun(text, index) { */ function terminatorTouchesEuropeanNumber(text, index) { for (const step of [-1, 1]) { - for (let at = index; ;) { + let budget = MAX_SCAN_CHARACTERS; + for (let at = index; budget > 0; budget -= 1) { if (step < 0) { if (at === 0) break; at = codePointStart(text, at - 1); @@ -331,9 +367,10 @@ function terminatorTouchesEuropeanNumber(text, index) { * @returns {boolean} */ function strongSideIsRtl(text, index, step, paragraphIsRtl) { - for (let at = index; ;) { + let budget = MAX_SCAN_CHARACTERS; + for (let at = index; budget > 0; budget -= 1) { if (step < 0) { - if (at === 0) return paragraphIsRtl; + if (at <= 0) return paragraphIsRtl; at = codePointStart(text, at - 1); } else { at = codePointEnd(text, at); @@ -343,6 +380,7 @@ function strongSideIsRtl(text, index, step, paragraphIsRtl) { if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) return true; if (charClass === CLASS_LTR) return false; } + return paragraphIsRtl; } /** @@ -380,7 +418,11 @@ function canonicalBracket(char) { function bracketPairAt(text, index) { /** @type {{ closing: string, at: number }[]} */ const stack = []; - for (let at = 0; at < text.length; at = codePointEnd(text, at)) { + // Bounded like the other searches; a bracket whose partner is further away + // than this simply has no pair, which leaves it neutral. + const from = Math.max(0, index - MAX_SCAN_CHARACTERS); + const to = Math.min(text.length, index + MAX_SCAN_CHARACTERS); + for (let at = from; at < to; at = codePointEnd(text, at)) { const char = characterAt(text, at); // Only a bracket that is still neutral takes part; one that a preceding rule // already resolved is not a bracket for N0's purposes. @@ -473,8 +515,8 @@ function characterIsRtl(text, index, resolveParagraphIsRtl) { // W1: a non-spacing mark takes the class of the character before it, and the // paragraph direction when there is none. - while (classOf(char) === CLASS_MARK) { - if (at === 0) return resolveParagraphIsRtl(); + for (let budget = MAX_SCAN_CHARACTERS; classOf(char) === CLASS_MARK; budget -= 1) { + if (at <= 0 || budget <= 0) return resolveParagraphIsRtl(); at = codePointStart(text, at - 1); char = characterAt(text, at); } @@ -565,8 +607,8 @@ function nearestMeasuredCharacter(offset, text, measureCharRect, step) { * @returns {{ x: number, top: number, height: number } | null} */ export function resolveCollapsedCaretGeometry(offset, text, measureCharRect, resolveParagraphIsRtl) { - const textLength = text?.length ?? 0; - if (!Number.isInteger(offset) || offset < 0 || offset > textLength) return null; + if (typeof text !== 'string') return null; + if (!Number.isInteger(offset) || offset < 0 || offset > text.length) return null; const previous = nearestMeasuredCharacter(offset, text, measureCharRect, -1); if (previous) { diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js index 57b28a51c5..6a0f924c30 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js @@ -376,6 +376,38 @@ describe('resolveCollapsedCaretGeometry', () => { } }); + it('keeps the caret after a native-script digit, which is plain left-to-right', () => { + // Only ASCII, Persian, fullwidth, Arabic-Indic, NKo and Adlam digits have a + // bidi class of their own. Devanagari, Thai, Bengali and forty more scripts + // write ordinary left-to-right digits, and reading them as neutral put the + // caret on the paragraph's edge instead of theirs. + for (const digit of ['२', '๑', '১', '၁']) { + const { text, charRect, tailStart } = rtlThenLtrTail(digit); + expect(resolveCollapsedCaretGeometry(text.length, text, charRect, RTL)?.x).toBe(tailStart + CHAR_WIDTH); + } + }); + + it('returns nothing for text that is not a string', () => { + for (const notText of [null, undefined, 42, {}]) { + expect(resolveCollapsedCaretGeometry(0, notText, () => null, RTL)).toBeNull(); + expect(resolveCollapsedCaretGeometry(1, notText, rtlRun(1), RTL)).toBeNull(); + } + }); + + it('stops searching for a strong character rather than walking the whole node', () => { + // Every step of the search classifies a character, and the caret is resolved + // on each placement, so an unbroken run of neutrals would otherwise make + // key-repeat quadratic. Here a left-to-right paragraph holds two Hebrew + // letters more than the bound apart, with the caret in the neutral run + // between them: within the bound they would pull the caret right-to-left, + // past it the paragraph decides, which is what the neutral rules give for a + // run this long anyway. + const gap = ' '.repeat(1200); + const text = `א${gap}ב`; + const offset = 1 + gap.length / 2; + expect(resolveCollapsedCaretGeometry(offset, text, ltrRun(text.length), LTR)?.x).toBe(CHAR_WIDTH * offset); + }); + it('carries the glyph vertical metrics onto the caret', () => { expect(resolveCollapsedCaretGeometry(5, HEBREW_SPACE, rtlRun(5), RTL)).toMatchObject({ top: 0, From fb08a0c3dc2baa56b0b92b10fab021a0128f771f Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Wed, 2 Sep 2026 19:25:06 +0300 Subject: [PATCH 4/6] fix(v2): cache the bidi pass instead of cutting the search short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distance cutoff added in the previous commit was the wrong trade and the review is right to reject it. It was there because the neutral, terminator, mark and bracket searches are each linear in the text they walk, and the caret is resolved once per placement, so an unbroken run of neutrals made key-repeat quadratic — 20,000 characters of them measured at twelve minutes of main-thread work. Cutting the walks off at 512 characters fixed that by changing the answer: a neutral run longer than the cutoff stopped seeing the strong characters around it, and a bracket pair wider than it stopped being a pair. That is the same class of defect the workaround exists to fix, traded for speed. The work is bounded by doing it once instead. Everything the rules need from the rest of the text — the nearest strong character on each side, the character a mark sits on, whether a terminator touches a European number, and where the bracket pairs are — is computed in a single pass over the text and cached on it, so each resolution is a handful of array reads however long the text is. Only the paragraph-independent half is precomputed, so a character that carries its own direction still resolves without reading the paragraph's and forcing a style recalc. Measured on the case that prompted the cutoff, a text node of nothing but terminators, one resolution per offset: 2000 characters: 24.1 ms 4000 characters: 13.8 ms 20000 characters: 33.5 ms Linear, where the same benchmark took 750 seconds before the cutoff. And the answer is now what the algorithm says at any length: the test that asserted the truncated behaviour has been replaced by one that puts two Hebrew letters 1200 characters apart with the caret in the neutral run between them, which N1 gives their direction and not the paragraph's. Nothing else moves. The classification audit still reports the module agreeing with DerivedBidiClass.txt on all 297334 assigned code points; the rule still scores 35/35 on the general boundary set and 21/21 on the bracket set against Chromium; the engine harness still repairs all 45 boundaries WebKit refuses across 8528 caret positions at eight zoom levels; and all seventeen mutations are still caught. Refs #3943 --- .../webkit-collapsed-caret-rect.js | 352 ++++++++++-------- .../webkit-collapsed-caret-rect.test.js | 28 +- 2 files changed, 209 insertions(+), 171 deletions(-) diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js index 681b31de64..7f66d109e0 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js @@ -219,20 +219,6 @@ const MARK_EXCEPTION_CHAR = /[\u0CBF\u0CC6\u{11A07}\u{11A08}\u{11C3F}]/u; const NEUTRAL_CHAR = /[\u0000-\u0040\u005B-\u0060\u007B-\u00A9\u00AB-\u00B4\u00B6-\u00B8\u00BB-\u00BF\u00D7\u00F7\u02B9-\u02BA\u02C2-\u02CF\u02D2-\u02DF\u02E5-\u02ED\u02EF-\u02FF\u0374-\u0375\u037E\u0384-\u0385\u0387\u03F6\u058A\u058D-\u07F9\u0BF3-\u0BFA\u0C78-\u0C7E\u0F3A-\u0F3D\u1390-\u1399\u1400\u1680\u169B-\u169C\u17F0-\u17F9\u1800-\u180E\u1940\u1944-\u1945\u19DE-\u19FF\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD-\u1FFE\u2000-\u200D\u2010-\u206F\u207A-\u207E\u208A-\u208E\u2100-\u2101\u2103-\u2106\u2108-\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u213A-\u213B\u2140-\u2144\u214A-\u214D\u2150-\u215F\u2189-\u218B\u2190-\u2335\u237B-\u2394\u2396-\u2429\u2440-\u244A\u2460-\u2487\u24EA-\u26AB\u26AD-\u27FF\u2900-\u2B73\u2B76-\u2BFF\u2CE5-\u2CEA\u2CF9-\u2CFF\u2E00-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u3004\u3008-\u3020\u3030\u3036-\u3037\u303D-\u303F\u309B-\u309C\u30A0\u30FB\u31C0-\u31E5\u31EF\u321D-\u321E\u3250-\u325F\u327C-\u327E\u32B1-\u32BF\u32CC-\u32CF\u3377-\u337A\u33DE-\u33DF\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA60D-\uA60F\uA673-\uA67F\uA700-\uA721\uA788\uA828-\uA82B\uA874-\uA877\uAB6A-\uAB6B\uFB29-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFEFF\uFF01-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\uFFE2-\uFFE4\uFFE8-\uFFEE\uFFF0-\uFFFF\u{10101}\u{10140}-\u{1018C}\u{10190}-\u{1019C}\u{101A0}\u{1091F}-\u{10ED8}\u{11052}-\u{11065}\u{11660}-\u{1166C}\u{11FD5}-\u{11FF1}\u{16FE2}\u{1BCA0}-\u{1BCA3}\u{1CC00}-\u{1CCD5}\u{1CCFA}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1D173}-\u{1D17A}\u{1D1E9}-\u{1D1EA}\u{1D200}-\u{1D245}\u{1D300}-\u{1D356}\u{1D6C1}\u{1D6DB}\u{1D6FB}\u{1D715}\u{1D735}\u{1D74F}\u{1D76F}\u{1D789}\u{1D7A9}\u{1D7C3}\u{1EEF0}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F10B}-\u{1F10F}\u{1F12F}\u{1F16A}-\u{1F16F}\u{1F1AD}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}-\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{1FFFE}-\u{1FFFF}\u{2FFFE}-\u{2FFFF}\u{3FFFE}-\u{3FFFF}\u{4FFFE}-\u{4FFFF}\u{5FFFE}-\u{5FFFF}\u{6FFFE}-\u{6FFFF}\u{7FFFE}-\u{7FFFF}\u{8FFFE}-\u{8FFFF}\u{9FFFE}-\u{9FFFF}\u{AFFFE}-\u{AFFFF}\u{BFFFE}-\u{BFFFF}\u{CFFFE}-\u{CFFFF}\u{DFFFE}-\u{E0FFF}\u{EFFFE}-\u{EFFFF}\u{FFFFE}-\u{FFFFF}\u{10FFFE}-\u{10FFFF}]/u; -/** - * How far the neutral, terminator and bracket searches look before giving up and - * taking the paragraph's direction. - * - * Each search is linear in the text it walks, and the caret is resolved once per - * placement, so an unbroken run of neutrals — pasted financial data, a wall of - * combining marks — turns key-repeat or drag-select into quadratic work on the - * main thread. Measured at roughly 1.5µs per character walked, this bound keeps - * a single resolution under about a millisecond. A run longer than this has no - * strong character near enough to matter, and the paragraph's direction is what - * N2 and L1 would give anyway. - */ -const MAX_SCAN_CHARACTERS = 512; - const HIGH_SURROGATE_START = 0xd800; const HIGH_SURROGATE_END = 0xdbff; const LOW_SURROGATE_START = 0xdc00; @@ -302,87 +288,6 @@ function classOf(char) { return CLASS_LTR; } -/** - * Rule W2: a European number is re-read as an Arabic number when the nearest - * strong character before it is an Arabic letter. Only European numbers absorb - * a terminator, so this is what makes "%" part of the number in Hebrew - * ("שלום 50%") but not in Arabic ("مرحبا 50%"), which both engines confirm. - * - * @param {string} text - * @param {number} index Index of the European number. - * @returns {boolean} - */ -function europeanNumberKeepsEuropeanRun(text, index) { - for (let at = index; at > 0;) { - at = codePointStart(text, at - 1); - const char = characterAt(text, at); - const charClass = classOf(char); - if (charClass === CLASS_RTL) return !ARABIC_LETTER_CHAR.test(char); - if (charClass === CLASS_LTR) return true; - } - return true; -} - -/** - * Rule W5: a run of terminators touching a European number joins that number, - * on either side, so both "$50" and "50%" stay one left-to-right run. - * - * @param {string} text - * @param {number} index Index of the terminator. - * @returns {boolean} - */ -function terminatorTouchesEuropeanNumber(text, index) { - for (const step of [-1, 1]) { - let budget = MAX_SCAN_CHARACTERS; - for (let at = index; budget > 0; budget -= 1) { - if (step < 0) { - if (at === 0) break; - at = codePointStart(text, at - 1); - } else { - at = codePointEnd(text, at); - if (at >= text.length) break; - } - const char = characterAt(text, at); - const charClass = classOf(char); - if (charClass === CLASS_TERMINATOR || charClass === CLASS_MARK) continue; - if (charClass !== CLASS_NUMBER || !EUROPEAN_NUMBER_CHAR.test(char)) break; - if (europeanNumberKeepsEuropeanRun(text, at)) return true; - break; - } - } - return false; -} - -/** - * Direction of the nearest strong character on one side of a neutral, with the - * paragraph direction standing in past either end of the text (sor / eor). - * Rule N1 has numbers influence a neighbouring neutral as though they were - * right-to-left, which is why CLASS_NUMBER answers `true` here even though a - * number is itself ordered left-to-right. - * - * @param {string} text - * @param {number} index - * @param {number} step -1 to look back, 1 to look forward. - * @param {boolean} paragraphIsRtl - * @returns {boolean} - */ -function strongSideIsRtl(text, index, step, paragraphIsRtl) { - let budget = MAX_SCAN_CHARACTERS; - for (let at = index; budget > 0; budget -= 1) { - if (step < 0) { - if (at <= 0) return paragraphIsRtl; - at = codePointStart(text, at - 1); - } else { - at = codePointEnd(text, at); - if (at >= text.length) return paragraphIsRtl; - } - const charClass = classOf(characterAt(text, at)); - if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) return true; - if (charClass === CLASS_LTR) return false; - } - return paragraphIsRtl; -} - /** * The 64 bracket pairs of `BidiBrackets.txt` (Unicode 17.0.0), index-aligned: * the closing bracket for `BRACKET_OPENINGS[i]` is `BRACKET_CLOSINGS[i]`. @@ -409,81 +314,192 @@ function canonicalBracket(char) { } /** - * BD16: the bracket pair that `index` belongs to, or null when it is in none. - * + * Everything about one text node's characters that does not depend on the + * paragraph's direction, computed in a single pass over the text. + * + * The rules below need, for a given character, the nearest strong character on + * each side, the character a mark sits on, and whether a terminator touches a + * European number. Walking for those on every caret placement is linear each + * time, which makes an unbroken run of neutrals quadratic under key-repeat — a + * run of 20,000 measured at twelve minutes of main-thread work. Cutting the + * walks off at a fixed distance would fix that and change the answer: a neutral + * run longer than the cutoff would stop seeing the strong characters that + * surround it, and a wide bracket pair would stop being a pair. So the work is + * bounded by doing it once instead, and the result stays exactly what the + * algorithm says at any length. + * + * Only the paragraph-independent half is precomputed, so a character that + * carries its own direction still resolves without reading the paragraph's, + * which forces a style recalc. + * + * @typedef {object} TextAnalysis + * @property {Int8Array} classes Coarse Bidi_Class per UTF-16 unit. + * @property {Int32Array} baseBefore Nearest index at or before each one whose class is not a mark, or -1 (rule W1). + * @property {Int8Array} strongBefore Direction of the nearest strong character before each index: 1, 0, or -1 for none. + * @property {Int8Array} strongAfter The same, after each index. + * @property {Uint8Array} terminatorJoinsNumber Whether the terminator at each index touches a European run (rules W2 and W5). + * @property {Map} bracketPairs Keyed by the index of each bracket of the pair. + */ + +/** + * @typedef {object} BracketPair + * @property {number} open + * @property {number} close + * @property {{ rtl: boolean, ltr: boolean } | null} enclosed Filled in on first use. + */ + +/** Numbers influence a neighbouring neutral as though they were right-to-left (rule N1). */ +const STRONG_NONE = -1; +const STRONG_LTR = 0; +const STRONG_RTL = 1; + +/** What the nearest strong character before a number is, for rule W2. */ +const LETTER_NONE = -1; +const LETTER_LTR = 0; +const LETTER_RTL = 1; +const LETTER_ARABIC = 2; + +/** * @param {string} text - * @param {number} index - * @returns {{ open: number, close: number } | null} + * @returns {TextAnalysis} */ -function bracketPairAt(text, index) { +function analyseText(text) { + const length = text.length; + const classes = new Int8Array(length); + const baseBefore = new Int32Array(length).fill(-1); + const strongBefore = new Int8Array(length).fill(STRONG_NONE); + const strongAfter = new Int8Array(length).fill(STRONG_NONE); + const terminatorJoinsNumber = new Uint8Array(length); + /** @type {Map} */ + const bracketPairs = new Map(); + + /** @type {number[]} */ + const starts = []; + for (let at = 0; at < length;) { + const end = codePointEnd(text, at); + const charClass = classOf(characterAt(text, at)); + for (let unit = at; unit < end; unit += 1) classes[unit] = charClass; + starts.push(at); + at = end; + } + + // Forward: what each character has behind it. + const letterBefore = new Int8Array(length).fill(LETTER_NONE); + const runBefore = new Int32Array(length).fill(-1); + let lastBase = -1; + let lastStrong = STRONG_NONE; + let lastLetter = LETTER_NONE; + let lastRun = -1; + for (const start of starts) { + const charClass = classes[start]; + baseBefore[start] = charClass === CLASS_MARK ? lastBase : start; + strongBefore[start] = lastStrong; + letterBefore[start] = lastLetter; + runBefore[start] = lastRun; + if (charClass !== CLASS_MARK) lastBase = start; + if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) lastStrong = STRONG_RTL; + else if (charClass === CLASS_LTR) lastStrong = STRONG_LTR; + if (charClass === CLASS_RTL) { + lastLetter = ARABIC_LETTER_CHAR.test(characterAt(text, start)) ? LETTER_ARABIC : LETTER_RTL; + } else if (charClass === CLASS_LTR) { + lastLetter = LETTER_LTR; + } + if (charClass !== CLASS_TERMINATOR && charClass !== CLASS_MARK) lastRun = start; + } + + // Backward: what each character has ahead of it. + const runAfter = new Int32Array(length).fill(-1); + let nextStrong = STRONG_NONE; + let nextRun = -1; + for (let index = starts.length - 1; index >= 0; index -= 1) { + const start = starts[index]; + const charClass = classes[start]; + strongAfter[start] = nextStrong; + runAfter[start] = nextRun; + if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) nextStrong = STRONG_RTL; + else if (charClass === CLASS_LTR) nextStrong = STRONG_LTR; + if (charClass !== CLASS_TERMINATOR && charClass !== CLASS_MARK) nextRun = start; + } + + // Rule W5, with rule W2 folded in: a run of terminators joins an adjacent + // European number, unless an Arabic letter before that number made it Arabic. + const touchesEuropean = (start) => + start >= 0 && + classes[start] === CLASS_NUMBER && + EUROPEAN_NUMBER_CHAR.test(characterAt(text, start)) && + letterBefore[start] !== LETTER_ARABIC; + for (const start of starts) { + if (classes[start] !== CLASS_TERMINATOR) continue; + terminatorJoinsNumber[start] = touchesEuropean(runBefore[start]) || touchesEuropean(runAfter[start]) ? 1 : 0; + } + + // BD16: bracket pairs, from one pass with a stack. /** @type {{ closing: string, at: number }[]} */ const stack = []; - // Bounded like the other searches; a bracket whose partner is further away - // than this simply has no pair, which leaves it neutral. - const from = Math.max(0, index - MAX_SCAN_CHARACTERS); - const to = Math.min(text.length, index + MAX_SCAN_CHARACTERS); - for (let at = from; at < to; at = codePointEnd(text, at)) { - const char = characterAt(text, at); - // Only a bracket that is still neutral takes part; one that a preceding rule - // already resolved is not a bracket for N0's purposes. - if (classOf(char) !== CLASS_NEUTRAL) continue; - const canonical = canonicalBracket(char); + for (const start of starts) { + if (classes[start] !== CLASS_NEUTRAL) continue; + const canonical = canonicalBracket(characterAt(text, start)); const opening = BRACKET_OPENINGS.indexOf(canonical); if (opening >= 0) { - if (stack.length >= MAX_BRACKET_PAIRS) return null; - stack.push({ closing: BRACKET_CLOSINGS[opening], at }); + if (stack.length >= MAX_BRACKET_PAIRS) break; + stack.push({ closing: BRACKET_CLOSINGS[opening], at: start }); continue; } if (BRACKET_CLOSINGS.indexOf(canonical) < 0) continue; for (let depth = stack.length - 1; depth >= 0; depth -= 1) { if (stack[depth].closing !== canonical) continue; - const pair = { open: stack[depth].at, close: at }; - if (pair.open === index || pair.close === index) return pair; + const pair = { open: stack[depth].at, close: start, enclosed: null }; + bracketPairs.set(pair.open, pair); + bracketPairs.set(pair.close, pair); stack.length = depth; break; } } - return null; + + return { classes, baseBefore, strongBefore, strongAfter, terminatorJoinsNumber, bracketPairs }; } /** - * N0: a bracket pair takes the direction of the strong text it encloses. - * - * "שלום abc(def)" is the case that matters — a Latin parenthetical inside Hebrew, - * which Hebrew technical and legal writing is full of. The brackets enclose - * left-to-right text and follow left-to-right text, so they join it; without this - * they would be neutrals taking the paragraph's direction, and the caret after - * the closing bracket would sit on its other edge. - * - * Returns null when the rule does not apply — an unpaired bracket, or a pair - * enclosing nothing strong — leaving the character neutral for N1/N2. + * The last analysed text, kept so that repeated caret placements in one text + * node pay for the pass once. One entry is enough: the caret is resolved in the + * node it is in, and moving to another node is not the case that repeats. * + * @type {{ text: string, analysis: TextAnalysis } | null} + */ +let lastAnalysis = null; + +/** * @param {string} text - * @param {number} index - * @param {boolean} paragraphIsRtl - * @returns {boolean | null} + * @returns {TextAnalysis} */ -function bracketPairIsRtl(text, index, paragraphIsRtl) { - const pair = bracketPairAt(text, index); - if (!pair) return null; +function analysisFor(text) { + if (lastAnalysis?.text === text) return lastAnalysis.analysis; + const analysis = analyseText(text); + lastAnalysis = { text, analysis }; + return analysis; +} - let enclosesParagraphDirection = false; - let enclosesOppositeDirection = false; - for (let at = codePointEnd(text, pair.open); at < pair.close; at = codePointEnd(text, at)) { - const charClass = classOf(characterAt(text, at)); +/** + * What a bracket pair encloses, by strong direction. Computed on first use + * because most brackets are never asked about, and kept on the pair so a nested + * pair is not rescanned. + * + * @param {TextAnalysis} analysis + * @param {BracketPair} pair + * @returns {{ rtl: boolean, ltr: boolean }} + */ +function enclosedDirections(analysis, pair) { + if (pair.enclosed) return pair.enclosed; + let rtl = false; + let ltr = false; + for (let at = pair.open + 1; at < pair.close; at += 1) { + const charClass = analysis.classes[at]; // N0 counts numbers as right-to-left, exactly as N1 does. - const isRtl = charClass === CLASS_RTL || charClass === CLASS_NUMBER; - if (!isRtl && charClass !== CLASS_LTR) continue; - if (isRtl === paragraphIsRtl) enclosesParagraphDirection = true; - else enclosesOppositeDirection = true; + if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) rtl = true; + else if (charClass === CLASS_LTR) ltr = true; } - - if (enclosesParagraphDirection) return paragraphIsRtl; - if (!enclosesOppositeDirection) return null; - // The pair runs against the paragraph, so the text before it decides whether - // the brackets join that run or fall back to the paragraph. - const opposite = !paragraphIsRtl; - return strongSideIsRtl(text, pair.open, -1, paragraphIsRtl) === opposite ? opposite : paragraphIsRtl; + pair.enclosed = { rtl, ltr }; + return pair.enclosed; } /** @@ -504,41 +520,53 @@ function bracketPairIsRtl(text, index, paragraphIsRtl) { * number), N0 for a paired bracket, then N1/N2 and L1 for anything neutral, * which at the end of the text is always the paragraph's own direction. * + * Everything the rules need from the rest of the text comes from a single pass + * over it, cached, so this is a handful of array reads however long the text is. + * * @param {string} text * @param {number} index * @param {() => boolean} resolveParagraphIsRtl Paragraph direction, read only when needed since it forces a style recalc. * @returns {boolean} */ function characterIsRtl(text, index, resolveParagraphIsRtl) { - let at = codePointStart(text, index); - let char = characterAt(text, at); + const analysis = analysisFor(text); // W1: a non-spacing mark takes the class of the character before it, and the // paragraph direction when there is none. - for (let budget = MAX_SCAN_CHARACTERS; classOf(char) === CLASS_MARK; budget -= 1) { - if (at <= 0 || budget <= 0) return resolveParagraphIsRtl(); - at = codePointStart(text, at - 1); - char = characterAt(text, at); - } + const at = analysis.baseBefore[codePointStart(text, index)]; + if (at < 0) return resolveParagraphIsRtl(); - const charClass = classOf(char); + const charClass = analysis.classes[at]; if (charClass === CLASS_RTL) return true; if (charClass === CLASS_LTR) return false; if (charClass === CLASS_NUMBER) return false; - if (charClass === CLASS_TERMINATOR && terminatorTouchesEuropeanNumber(text, at)) return false; + if (charClass === CLASS_TERMINATOR && analysis.terminatorJoinsNumber[at]) return false; const paragraphIsRtl = resolveParagraphIsRtl(); + const sideIsRtl = (side) => (side === STRONG_NONE ? paragraphIsRtl : side === STRONG_RTL); // N0: a paired bracket resolves from what its pair encloses, before the - // general neutral rules see it. - const canonical = canonicalBracket(char); - if (BRACKET_OPENINGS.indexOf(canonical) >= 0 || BRACKET_CLOSINGS.indexOf(canonical) >= 0) { - const paired = bracketPairIsRtl(text, at, paragraphIsRtl); - if (paired !== null) return paired; + // general neutral rules see it. A pair enclosing nothing strong, and a bracket + // with no pair, fall through to those rules. + const pair = analysis.bracketPairs.get(at); + if (pair) { + const enclosed = enclosedDirections(analysis, pair); + const enclosesParagraphDirection = paragraphIsRtl ? enclosed.rtl : enclosed.ltr; + const enclosesOppositeDirection = paragraphIsRtl ? enclosed.ltr : enclosed.rtl; + if (enclosesParagraphDirection) return paragraphIsRtl; + if (enclosesOppositeDirection) { + // The pair runs against the paragraph, so the text before it decides + // whether the brackets join that run or fall back to the paragraph. + const opposite = !paragraphIsRtl; + return sideIsRtl(analysis.strongBefore[pair.open]) === opposite ? opposite : paragraphIsRtl; + } } - const before = strongSideIsRtl(text, at, -1, paragraphIsRtl); - const after = strongSideIsRtl(text, at, 1, paragraphIsRtl); + // N1 and N2: a neutral takes the direction its two sides share, and the + // paragraph's otherwise. At either end of the text the paragraph stands in, + // which is also what L1 gives for a trailing neutral. + const before = sideIsRtl(analysis.strongBefore[at]); + const after = sideIsRtl(analysis.strongAfter[at]); return before === after ? before : paragraphIsRtl; } diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js index 6a0f924c30..ee786e21b4 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js @@ -394,18 +394,28 @@ describe('resolveCollapsedCaretGeometry', () => { } }); - it('stops searching for a strong character rather than walking the whole node', () => { - // Every step of the search classifies a character, and the caret is resolved - // on each placement, so an unbroken run of neutrals would otherwise make - // key-repeat quadratic. Here a left-to-right paragraph holds two Hebrew - // letters more than the bound apart, with the caret in the neutral run - // between them: within the bound they would pull the caret right-to-left, - // past it the paragraph decides, which is what the neutral rules give for a - // run this long anyway. + it('joins a neutral to the strong characters around it however far away they are', () => { + // A left-to-right paragraph holding two Hebrew letters 1200 characters apart, + // with the caret in the neutral run between them: N1 gives the run their + // direction, and distance does not enter into it. Cutting the search off at + // a fixed length would answer with the paragraph instead. const gap = ' '.repeat(1200); const text = `א${gap}ב`; const offset = 1 + gap.length / 2; - expect(resolveCollapsedCaretGeometry(offset, text, ltrRun(text.length), LTR)?.x).toBe(CHAR_WIDTH * offset); + expect(resolveCollapsedCaretGeometry(offset, text, ltrRun(text.length), LTR)?.x).toBe(CHAR_WIDTH * (offset - 1)); + }); + + it('reads the text once however many carets are resolved in it', () => { + // The rules need the nearest strong character on each side, which is linear + // to walk to. Walking on every placement made an unbroken run of neutrals + // quadratic under key-repeat, so the pass is cached on the text instead. + const text = `${'%'.repeat(20000)} `; + const glyphs = rtlRun(text.length); + const started = Date.now(); + for (let caret = 0; caret < 2000; caret += 1) { + resolveCollapsedCaretGeometry(text.length - caret, text, glyphs, RTL); + } + expect(Date.now() - started).toBeLessThan(2000); }); it('carries the glyph vertical metrics onto the caret', () => { From 646d6a9646a0370b7c1ce4e5fee4dbfbde2d6730 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Wed, 2 Sep 2026 22:31:18 +0300 Subject: [PATCH 5/6] fix(v2): work the bidi rules out from the caret, not over the whole node Keeping a whole-node analysis on the text answered the last round's review but traded one quadratic case for another: every keystroke replaces the text, so the analysis misses on each one and the whole node is read again. Measured at 11.7 ms of main-thread work per keystroke in a 20,000-character paragraph and 29 ms in a 50,000-character one -- and the caret at the end of a right-to-left text node is exactly the boundary WebKit refuses, so that landed on essentially every keystroke in Hebrew or Arabic. Nothing is worked out up front now. Each rule walks from the caret outward and writes what it found back over the run it passed, since every position that walk crossed shares the answer. And what a character has behind it -- its own class, the character a mark sits on, the nearest strong character and strong letter before it, the left end of its terminator run -- cannot be changed by an edit after it, so an edit hands that half on and drops the rest. One resolution costs the distance to the nearest strong character, which is one character in ordinary text; all the resolutions over one text together cost a single pass over it; and a keystroke never walks a run that has already been walked. Per keystroke, appending at the end of the node: 20,000 characters of ordinary text 11.7 ms -> 0.077 ms 50,000 characters of ordinary text 29.0 ms -> 0.205 ms 20,000 characters of unbroken neutrals 12.6 ms -> 0.109 ms The answer is unchanged at every length: the classifier still agrees with DerivedBidiClass.txt on all 297,334 assigned code points, the rule still scores 35/35 on the general boundary set and 21/21 on the bracket set against Chromium, and the engine harness still repairs all 45 boundaries WebKit refuses across 8528 caret positions at eight zoom levels. Five tests cover what an edit may and may not keep, and three cover the cost -- sweeping a run forwards, sweeping it backwards, and typing into it. Those three opt out of the suite's retry, because a retried cost test can never fail: the retry runs the same text through a module that has already worked it out. Each of the twenty-four mutations is caught by the suite. Refs #3943 --- .../webkit-collapsed-caret-rect.js | 575 ++++++++++++++---- .../webkit-collapsed-caret-rect.test.js | 112 +++- 2 files changed, 554 insertions(+), 133 deletions(-) diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js index 7f66d109e0..c89c410efe 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js @@ -216,7 +216,11 @@ const MARK_EXCEPTION_CHAR = /[\u0CBF\u0CC6\u{11A07}\u{11A08}\u{11C3F}]/u; * symbols belonging to left-to-right scripts — 1.3% of assigned code points, * and visible only inside a right-to-left paragraph. */ +// The C0 controls are in this set on purpose: Bidi_Class B, S and WS cover tab, +// the line and paragraph breaks, and the file and record separators, all of +// which are neutral and all of which a text node can hold. const NEUTRAL_CHAR = + // eslint-disable-next-line no-control-regex /[\u0000-\u0040\u005B-\u0060\u007B-\u00A9\u00AB-\u00B4\u00B6-\u00B8\u00BB-\u00BF\u00D7\u00F7\u02B9-\u02BA\u02C2-\u02CF\u02D2-\u02DF\u02E5-\u02ED\u02EF-\u02FF\u0374-\u0375\u037E\u0384-\u0385\u0387\u03F6\u058A\u058D-\u07F9\u0BF3-\u0BFA\u0C78-\u0C7E\u0F3A-\u0F3D\u1390-\u1399\u1400\u1680\u169B-\u169C\u17F0-\u17F9\u1800-\u180E\u1940\u1944-\u1945\u19DE-\u19FF\u1FBD\u1FBF-\u1FC1\u1FCD-\u1FCF\u1FDD-\u1FDF\u1FED-\u1FEF\u1FFD-\u1FFE\u2000-\u200D\u2010-\u206F\u207A-\u207E\u208A-\u208E\u2100-\u2101\u2103-\u2106\u2108-\u2109\u2114\u2116-\u2118\u211E-\u2123\u2125\u2127\u2129\u213A-\u213B\u2140-\u2144\u214A-\u214D\u2150-\u215F\u2189-\u218B\u2190-\u2335\u237B-\u2394\u2396-\u2429\u2440-\u244A\u2460-\u2487\u24EA-\u26AB\u26AD-\u27FF\u2900-\u2B73\u2B76-\u2BFF\u2CE5-\u2CEA\u2CF9-\u2CFF\u2E00-\u2E5D\u2E80-\u2E99\u2E9B-\u2EF3\u2F00-\u2FD5\u2FF0-\u3004\u3008-\u3020\u3030\u3036-\u3037\u303D-\u303F\u309B-\u309C\u30A0\u30FB\u31C0-\u31E5\u31EF\u321D-\u321E\u3250-\u325F\u327C-\u327E\u32B1-\u32BF\u32CC-\u32CF\u3377-\u337A\u33DE-\u33DF\u33FF\u4DC0-\u4DFF\uA490-\uA4C6\uA60D-\uA60F\uA673-\uA67F\uA700-\uA721\uA788\uA828-\uA82B\uA874-\uA877\uAB6A-\uAB6B\uFB29-\uFE19\uFE30-\uFE52\uFE54-\uFE66\uFE68-\uFE6B\uFEFF\uFF01-\uFF20\uFF3B-\uFF40\uFF5B-\uFF65\uFFE2-\uFFE4\uFFE8-\uFFEE\uFFF0-\uFFFF\u{10101}\u{10140}-\u{1018C}\u{10190}-\u{1019C}\u{101A0}\u{1091F}-\u{10ED8}\u{11052}-\u{11065}\u{11660}-\u{1166C}\u{11FD5}-\u{11FF1}\u{16FE2}\u{1BCA0}-\u{1BCA3}\u{1CC00}-\u{1CCD5}\u{1CCFA}-\u{1CCFC}\u{1CD00}-\u{1CEB3}\u{1CEBA}-\u{1CED0}\u{1CEE0}-\u{1CEF0}\u{1D173}-\u{1D17A}\u{1D1E9}-\u{1D1EA}\u{1D200}-\u{1D245}\u{1D300}-\u{1D356}\u{1D6C1}\u{1D6DB}\u{1D6FB}\u{1D715}\u{1D735}\u{1D74F}\u{1D76F}\u{1D789}\u{1D7A9}\u{1D7C3}\u{1EEF0}-\u{1F02B}\u{1F030}-\u{1F093}\u{1F0A0}-\u{1F0AE}\u{1F0B1}-\u{1F0BF}\u{1F0C1}-\u{1F0CF}\u{1F0D1}-\u{1F0F5}\u{1F10B}-\u{1F10F}\u{1F12F}\u{1F16A}-\u{1F16F}\u{1F1AD}\u{1F260}-\u{1F265}\u{1F300}-\u{1F6D8}\u{1F6DC}-\u{1F6EC}\u{1F6F0}-\u{1F6FC}\u{1F700}-\u{1F7D9}\u{1F7E0}-\u{1F7EB}\u{1F7F0}\u{1F800}-\u{1F80B}\u{1F810}-\u{1F847}\u{1F850}-\u{1F859}\u{1F860}-\u{1F887}\u{1F890}-\u{1F8AD}\u{1F8B0}-\u{1F8BB}\u{1F8C0}-\u{1F8C1}\u{1F8D0}-\u{1F8D8}\u{1F900}-\u{1FA57}\u{1FA60}-\u{1FA6D}\u{1FA70}-\u{1FA7C}\u{1FA80}-\u{1FA8A}\u{1FA8E}-\u{1FAC6}\u{1FAC8}\u{1FACD}-\u{1FADC}\u{1FADF}-\u{1FAEA}\u{1FAEF}-\u{1FAF8}\u{1FB00}-\u{1FB92}\u{1FB94}-\u{1FBFA}\u{1FFFE}-\u{1FFFF}\u{2FFFE}-\u{2FFFF}\u{3FFFE}-\u{3FFFF}\u{4FFFE}-\u{4FFFF}\u{5FFFE}-\u{5FFFF}\u{6FFFE}-\u{6FFFF}\u{7FFFE}-\u{7FFFF}\u{8FFFE}-\u{8FFFF}\u{9FFFE}-\u{9FFFF}\u{AFFFE}-\u{AFFFF}\u{BFFFE}-\u{BFFFF}\u{CFFFE}-\u{CFFFF}\u{DFFFE}-\u{E0FFF}\u{EFFFE}-\u{EFFFF}\u{FFFFE}-\u{FFFFF}\u{10FFFE}-\u{10FFFF}]/u; const HIGH_SURROGATE_START = 0xd800; @@ -314,31 +318,44 @@ function canonicalBracket(char) { } /** - * Everything about one text node's characters that does not depend on the - * paragraph's direction, computed in a single pass over the text. + * What the rules need to know about one text node's characters, worked out as + * they ask for it and kept for as long as that text stands. * - * The rules below need, for a given character, the nearest strong character on - * each side, the character a mark sits on, and whether a terminator touches a - * European number. Walking for those on every caret placement is linear each - * time, which makes an unbroken run of neutrals quadratic under key-repeat — a - * run of 20,000 measured at twelve minutes of main-thread work. Cutting the - * walks off at a fixed distance would fix that and change the answer: a neutral - * run longer than the cutoff would stop seeing the strong characters that - * surround it, and a wide bracket pair would stop being a pair. So the work is - * bounded by doing it once instead, and the result stays exactly what the - * algorithm says at any length. + * For a given character the rules need the nearest strong character on each + * side, the character a mark sits on, whether a terminator touches a European + * number, and where the bracket pairs are. None of that can be cut off at a + * fixed distance: past a cutoff a neutral run stops seeing the strong characters + * around it and a wide bracket pair stops being a pair, so the answer changes. + * Nor can it be worked out for the whole node up front, because every keystroke + * replaces the text: a node analysed in full on each one makes a typing session + * quadratic, twelve milliseconds a keystroke in a twenty-thousand character + * paragraph. * - * Only the paragraph-independent half is precomputed, so a character that + * So each answer is worked out from the character outward, and written back over + * every position the walk passed — they all share it, which is what makes this + * sound. One resolution costs the distance to the nearest strong character, one + * character in ordinary text, and every resolution over one text together costs + * a single pass over it however they are spread. + * + * Zero means "not worked out yet" in every table, and each table is allocated + * the first time a rule reaches for it, so the common answers cost neither a + * pass nor an allocation. An edit hands on the half of the tables it cannot have + * changed, so a keystroke never walks a run twice. + * + * Only the paragraph-independent half is worked out here, so a character that * carries its own direction still resolves without reading the paragraph's, * which forces a style recalc. * * @typedef {object} TextAnalysis - * @property {Int8Array} classes Coarse Bidi_Class per UTF-16 unit. - * @property {Int32Array} baseBefore Nearest index at or before each one whose class is not a mark, or -1 (rule W1). - * @property {Int8Array} strongBefore Direction of the nearest strong character before each index: 1, 0, or -1 for none. - * @property {Int8Array} strongAfter The same, after each index. - * @property {Uint8Array} terminatorJoinsNumber Whether the terminator at each index touches a European run (rules W2 and W5). - * @property {Map} bracketPairs Keyed by the index of each bracket of the pair. + * @property {string} text The text every index below refers to. + * @property {Int8Array | null} classes Coarse Bidi_Class, at each code point's first unit. + * @property {Int32Array | null} baseBefore The character a mark sits on, biased (rule W1). + * @property {Int8Array | null} strongBefore Direction of the nearest strong character before each index. + * @property {Int8Array | null} strongAfter The same, after each index. + * @property {Uint8Array | null} letterBefore Nearest strong letter before each index (rule W2). + * @property {Int32Array | null} runFirst Left end of the terminator run reaching each index, biased (rule W5). + * @property {Int32Array | null} runPast Right end of that run, biased. + * @property {Map | null} bracketPairs Built the first time a bracket is asked about. */ /** @@ -348,139 +365,439 @@ function canonicalBracket(char) { * @property {{ rtl: boolean, ltr: boolean } | null} enclosed Filled in on first use. */ -/** Numbers influence a neighbouring neutral as though they were right-to-left (rule N1). */ -const STRONG_NONE = -1; -const STRONG_LTR = 0; -const STRONG_RTL = 1; +/** Every table reads zero as "this position has not been worked out yet". */ +const NOT_WORKED_OUT = 0; + +/** Direction of the nearest strong character. Numbers count as right-to-left, as rule N1 has them. */ +const STRONG_NONE = 1; +const STRONG_LTR = 2; +const STRONG_RTL = 3; -/** What the nearest strong character before a number is, for rule W2. */ -const LETTER_NONE = -1; -const LETTER_LTR = 0; -const LETTER_RTL = 1; -const LETTER_ARABIC = 2; +/** The nearest strong *letter*, which is what rule W2 reads — a number does not stand in for one there. */ +const LETTER_NONE = 1; +const LETTER_LTR = 2; +const LETTER_RTL = 3; +const LETTER_ARABIC = 4; + +/** A mark's base character, stored as its index plus two so zero stays free for "not worked out". */ +const BASE_NONE = 1; +const BASE_BIAS = 2; /** * @param {string} text * @returns {TextAnalysis} */ -function analyseText(text) { +function createAnalysis(text) { + return { + text, + classes: null, + baseBefore: null, + strongBefore: null, + strongAfter: null, + letterBefore: null, + runFirst: null, + runPast: null, + bracketPairs: null, + }; +} + +/** + * The analysis of the text the caret is in, kept so that resolutions in one text + * share their walks. One entry is enough: the caret is resolved in the node it + * is in, and moving to another node is not the case that repeats. + * + * @type {TextAnalysis | null} + */ +let lastAnalysis = null; + +/** + * @param {string} text + * @returns {TextAnalysis} + */ +function analysisFor(text) { + const previous = lastAnalysis; + if (previous?.text === text) return previous; + lastAnalysis = createAnalysis(text); + if (previous) carryUnchangedPrefix(previous, lastAnalysis); + return lastAnalysis; +} + +/** + * Keep what an edit did not invalidate. + * + * Everything worked out about a character from the text *before* it — its own + * class, the character a mark sits on, and the nearest strong character and + * strong letter behind it — is still true when the text after it changes. So an + * edit hands those on for the part of the text it left alone, and a keystroke + * never re-walks a run that has already been walked, even one with no strong + * character in it at all. + * + * What a position has *after* it, and the bracket pairing, are dropped: an edit + * moves both. + * + * @param {TextAnalysis} previous + * @param {TextAnalysis} analysis + */ +function carryUnchangedPrefix(previous, analysis) { + const text = analysis.text; + const previousText = previous.text; + // Typing and backspacing leave one text a prefix of the other, which the + // engine answers with a single comparison; anything else is compared until it + // differs, which is where the edit was. + let shared; + if (text.startsWith(previousText)) shared = previousText.length; + else if (previousText.startsWith(text)) shared = text.length; + else { + const limit = Math.min(previousText.length, text.length); + shared = 0; + while (shared < limit && previousText.charCodeAt(shared) === text.charCodeAt(shared)) shared += 1; + } + // A code point the edit split is worked out again: the units before it are the + // same, but the character they encode need not be. + const kept = codePointStart(text, shared); + if (kept <= 0) return; + + const carry = (table, Table) => { + if (!table) return null; + const carried = new Table(text.length); + carried.set(table.subarray(0, kept)); + return carried; + }; + analysis.classes = carry(previous.classes, Int8Array); + analysis.baseBefore = carry(previous.baseBefore, Int32Array); + analysis.strongBefore = carry(previous.strongBefore, Int8Array); + analysis.letterBefore = carry(previous.letterBefore, Uint8Array); + analysis.runFirst = carry(previous.runFirst, Int32Array); +} + +/** + * Coarse Bidi_Class of the code point starting at `at`, worked out once. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function classAt(analysis, at) { + const classes = analysis.classes ?? (analysis.classes = new Int8Array(analysis.text.length)); + const known = classes[at]; + if (known !== NOT_WORKED_OUT) return known; + const charClass = classOf(characterAt(analysis.text, at)); + classes[at] = charClass; + return charClass; +} + +/** + * Rule W1: the character a mark takes its class from, or -1 when the text starts + * with the mark. A run of marks all sit on the same character, so the walk + * writes its answer over the whole run. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function baseBefore(analysis, at) { + if (classAt(analysis, at) !== CLASS_MARK) return at; + const text = analysis.text; + const table = analysis.baseBefore ?? (analysis.baseBefore = new Int32Array(text.length)); + const known = table[at]; + if (known !== NOT_WORKED_OUT) return known === BASE_NONE ? -1 : known - BASE_BIAS; + + let base = at; + let walked = at; + for (;;) { + base = base > 0 ? codePointStart(text, base - 1) : -1; + if (base < 0) break; + // A mark whose own base is known is in this same run, so its base is this + // one's too, and the rest of the run does not have to be walked again. + const answered = table[base]; + if (answered !== NOT_WORKED_OUT) { + base = answered === BASE_NONE ? -1 : answered - BASE_BIAS; + break; + } + if (classAt(analysis, base) !== CLASS_MARK) break; + walked = base; + } + + // Only what this walk covered is written; the rest of the run already carries + // the same answer, and rewriting it would put the run's length back into the + // cost of every resolution. + const value = base < 0 ? BASE_NONE : base + BASE_BIAS; + for (let fill = at; fill >= walked;) { + table[fill] = value; + if (fill === 0) break; + fill = codePointStart(text, fill - 1); + } + return base; +} + +/** + * Direction of the nearest strong character before `at`, for rules N1 and N2. + * + * Everything between that character and `at` is neutral, so it has the same + * answer, and the walk writes it over all of them. An earlier walk's answer is + * taken where it is met, which is what keeps every resolution inside one neutral + * run to a single pass between them. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function strongBefore(analysis, at) { + const text = analysis.text; + const table = analysis.strongBefore ?? (analysis.strongBefore = new Int8Array(text.length)); + const known = table[at]; + if (known !== NOT_WORKED_OUT) return known; + + let side = STRONG_NONE; + let stop = -1; + for (let index = at; index > 0;) { + index = codePointStart(text, index - 1); + const charClass = classAt(analysis, index); + if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) side = STRONG_RTL; + else if (charClass === CLASS_LTR) side = STRONG_LTR; + else if (table[index] !== NOT_WORKED_OUT) side = table[index]; + else continue; + stop = index; + break; + } + + for (let fill = at; fill > stop;) { + table[fill] = side; + if (fill === 0) break; + fill = codePointStart(text, fill - 1); + } + return side; +} + +/** + * Direction of the nearest strong character after `at`, the mirror of + * `strongBefore` and written back the same way. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function strongAfter(analysis, at) { + const text = analysis.text; const length = text.length; - const classes = new Int8Array(length); - const baseBefore = new Int32Array(length).fill(-1); - const strongBefore = new Int8Array(length).fill(STRONG_NONE); - const strongAfter = new Int8Array(length).fill(STRONG_NONE); - const terminatorJoinsNumber = new Uint8Array(length); - /** @type {Map} */ - const bracketPairs = new Map(); - - /** @type {number[]} */ - const starts = []; - for (let at = 0; at < length;) { - const end = codePointEnd(text, at); - const charClass = classOf(characterAt(text, at)); - for (let unit = at; unit < end; unit += 1) classes[unit] = charClass; - starts.push(at); - at = end; + const table = analysis.strongAfter ?? (analysis.strongAfter = new Int8Array(length)); + const known = table[at]; + if (known !== NOT_WORKED_OUT) return known; + + let side = STRONG_NONE; + let stop = length; + for (let index = codePointEnd(text, at); index < length; index = codePointEnd(text, index)) { + const charClass = classAt(analysis, index); + if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) side = STRONG_RTL; + else if (charClass === CLASS_LTR) side = STRONG_LTR; + else if (table[index] !== NOT_WORKED_OUT) side = table[index]; + else continue; + stop = index; + break; } - // Forward: what each character has behind it. - const letterBefore = new Int8Array(length).fill(LETTER_NONE); - const runBefore = new Int32Array(length).fill(-1); - let lastBase = -1; - let lastStrong = STRONG_NONE; - let lastLetter = LETTER_NONE; - let lastRun = -1; - for (const start of starts) { - const charClass = classes[start]; - baseBefore[start] = charClass === CLASS_MARK ? lastBase : start; - strongBefore[start] = lastStrong; - letterBefore[start] = lastLetter; - runBefore[start] = lastRun; - if (charClass !== CLASS_MARK) lastBase = start; - if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) lastStrong = STRONG_RTL; - else if (charClass === CLASS_LTR) lastStrong = STRONG_LTR; + for (let fill = at; fill < stop; fill = codePointEnd(text, fill)) table[fill] = side; + return side; +} + +/** + * Rule W2 reads the nearest strong letter before a number, which is not the + * nearest strong character: a number does not stand in for one here. Written + * back over the walk like the others. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function letterBefore(analysis, at) { + const text = analysis.text; + const table = analysis.letterBefore ?? (analysis.letterBefore = new Uint8Array(text.length)); + const known = table[at]; + if (known !== NOT_WORKED_OUT) return known; + + let letter = LETTER_NONE; + let stop = -1; + for (let index = at; index > 0;) { + index = codePointStart(text, index - 1); + const charClass = classAt(analysis, index); if (charClass === CLASS_RTL) { - lastLetter = ARABIC_LETTER_CHAR.test(characterAt(text, start)) ? LETTER_ARABIC : LETTER_RTL; + letter = ARABIC_LETTER_CHAR.test(characterAt(text, index)) ? LETTER_ARABIC : LETTER_RTL; } else if (charClass === CLASS_LTR) { - lastLetter = LETTER_LTR; + letter = LETTER_LTR; + } else if (table[index] !== NOT_WORKED_OUT) { + letter = table[index]; + } else { + continue; } - if (charClass !== CLASS_TERMINATOR && charClass !== CLASS_MARK) lastRun = start; + stop = index; + break; } - // Backward: what each character has ahead of it. - const runAfter = new Int32Array(length).fill(-1); - let nextStrong = STRONG_NONE; - let nextRun = -1; - for (let index = starts.length - 1; index >= 0; index -= 1) { - const start = starts[index]; - const charClass = classes[start]; - strongAfter[start] = nextStrong; - runAfter[start] = nextRun; - if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) nextStrong = STRONG_RTL; - else if (charClass === CLASS_LTR) nextStrong = STRONG_LTR; - if (charClass !== CLASS_TERMINATOR && charClass !== CLASS_MARK) nextRun = start; + for (let fill = at; fill > stop;) { + table[fill] = letter; + if (fill === 0) break; + fill = codePointStart(text, fill - 1); } + return letter; +} - // Rule W5, with rule W2 folded in: a run of terminators joins an adjacent - // European number, unless an Arabic letter before that number made it Arabic. - const touchesEuropean = (start) => - start >= 0 && - classes[start] === CLASS_NUMBER && - EUROPEAN_NUMBER_CHAR.test(characterAt(text, start)) && - letterBefore[start] !== LETTER_ARABIC; - for (const start of starts) { - if (classes[start] !== CLASS_TERMINATOR) continue; - terminatorJoinsNumber[start] = touchesEuropean(runBefore[start]) || touchesEuropean(runAfter[start]) ? 1 : 0; +/** + * The first character of the run of terminators and marks reaching `at`, which + * is the left end rule W5 measures from. It depends only on the text before + * `at`, so an edit hands it on and a keystroke does not walk the run again. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function terminatorRunFirst(analysis, at) { + const text = analysis.text; + const table = analysis.runFirst ?? (analysis.runFirst = new Int32Array(text.length)); + const known = table[at]; + if (known !== NOT_WORKED_OUT) return known - 1; + + let first = at; + let walked = at; + while (first > 0) { + const before = codePointStart(text, first - 1); + const answered = table[before]; + if (answered !== NOT_WORKED_OUT) { + first = answered - 1; + break; + } + const charClass = classAt(analysis, before); + if (charClass !== CLASS_TERMINATOR && charClass !== CLASS_MARK) break; + first = before; + walked = before; } - // BD16: bracket pairs, from one pass with a stack. + // Stored biased by one, so that zero stays free for "not worked out", and only + // over what this walk covered: the rest of the run already says the same. + for (let fill = at; fill >= walked;) { + table[fill] = first + 1; + if (fill === 0) break; + fill = codePointStart(text, fill - 1); + } + return first; +} + +/** + * The right end of that run. Unlike its left end this cannot survive an edit, + * since an edit moves what follows — but within one text it still costs a single + * pass however many carets are resolved inside the run. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a code point. + * @returns {number} + */ +function terminatorRunPast(analysis, at) { + const text = analysis.text; + const length = text.length; + const table = analysis.runPast ?? (analysis.runPast = new Int32Array(length)); + const known = table[at]; + if (known !== NOT_WORKED_OUT) return known - 1; + + let past = at; + let walked = at; + for (;;) { + past = codePointEnd(text, past); + if (past >= length) { + past = length; + break; + } + const answered = table[past]; + if (answered !== NOT_WORKED_OUT) { + past = answered - 1; + break; + } + const charClass = classAt(analysis, past); + if (charClass !== CLASS_TERMINATOR && charClass !== CLASS_MARK) break; + walked = past; + } + + for (let fill = at; fill <= walked; fill = codePointEnd(text, fill)) table[fill] = past + 1; + return past; +} + +/** + * Rule W5 with rule W2 folded in: a run of terminators joins an adjacent + * European number, unless an Arabic letter before that number made it Arabic. + * + * Marks inside the run have already been resolved away by rule W1, so they do + * not separate a terminator from the number it belongs to. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a terminator. + * @returns {boolean} + */ +function terminatorJoinsNumber(analysis, at) { + const text = analysis.text; + const first = terminatorRunFirst(analysis, at); + const past = terminatorRunPast(analysis, at); + const touchesEuropean = (index) => + index >= 0 && + index < text.length && + classAt(analysis, index) === CLASS_NUMBER && + EUROPEAN_NUMBER_CHAR.test(characterAt(text, index)) && + letterBefore(analysis, index) !== LETTER_ARABIC; + return touchesEuropean(first > 0 ? codePointStart(text, first - 1) : -1) || touchesEuropean(past); +} + +/** + * Whether a character can take part in a bracket pair at all. Asked before the + * pairing is built, so that text without brackets never pays for that pass. + * + * @param {string} char + * @returns {boolean} + */ +function isBracket(char) { + const canonical = canonicalBracket(char); + return BRACKET_OPENINGS.indexOf(canonical) >= 0 || BRACKET_CLOSINGS.indexOf(canonical) >= 0; +} + +/** + * BD16: the bracket pairs of the text, from one pass with a stack. Built the + * first time a bracket is resolved and then kept, since pairing a closing + * bracket needs the openings before it and so cannot start from the caret. + * + * @param {TextAnalysis} analysis + * @param {number} at First UTF-16 unit of a bracket. + * @returns {BracketPair | null} + */ +function bracketPairAt(analysis, at) { + if (analysis.bracketPairs) return analysis.bracketPairs.get(at) ?? null; + + const text = analysis.text; + /** @type {Map} */ + const pairs = new Map(); /** @type {{ closing: string, at: number }[]} */ const stack = []; - for (const start of starts) { - if (classes[start] !== CLASS_NEUTRAL) continue; - const canonical = canonicalBracket(characterAt(text, start)); + for (let index = 0; index < text.length; index = codePointEnd(text, index)) { + const char = characterAt(text, index); + if (!isBracket(char) || classAt(analysis, index) !== CLASS_NEUTRAL) continue; + const canonical = canonicalBracket(char); const opening = BRACKET_OPENINGS.indexOf(canonical); if (opening >= 0) { if (stack.length >= MAX_BRACKET_PAIRS) break; - stack.push({ closing: BRACKET_CLOSINGS[opening], at: start }); + stack.push({ closing: BRACKET_CLOSINGS[opening], at: index }); continue; } - if (BRACKET_CLOSINGS.indexOf(canonical) < 0) continue; for (let depth = stack.length - 1; depth >= 0; depth -= 1) { if (stack[depth].closing !== canonical) continue; - const pair = { open: stack[depth].at, close: start, enclosed: null }; - bracketPairs.set(pair.open, pair); - bracketPairs.set(pair.close, pair); + const pair = { open: stack[depth].at, close: index, enclosed: null }; + pairs.set(pair.open, pair); + pairs.set(pair.close, pair); stack.length = depth; break; } } - return { classes, baseBefore, strongBefore, strongAfter, terminatorJoinsNumber, bracketPairs }; -} - -/** - * The last analysed text, kept so that repeated caret placements in one text - * node pay for the pass once. One entry is enough: the caret is resolved in the - * node it is in, and moving to another node is not the case that repeats. - * - * @type {{ text: string, analysis: TextAnalysis } | null} - */ -let lastAnalysis = null; - -/** - * @param {string} text - * @returns {TextAnalysis} - */ -function analysisFor(text) { - if (lastAnalysis?.text === text) return lastAnalysis.analysis; - const analysis = analyseText(text); - lastAnalysis = { text, analysis }; - return analysis; + analysis.bracketPairs = pairs; + return pairs.get(at) ?? null; } /** - * What a bracket pair encloses, by strong direction. Computed on first use + * What a bracket pair encloses, by strong direction. Worked out on first use * because most brackets are never asked about, and kept on the pair so a nested * pair is not rescanned. * @@ -490,10 +807,11 @@ function analysisFor(text) { */ function enclosedDirections(analysis, pair) { if (pair.enclosed) return pair.enclosed; + const text = analysis.text; let rtl = false; let ltr = false; - for (let at = pair.open + 1; at < pair.close; at += 1) { - const charClass = analysis.classes[at]; + for (let at = codePointEnd(text, pair.open); at < pair.close; at = codePointEnd(text, at)) { + const charClass = classAt(analysis, at); // N0 counts numbers as right-to-left, exactly as N1 does. if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) rtl = true; else if (charClass === CLASS_LTR) ltr = true; @@ -520,8 +838,9 @@ function enclosedDirections(analysis, pair) { * number), N0 for a paired bracket, then N1/N2 and L1 for anything neutral, * which at the end of the text is always the paragraph's own direction. * - * Everything the rules need from the rest of the text comes from a single pass - * over it, cached, so this is a handful of array reads however long the text is. + * What the rules need from the rest of the text is worked out from the + * character outward and kept, so this costs the distance to the nearest strong + * character rather than the length of the text. * * @param {string} text * @param {number} index @@ -533,14 +852,14 @@ function characterIsRtl(text, index, resolveParagraphIsRtl) { // W1: a non-spacing mark takes the class of the character before it, and the // paragraph direction when there is none. - const at = analysis.baseBefore[codePointStart(text, index)]; + const at = baseBefore(analysis, codePointStart(text, index)); if (at < 0) return resolveParagraphIsRtl(); - const charClass = analysis.classes[at]; + const charClass = classAt(analysis, at); if (charClass === CLASS_RTL) return true; if (charClass === CLASS_LTR) return false; if (charClass === CLASS_NUMBER) return false; - if (charClass === CLASS_TERMINATOR && analysis.terminatorJoinsNumber[at]) return false; + if (charClass === CLASS_TERMINATOR && terminatorJoinsNumber(analysis, at)) return false; const paragraphIsRtl = resolveParagraphIsRtl(); const sideIsRtl = (side) => (side === STRONG_NONE ? paragraphIsRtl : side === STRONG_RTL); @@ -548,7 +867,7 @@ function characterIsRtl(text, index, resolveParagraphIsRtl) { // N0: a paired bracket resolves from what its pair encloses, before the // general neutral rules see it. A pair enclosing nothing strong, and a bracket // with no pair, fall through to those rules. - const pair = analysis.bracketPairs.get(at); + const pair = isBracket(characterAt(text, at)) ? bracketPairAt(analysis, at) : null; if (pair) { const enclosed = enclosedDirections(analysis, pair); const enclosesParagraphDirection = paragraphIsRtl ? enclosed.rtl : enclosed.ltr; @@ -558,15 +877,15 @@ function characterIsRtl(text, index, resolveParagraphIsRtl) { // The pair runs against the paragraph, so the text before it decides // whether the brackets join that run or fall back to the paragraph. const opposite = !paragraphIsRtl; - return sideIsRtl(analysis.strongBefore[pair.open]) === opposite ? opposite : paragraphIsRtl; + return sideIsRtl(strongBefore(analysis, pair.open)) === opposite ? opposite : paragraphIsRtl; } } // N1 and N2: a neutral takes the direction its two sides share, and the // paragraph's otherwise. At either end of the text the paragraph stands in, // which is also what L1 gives for a trailing neutral. - const before = sideIsRtl(analysis.strongBefore[at]); - const after = sideIsRtl(analysis.strongAfter[at]); + const before = sideIsRtl(strongBefore(analysis, at)); + const after = sideIsRtl(strongAfter(analysis, at)); return before === after ? before : paragraphIsRtl; } diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js index ee786e21b4..89ba85791b 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js @@ -36,9 +36,47 @@ const ltrRun = const RTL = () => true; const LTR = () => false; +/** + * A node long enough that reading it again per caret, or per keystroke, is + * unmissable, swept at few enough carets that reading it *once* is nearly free. + * Both mistakes these tests are for cost a pass per caret, so the two sides sit + * about fifty times apart at this shape. + * + * The tests that use it each start their text with a different character, on + * purpose: what one resolution works out is handed on to a text that extends it, + * so a shared prefix would let one test answer the next and leave it measuring + * nothing. + */ +const RUN = 200000; +const SWEEP = 5000; + +/** + * Typing is measured on a shorter node with more keystrokes: the gap there is + * per keystroke whatever the node's length, since both sides of it are one pass + * over the node — a copy of what is already worked out, against working it all + * out again. + */ +const TYPED_RUN = 30000; +const KEYSTROKES = 200; + +/** + * The three cost tests below opt out of the suite's `retry`. They measure work + * that is only done once: a retry runs the same text through a module that has + * already worked it out, so it passes however slow the first attempt was, and a + * retried cost test can never fail. + */ +const MEASURED_ONCE = { retry: 0 }; + +/** Generous next to the ~20 ms these take, and far below the seconds they take when the walks are not kept. */ +const COST_BUDGET_MS = 1000; + const HEBREW = 'שלום'; const HEBREW_SPACE = `${HEBREW} `; +/** The two edges of the character after HEBREW in a right-to-left run: its logical end is the left one. */ +const TAIL_RIGHT = 100 - CHAR_WIDTH * HEBREW.length; +const TAIL_LEFT = TAIL_RIGHT - CHAR_WIDTH; + /** * An RTL paragraph whose Hebrew word is followed by a space and then a * left-to-right tail. The tail is painted to the LEFT of the space, so its rects @@ -405,17 +443,81 @@ describe('resolveCollapsedCaretGeometry', () => { expect(resolveCollapsedCaretGeometry(offset, text, ltrRun(text.length), LTR)?.x).toBe(CHAR_WIDTH * (offset - 1)); }); - it('reads the text once however many carets are resolved in it', () => { + it('walks a neutral run once for all the carets resolved along it', MEASURED_ONCE, () => { // The rules need the nearest strong character on each side, which is linear // to walk to. Walking on every placement made an unbroken run of neutrals - // quadratic under key-repeat, so the pass is cached on the text instead. - const text = `${'%'.repeat(20000)} `; + // quadratic under key-repeat. Sweeping forwards is what asks each walk to + // stop at the answer the one before it left. + const text = `a${'%'.repeat(RUN)}`; const glyphs = rtlRun(text.length); const started = Date.now(); - for (let caret = 0; caret < 2000; caret += 1) { + for (let caret = text.length - SWEEP; caret <= text.length; caret += 1) { + resolveCollapsedCaretGeometry(caret, text, glyphs, RTL); + } + expect(Date.now() - started).toBeLessThan(COST_BUDGET_MS); + }); + + it('writes what one walk found over the whole run it passed', MEASURED_ONCE, () => { + // Sweeping backwards instead: the first caret walks the run, and every + // caret behind it must be answered by what that walk wrote down rather than + // by a walk of its own. A separate text, since the sweep above would + // otherwise have answered this one. + const text = `b${'%'.repeat(RUN)}`; + const glyphs = rtlRun(text.length); + const started = Date.now(); + for (let caret = 0; caret < SWEEP; caret += 1) { resolveCollapsedCaretGeometry(text.length - caret, text, glyphs, RTL); } - expect(Date.now() - started).toBeLessThan(2000); + expect(Date.now() - started).toBeLessThan(COST_BUDGET_MS); + }); + + it('does not read the whole node again on every keystroke', MEASURED_ONCE, () => { + // Typing replaces the text, so nothing kept on the whole text survives a + // keystroke, and reading the whole node on each one made a typing session + // quadratic in turn — twelve milliseconds a keystroke in a node this long. + // What a character has behind it cannot be changed by an edit after it, so + // an edit hands that on. + let text = `c${'%'.repeat(TYPED_RUN)}`; + const started = Date.now(); + for (let keystroke = 0; keystroke < KEYSTROKES; keystroke += 1) { + text += '%'; + resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL); + } + expect(Date.now() - started).toBeLessThan(COST_BUDGET_MS); + }); + + it('works out a character again when an edit replaced it', () => { + // What an edit changed is not among what it hands on. Resolving the Latin + // tail first is what puts its answer in reach of the Hebrew one: both texts + // share the four Hebrew letters, and only the fifth character differs. + const latin = `${HEBREW}b`; + const hebrew = `${HEBREW}\u05d0`; + expect(resolveCollapsedCaretGeometry(latin.length, latin, rtlRun(latin.length), RTL)?.x).toBe(TAIL_RIGHT); + expect(resolveCollapsedCaretGeometry(hebrew.length, hebrew, rtlRun(hebrew.length), RTL)?.x).toBe(TAIL_LEFT); + }); + + it('works out a character again when an edit completed its surrogate pair', () => { + // A lone high surrogate and the pair it becomes share their first unit, so + // comparing units alone would hand on an answer for a different character. + // The half is left-to-right by default; the whole is Phoenician alf. + const half = `${HEBREW}\ud802`; + const whole = `${HEBREW}\ud802\udd00`; + expect(resolveCollapsedCaretGeometry(half.length, half, rtlRun(half.length), RTL)?.x).toBe(TAIL_RIGHT); + expect(resolveCollapsedCaretGeometry(whole.length, whole, rtlRun(whole.length), RTL)?.x).toBe(TAIL_LEFT); + }); + + it('does not hand on what an edit moved', () => { + // N1 reads both sides of a neutral. What is behind a character survives an + // edit after it; what is ahead of it does not, and keeping that would answer + // the second of these with the first one's tail. + const gap = ' '.repeat(40); + const offset = 1 + gap.length / 2; + const rtlTail = `\u05d0${gap}\u05d1`; + const ltrTail = `\u05d0${gap}b`; + expect(resolveCollapsedCaretGeometry(offset, rtlTail, ltrRun(rtlTail.length), LTR)?.x).toBe( + CHAR_WIDTH * (offset - 1), + ); + expect(resolveCollapsedCaretGeometry(offset, ltrTail, ltrRun(ltrTail.length), LTR)?.x).toBe(CHAR_WIDTH * offset); }); it('carries the glyph vertical metrics onto the caret', () => { From 4f7707d8cb0a550e1c5794caa38c61fad65a1e92 Mon Sep 17 00:00:00 2001 From: Nathaniel-260 Date: Wed, 2 Sep 2026 23:21:25 +0300 Subject: [PATCH 6/6] fix(v2): work a split surrogate pair out again, and count the walks instead of timing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the previous round, both valid. An edit that took away or replaced the low surrogate of a cached astral character left its high surrogate carrying the whole character's class, so a Hebrew line ending in Phoenician alf that lost the pair's second half kept a right-to-left answer for a lone surrogate, which Unicode gives Bidi_Class L. The code-point boundary the carry stops at is now taken in both texts, the old and the new, and a test covers the removed and the replaced low surrogate. The three cost tests measured wall-clock time against a one-second budget with retries off, which a loaded CI runner could miss with nothing wrong. The module now counts the positions its walks visit — one increment per position walked, nothing in production reads it — and the tests read that count: a pass over the text against a pass per caret sits thousands of counts apart, and the same on every machine. The node shrank from 200,000 to 20,000 characters since the count needs no clock gap, and the file runs in less than half the time. Six mutations — the Math.min, stopping a walk at an answer it meets in strongBefore and in terminatorRunFirst, writing a walk's answer over the run it passed, the carry as a whole and the terminator run's carry alone — each turn the suite red, and each is caught by the test written for it. --- .../webkit-collapsed-caret-rect.js | 41 +++++- .../webkit-collapsed-caret-rect.test.js | 122 ++++++++++++------ 2 files changed, 121 insertions(+), 42 deletions(-) diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js index c89c410efe..c93fdba7b6 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.js @@ -401,6 +401,29 @@ function createAnalysis(text) { }; } +/** + * Positions the walks below have visited, in total, since the module loaded. + * + * This is the module's cost: a resolution costs exactly the positions its walks + * cross, and every guarantee above about that cost — one pass per text however + * many carets, a handful of positions per keystroke — is a statement about this + * number. The tests that pin those guarantees read it instead of a clock, so + * they fail the same way on a loaded CI runner as on a quiet desktop, and a + * regression to a walk per caret is a count a thousand times over, not a + * budget missed by a few milliseconds. One increment per position walked, on a + * path that is already reading a typed array; nothing in production reads it. + */ +let positionsWalked = 0; + +/** + * @internal For the cost tests only. Everything else about this module shows in + * its answers; how much of the text it read to give them does not. + * @returns {number} + */ +export function positionsWalkedSoFar() { + return positionsWalked; +} + /** * The analysis of the text the caret is in, kept so that resolutions in one text * share their walks. One entry is enough: the caret is resolved in the node it @@ -452,9 +475,13 @@ function carryUnchangedPrefix(previous, analysis) { shared = 0; while (shared < limit && previousText.charCodeAt(shared) === text.charCodeAt(shared)) shared += 1; } - // A code point the edit split is worked out again: the units before it are the - // same, but the character they encode need not be. - const kept = codePointStart(text, shared); + // A code point the edit split is worked out again, whichever text it is whole + // in: the units before it are the same in both, but the character they encode + // need not be. An edit that completes a pair leaves the whole in the new text; + // one that takes its low surrogate away, or puts something else there, leaves + // the whole in the old text and a half in the new, and the half must not + // inherit the whole's class. + const kept = Math.min(codePointStart(text, shared), codePointStart(previousText, shared)); if (kept <= 0) return; const carry = (table, Table) => { @@ -507,6 +534,7 @@ function baseBefore(analysis, at) { for (;;) { base = base > 0 ? codePointStart(text, base - 1) : -1; if (base < 0) break; + positionsWalked += 1; // A mark whose own base is known is in this same run, so its base is this // one's too, and the rest of the run does not have to be walked again. const answered = table[base]; @@ -552,6 +580,7 @@ function strongBefore(analysis, at) { let stop = -1; for (let index = at; index > 0;) { index = codePointStart(text, index - 1); + positionsWalked += 1; const charClass = classAt(analysis, index); if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) side = STRONG_RTL; else if (charClass === CLASS_LTR) side = STRONG_LTR; @@ -587,6 +616,7 @@ function strongAfter(analysis, at) { let side = STRONG_NONE; let stop = length; for (let index = codePointEnd(text, at); index < length; index = codePointEnd(text, index)) { + positionsWalked += 1; const charClass = classAt(analysis, index); if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) side = STRONG_RTL; else if (charClass === CLASS_LTR) side = STRONG_LTR; @@ -619,6 +649,7 @@ function letterBefore(analysis, at) { let stop = -1; for (let index = at; index > 0;) { index = codePointStart(text, index - 1); + positionsWalked += 1; const charClass = classAt(analysis, index); if (charClass === CLASS_RTL) { letter = ARABIC_LETTER_CHAR.test(characterAt(text, index)) ? LETTER_ARABIC : LETTER_RTL; @@ -660,6 +691,7 @@ function terminatorRunFirst(analysis, at) { let walked = at; while (first > 0) { const before = codePointStart(text, first - 1); + positionsWalked += 1; const answered = table[before]; if (answered !== NOT_WORKED_OUT) { first = answered - 1; @@ -705,6 +737,7 @@ function terminatorRunPast(analysis, at) { past = length; break; } + positionsWalked += 1; const answered = table[past]; if (answered !== NOT_WORKED_OUT) { past = answered - 1; @@ -773,6 +806,7 @@ function bracketPairAt(analysis, at) { /** @type {{ closing: string, at: number }[]} */ const stack = []; for (let index = 0; index < text.length; index = codePointEnd(text, index)) { + positionsWalked += 1; const char = characterAt(text, index); if (!isBracket(char) || classAt(analysis, index) !== CLASS_NEUTRAL) continue; const canonical = canonicalBracket(char); @@ -811,6 +845,7 @@ function enclosedDirections(analysis, pair) { let rtl = false; let ltr = false; for (let at = codePointEnd(text, pair.open); at < pair.close; at = codePointEnd(text, at)) { + positionsWalked += 1; const charClass = classAt(analysis, at); // N0 counts numbers as right-to-left, exactly as N1 does. if (charClass === CLASS_RTL || charClass === CLASS_NUMBER) rtl = true; diff --git a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js index 89ba85791b..bce93fb795 100644 --- a/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js +++ b/packages/superdoc/src/core/v2-integration/webkit-collapsed-caret-rect.test.js @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it } from 'vite-plus/test'; import { detectCollapsedCaretRectQuirk, installWebKitCollapsedCaretRectFix, + positionsWalkedSoFar, resolveCollapsedCaretGeometry, } from './webkit-collapsed-caret-rect.js'; @@ -37,38 +38,59 @@ const RTL = () => true; const LTR = () => false; /** - * A node long enough that reading it again per caret, or per keystroke, is - * unmissable, swept at few enough carets that reading it *once* is nearly free. - * Both mistakes these tests are for cost a pass per caret, so the two sides sit - * about fifty times apart at this shape. + * The cost tests below count the positions the module's walks visit, read from + * `positionsWalkedSoFar()`, rather than timing anything. The count is the same + * on a loaded CI runner as on a quiet desktop, and the two shapes the tests tell + * apart — a pass over the text, against a pass per caret — sit thousands of + * times apart in it, where a budget in milliseconds can only hold them a few + * multiples apart and then only on an idle machine. * - * The tests that use it each start their text with a different character, on - * purpose: what one resolution works out is handed on to a text that extends it, - * so a shared prefix would let one test answer the next and leave it measuring - * nothing. + * A node long enough that a pass per caret is unmistakable in the count, swept + * at enough carets to make it so. The tests that use it each start their text + * with a different character, on purpose: what one resolution works out is + * handed on to a text that extends it, so a shared prefix would let one test + * answer the next and leave it counting nothing. */ -const RUN = 200000; -const SWEEP = 5000; +const RUN = 20000; +const SWEEP = 2000; /** - * Typing is measured on a shorter node with more keystrokes: the gap there is - * per keystroke whatever the node's length, since both sides of it are one pass - * over the node — a copy of what is already worked out, against working it all - * out again. + * Passes over the text a sweep may cost in total, however many carets it + * resolves. Two rules walk these texts — to the left end of the terminator run + * and to the nearest strong character before the caret — and each may walk the + * text once; the rest is a position or two per caret. A walk per caret would + * come to SWEEP passes. + */ +const PASSES_ALLOWED = 4; + +/** + * Typing is measured on the same length of node, a keystroke at a time. A + * keystroke may cost a few positions — the character it added and the one + * before it — and never a pass: a pass per keystroke is what the carry across + * an edit exists to prevent, and is what a node this long cost before it. */ -const TYPED_RUN = 30000; const KEYSTROKES = 200; +const POSITIONS_PER_KEYSTROKE_ALLOWED = 8; /** - * The three cost tests below opt out of the suite's `retry`. They measure work - * that is only done once: a retry runs the same text through a module that has - * already worked it out, so it passes however slow the first attempt was, and a - * retried cost test can never fail. + * The three cost tests below opt out of the suite's `retry`. Their assertion is + * deterministic, so a failure is a regression and never noise; and a retry would + * run the same text through a module that has already worked it out, count + * nothing, and pass — hiding exactly what the test is for. */ const MEASURED_ONCE = { retry: 0 }; -/** Generous next to the ~20 ms these take, and far below the seconds they take when the walks are not kept. */ -const COST_BUDGET_MS = 1000; +/** + * Positions the module walked while `work` ran. + * + * @param {() => void} work + * @returns {number} + */ +const positionsWalkedBy = (work) => { + const before = positionsWalkedSoFar(); + work(); + return positionsWalkedSoFar() - before; +}; const HEBREW = 'שלום'; const HEBREW_SPACE = `${HEBREW} `; @@ -450,11 +472,14 @@ describe('resolveCollapsedCaretGeometry', () => { // stop at the answer the one before it left. const text = `a${'%'.repeat(RUN)}`; const glyphs = rtlRun(text.length); - const started = Date.now(); - for (let caret = text.length - SWEEP; caret <= text.length; caret += 1) { - resolveCollapsedCaretGeometry(caret, text, glyphs, RTL); - } - expect(Date.now() - started).toBeLessThan(COST_BUDGET_MS); + const walked = positionsWalkedBy(() => { + for (let caret = text.length - SWEEP; caret <= text.length; caret += 1) { + resolveCollapsedCaretGeometry(caret, text, glyphs, RTL); + } + }); + // Something was counted, so this text was not answered by an earlier one's. + expect(walked).toBeGreaterThan(0); + expect(walked).toBeLessThanOrEqual(PASSES_ALLOWED * text.length); }); it('writes what one walk found over the whole run it passed', MEASURED_ONCE, () => { @@ -464,11 +489,13 @@ describe('resolveCollapsedCaretGeometry', () => { // otherwise have answered this one. const text = `b${'%'.repeat(RUN)}`; const glyphs = rtlRun(text.length); - const started = Date.now(); - for (let caret = 0; caret < SWEEP; caret += 1) { - resolveCollapsedCaretGeometry(text.length - caret, text, glyphs, RTL); - } - expect(Date.now() - started).toBeLessThan(COST_BUDGET_MS); + const walked = positionsWalkedBy(() => { + for (let caret = 0; caret < SWEEP; caret += 1) { + resolveCollapsedCaretGeometry(text.length - caret, text, glyphs, RTL); + } + }); + expect(walked).toBeGreaterThan(0); + expect(walked).toBeLessThanOrEqual(PASSES_ALLOWED * text.length); }); it('does not read the whole node again on every keystroke', MEASURED_ONCE, () => { @@ -476,14 +503,18 @@ describe('resolveCollapsedCaretGeometry', () => { // keystroke, and reading the whole node on each one made a typing session // quadratic in turn — twelve milliseconds a keystroke in a node this long. // What a character has behind it cannot be changed by an edit after it, so - // an edit hands that on. - let text = `c${'%'.repeat(TYPED_RUN)}`; - const started = Date.now(); - for (let keystroke = 0; keystroke < KEYSTROKES; keystroke += 1) { - text += '%'; - resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL); - } - expect(Date.now() - started).toBeLessThan(COST_BUDGET_MS); + // an edit hands that on. The first resolution pays for the node once and is + // left out of the count; the keystrokes are what is measured. + let text = `c${'%'.repeat(RUN)}`; + resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL); + const walked = positionsWalkedBy(() => { + for (let keystroke = 0; keystroke < KEYSTROKES; keystroke += 1) { + text += '%'; + resolveCollapsedCaretGeometry(text.length, text, rtlRun(text.length), RTL); + } + }); + expect(walked).toBeGreaterThan(0); + expect(walked).toBeLessThanOrEqual(POSITIONS_PER_KEYSTROKE_ALLOWED * KEYSTROKES); }); it('works out a character again when an edit replaced it', () => { @@ -506,6 +537,19 @@ describe('resolveCollapsedCaretGeometry', () => { expect(resolveCollapsedCaretGeometry(whole.length, whole, rtlRun(whole.length), RTL)?.x).toBe(TAIL_LEFT); }); + it('works out a character again when an edit split its surrogate pair', () => { + // The mirror image: the pair is worked out whole, then the edit takes its + // low surrogate away, or puts something else in its place. The units before + // the edit are the same in both texts, so a comparison of units alone would + // hand the whole character's class on to the half — and Phoenician alf is + // right-to-left where a lone surrogate is not. + const whole = `${HEBREW}\ud802\udd00`; + for (const split of [`${HEBREW}\ud802`, `${HEBREW}\ud802b`]) { + expect(resolveCollapsedCaretGeometry(whole.length, whole, rtlRun(whole.length), RTL)?.x).toBe(TAIL_LEFT); + expect(resolveCollapsedCaretGeometry(HEBREW.length + 1, split, rtlRun(split.length), RTL)?.x).toBe(TAIL_RIGHT); + } + }); + it('does not hand on what an edit moved', () => { // N1 reads both sides of a neutral. What is behind a character survives an // edit after it; what is ahead of it does not, and keeping that would answer