From 0c1f202186d5cbdb77f8f9f18a9931958ed88451 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 5 Jun 2026 02:02:33 +0200 Subject: [PATCH] fix(canvas-text): implement fix-stext T1-T5 (font-load invalidation, LRU, bidi opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes all 5 items of fix-stext.md for the canvas-measured text engine. None were implemented before; T1 was a P0 correctness bug, T2 a P1 memory leak. - T1 (P0) font-load invalidation: `invalidateFont` / `invalidateFontFamily` in measurement.ts (granular, vs the old all-or-nothing clear) + `useFontReady(dom)` hook that subscribes to `document.fonts` `loadingdone` via `ActiveDom.listen` (iframe/popup -safe, no raw listener), evicts the loaded family's cache FIRST, then bumps a reactive `epoch`. s-text.svelte folds `epoch` into the layout getter. Fixes the line count lying after a web font swaps in (getComputedStyle reports the requested family, unchanged on load, and the cache is keyed by the font string). - T2 (P1) bounded caches: `LruCache` (Map-backed, move-to-recent + evict-oldest) caps the per-font segment cache at 4096 entries and at most 24 fonts. invalidateFont reuses it cleanly. - T3: single getComputedStyle per reactive pass (merged `font` + `lineHeightPx`). - T4: hydration flash documented in s-text.svelte. - T5: bidi levels opt-in via `PrepareOptions.computeBidiLevels` (default false) — the walker never consumed `segLevels`, so it was wasted compute on every prepare. - tests: new canvas-text.test.ts (9 tests, deterministic canvas stub) covering LruCache semantics, invalidateFont/Family, per-font bounding, and bidi opt-in + line-count invariance. (The engine had ZERO tests before.) Invariants kept: no change to line-break decisions, no canvas painting, pure files (measurement/layout) stay Svelte-free, SSR-safe, strict TS. check 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/uix/eidos/components/s-text/s-text.svelte | 68 ++++--- .../eidos/lib/canvas-text/canvas-text.test.ts | 151 +++++++++++++++ src/uix/eidos/lib/canvas-text/fix-stext.md | 181 ++++++++++++++++++ src/uix/eidos/lib/canvas-text/layout.ts | 19 +- src/uix/eidos/lib/canvas-text/measurement.ts | 101 +++++++++- .../lib/canvas-text/use-canvas.svelte.ts | 57 ++++++ 6 files changed, 538 insertions(+), 39 deletions(-) create mode 100644 src/uix/eidos/lib/canvas-text/canvas-text.test.ts create mode 100644 src/uix/eidos/lib/canvas-text/fix-stext.md diff --git a/src/uix/eidos/components/s-text/s-text.svelte b/src/uix/eidos/components/s-text/s-text.svelte index 34dc79dd7..d1bb5d8fb 100644 --- a/src/uix/eidos/components/s-text/s-text.svelte +++ b/src/uix/eidos/components/s-text/s-text.svelte @@ -23,7 +23,11 @@ // `$app/environment` (couples the visual layer to a specific framework). const isBrowser = typeof window !== 'undefined'; import { ActiveEidos } from '$uix/eidos'; - import { useContainerWidth, useTextLayout } from '$uix/eidos/lib/canvas-text/use-canvas.svelte'; + import { + useContainerWidth, + useTextLayout, + useFontReady + } from '$uix/eidos/lib/canvas-text/use-canvas.svelte'; import { S_TEXT_LANGS } from './langs'; import type { STextProps } from './types'; @@ -79,45 +83,53 @@ return [inline, tagStyle].filter(Boolean).join(' '); }); - // Canvas measurement is opt-in: only when `text` AND we're in the - // browser. SSR + `text === undefined` path stays a plain element - // with CSS-only clamping. + // Canvas measurement is opt-in: only when `text` AND we're in the browser. SSR + + // `text === undefined` path stays a plain element with CSS-only `-webkit-line-clamp`. + // + // T4 (known, documented): on SSR `canvasActive` is false, so the server paints the + // clamp WITHOUT the exact "+N more lines" hint; the hint appears after the first + // client measurement → a small layout shift on hydration. Accepted — no extra deps + // and the SSR contract is unchanged; a fuller mitigation would reserve the hint's + // space, which isn't worth the complexity here. const canvasActive = $derived(text != null && isBrowser); let textEl = $state(null); const measure = useContainerWidth(() => (canvasActive ? textEl : null), eidos.dom); - // Resolve the actual font triple from the element's computed style - // so canvas measureText draws with the same font the DOM renders. - // Fallback to a reasonable default before the first paint (SSR / - // pre-mount) so the engine still produces a valid layout. - const font = $derived.by(() => { - if (!canvasActive || !textEl) return '14px sans-serif'; + // Re-measure when a web font swaps in (T1): `getComputedStyle().fontFamily` reports + // the REQUESTED family, unchanged on font load — so `useFontReady` evicts the stale + // cache + bumps `epoch`, which we fold into the layout getter below. + const fonts = useFontReady(eidos.dom); + + // Single `getComputedStyle` read per reactive pass (T3): resolve the canvas font + // triple AND the line-height-in-px from one style read. `getComputedStyle() + // .lineHeight` is either a `` ("21px") or `'normal'` — canvas needs an + // explicit number, so fall back to 1.5× font-size when normal. Fallbacks match the + // SSR / pre-mount path ('14px sans-serif', 21) so the engine still produces a valid + // layout before first paint. + const computed = $derived.by(() => { + if (!canvasActive || !textEl) return { font: '14px sans-serif', lineHeightPx: 21 }; const cs = getComputedStyle(textEl); const fontSize = cs.fontSize || '14px'; const fontFamily = cs.fontFamily || 'sans-serif'; const fontWeight = cs.fontWeight || '400'; - return `${fontWeight} ${fontSize} ${fontFamily}`; - }); - - // Line height in px — `getComputedStyle().lineHeight` returns either - // a `` ("21px") or `'normal'`. Canvas needs an explicit - // number, so fall back to 1.5× font-size when normal. - const lineHeightPx = $derived.by(() => { - if (!canvasActive || !textEl) return 21; - const cs = getComputedStyle(textEl); const lh = cs.lineHeight; - if (lh && lh.endsWith('px')) return parseFloat(lh) || 21; - const fs = parseFloat(cs.fontSize) || 14; - return fs * 1.5; + const lineHeightPx = + lh && lh.endsWith('px') ? parseFloat(lh) || 21 : (parseFloat(cs.fontSize) || 14) * 1.5; + return { font: `${fontWeight} ${fontSize} ${fontFamily}`, lineHeightPx }; }); - const tl = useTextLayout(() => ({ - text: text ?? '', - font, - maxWidth: measure.width, - lineHeight: lineHeightPx - })); + const tl = useTextLayout(() => { + // Read `fonts.epoch` so the prepare → layout pipeline re-runs after a web font + // loads (useFontReady already evicted the stale cached widths first). + void fonts.epoch; + return { + text: text ?? '', + font: computed.font, + maxWidth: measure.width, + lineHeight: computed.lineHeightPx + }; + }); const measuredLineCount = $derived(canvasActive ? tl.lineCount : 0); const hiddenLines = $derived( diff --git a/src/uix/eidos/lib/canvas-text/canvas-text.test.ts b/src/uix/eidos/lib/canvas-text/canvas-text.test.ts new file mode 100644 index 000000000..7336168cb --- /dev/null +++ b/src/uix/eidos/lib/canvas-text/canvas-text.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + LruCache, + getFontMeasurementState, + getSegmentMetrics, + invalidateFont, + invalidateFontFamily, + clearMeasurementCaches +} from './measurement.js'; +import { prepare, prepareWithSegments, layout, clearCache } from './layout.js'; + +// ─── Deterministic canvas stub ────────────────────────────────────────────────── +// The server (node) test env has no canvas. The measurement engine only needs +// `measureText(s).width`, so a deterministic stub (width = code-point count × +// factor) lets us exercise the cache / invalidation / opt-in logic without a real +// browser. `widthFactor` is mutable so a test can simulate a font swapping in +// (different metrics for the same font string). +let widthFactor = 7; + +class FakeCtx { + font = ''; + measureText(s: string): { width: number } { + return { width: [...s].length * widthFactor }; + } +} + +class FakeOffscreenCanvas { + constructor(_width: number, _height: number) {} + getContext(): FakeCtx { + return new FakeCtx(); + } +} + +// getMeasureContext() prefers OffscreenCanvas and creates it lazily on first use. +(globalThis as { OffscreenCanvas?: unknown }).OffscreenCanvas = + FakeOffscreenCanvas as unknown as typeof OffscreenCanvas; + +beforeEach(() => { + widthFactor = 7; + clearMeasurementCaches(); + clearCache(); +}); + +// ─── LruCache (T2) ────────────────────────────────────────────────────────────── + +describe('LruCache', () => { + it('bounds size to capacity, evicting the oldest entry', () => { + const cache = new LruCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.set('d', 4); + expect(cache.size).toBe(3); + expect(cache.has('a')).toBe(false); // oldest evicted + expect(cache.has('d')).toBe(true); + }); + + it('keeps recently-used entries (LRU, not FIFO)', () => { + const cache = new LruCache(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.get('a'); // touch a → most recent + cache.set('d', 4); // over capacity → evict oldest, which is now b + expect(cache.has('a')).toBe(true); + expect(cache.has('b')).toBe(false); + expect(cache.has('c')).toBe(true); + expect(cache.has('d')).toBe(true); + }); + + it('supports delete and clear', () => { + const cache = new LruCache(3); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.delete('a')).toBe(true); + expect(cache.has('a')).toBe(false); + cache.clear(); + expect(cache.size).toBe(0); + }); +}); + +// ─── invalidateFont / invalidateFontFamily (T1) ───────────────────────────────── + +describe('font invalidation (T1)', () => { + it('invalidateFont re-measures a segment instead of returning the stale cache', () => { + const font = '400 16px TestFont'; + const first = getSegmentMetrics('abc', getFontMeasurementState(font, false).cache); + expect(first.width).toBe(3 * 7); + + widthFactor = 10; // the real font finished loading → different metrics + + // Without invalidation the cached (fallback-measured) width persists. + expect(getSegmentMetrics('abc', getFontMeasurementState(font, false).cache).width).toBe(3 * 7); + + invalidateFont(font); + + // After invalidation the segment is measured fresh. + expect(getSegmentMetrics('abc', getFontMeasurementState(font, false).cache).width).toBe(3 * 10); + }); + + it('invalidateFontFamily evicts by substring and leaves other families intact', () => { + const f1 = '400 16px "My Font", sans-serif'; + const f2 = '700 16px "My Font"'; + const f3 = '400 16px Other'; + getSegmentMetrics('x', getFontMeasurementState(f1, false).cache); + getSegmentMetrics('x', getFontMeasurementState(f2, false).cache); + getSegmentMetrics('x', getFontMeasurementState(f3, false).cache); + + invalidateFontFamily('My Font'); + + // Evicted fonts come back as fresh empty caches; the unrelated one is untouched. + expect(getFontMeasurementState(f1, false).cache.size).toBe(0); + expect(getFontMeasurementState(f2, false).cache.size).toBe(0); + expect(getFontMeasurementState(f3, false).cache.size).toBe(1); + }); +}); + +// ─── per-font cache bounding (T2 integration) ─────────────────────────────────── + +describe('measurement cache bounding (T2)', () => { + it('keeps a single font cache bounded under many unique segments', () => { + const cache = getFontMeasurementState('400 16px Bound', false).cache; + for (let i = 0; i < 5000; i++) getSegmentMetrics(`s${i}`, cache); + expect(cache.size).toBeGreaterThan(0); + expect(cache.size).toBeLessThan(5000); // eviction happened + expect(cache.size).toBeLessThanOrEqual(4096); // documented MAX_SEGMENTS_PER_FONT + }); +}); + +// ─── bidi opt-in (T5) ─────────────────────────────────────────────────────────── + +describe('bidi opt-in (T5)', () => { + const FONT = '400 16px T'; + + it('does not compute segLevels by default', () => { + const prepared = prepareWithSegments('שלום world', FONT); + expect(prepared.segLevels).toBeNull(); + }); + + it('computes segLevels when explicitly requested for bidi text', () => { + const prepared = prepareWithSegments('שלום world', FONT, { computeBidiLevels: true }); + expect(prepared.segLevels).not.toBeNull(); + }); + + it('the bidi option never changes the line count', () => { + const text = 'aaa bbb ccc ddd eee'; + const plain = layout(prepare(text, FONT), 100, 20); + const withBidi = layout(prepare(text, FONT, { computeBidiLevels: true }), 100, 20); + expect(withBidi.lineCount).toBe(plain.lineCount); + }); +}); diff --git a/src/uix/eidos/lib/canvas-text/fix-stext.md b/src/uix/eidos/lib/canvas-text/fix-stext.md new file mode 100644 index 000000000..814e68029 --- /dev/null +++ b/src/uix/eidos/lib/canvas-text/fix-stext.md @@ -0,0 +1,181 @@ +# fix-stext.md — correcciones del motor de medición de texto (eidos ``) + +> **Estado: IMPLEMENTADO (2026-06-05).** Los 5 ítems (T1–T5) aplicados y testeados +> (`canvas-text.test.ts`, 9/9; `npm run check` 0 errores). Resumen: +> - **T1** — `invalidateFont` / `invalidateFontFamily` en `measurement.ts` + hook +> `useFontReady(dom)` (suscribe `document.fonts` `loadingdone` vía `ActiveDom.listen`, +> invalida la familia cargada, **luego** bumpea `epoch`); `s-text.svelte` pliega el +> `epoch` en el getter de `useTextLayout`. Diseño: **opción B** del doc (hook separado). +> - **T2** — `LruCache` (Map-backed) acota la caché por fuente a +> `MAX_SEGMENTS_PER_FONT = 4096` + `MAX_FONTS = 24` fuentes, ambas LRU. +> - **T3** — un solo `getComputedStyle` por pasada reactiva (`computed` deriva `font` + +> `lineHeightPx`). +> - **T4** — flash de hidratación documentado en `s-text.svelte`. +> - **T5** — bidi opt-in vía `PrepareOptions.computeBidiLevels` (default `false`). +> +> Respetado lo "fuera de alcance": sin Knuth-Plass, sin pintar a canvas, sin métricas +> verticales, sin cambiar ninguna decisión de salto de línea. + +## Objetivo + +Corregir tres problemas concretos del motor `canvas-text` y su componente ``, **sin alterar el contrato de fidelidad con el navegador**. Por orden de prioridad: + +1. **(P0, corrección)** El conteo de líneas miente cuando una web font carga después de la primera medición. No hay invalidación por carga de fuente. +2. **(P1, memoria)** Las cachés de medición crecen sin límite. No hay desalojo ni invalidación granular por fuente. +3. **(P2, perf/UX, menores)** Doble lectura de `getComputedStyle`, flash de hidratación del hint, y metadata bidi calculada pero nunca consumida. + +--- + +## Contexto del sistema (leer antes de tocar nada) + +`` es **DOM-painted, canvas-measured**. El texto lo pinta el DOM (`

{text}

` con CSS); el canvas (`measureText`) se usa **solo para medir y contar líneas exactas** sin disparar reflow del documento. El valor del motor es producir el **mismo número de líneas que el navegador va a pintar**, replicando su algoritmo greedy y sus rarezas por motor (Safari 1/64 px, inflado de emoji canvas-vs-DOM, carry de CJK tras comilla de cierre en Chromium, kinsoku, etc.). + +Estructura relevante (resolver rutas reales en el repo; pistas por los imports): +- `…/eidos/lib/canvas-text/measurement.ts` — `measureText`, cachés de métricas por fuente, perfil de motor, corrección de emoji. +- `…/eidos/lib/canvas-text/layout.ts` — pipeline `prepare`/`prepareWithSegments` → `layout`/`layoutWithLines`; `clearCache()`, `setLocale()`. +- `…/eidos/lib/canvas-text/line-break.ts` — walker greedy fiel al navegador. +- `…/eidos/lib/canvas-text/analysis.ts` — segmentación (`Intl.Segmenter`), kinsoku, fusión de URLs/numéricos, whitespace. +- `…/eidos/lib/canvas-text/bidi.ts` — niveles bidi (metadata del rich path). +- `…/eidos/lib/canvas-text/use-canvas.svelte.ts` — hooks runes `useContainerWidth`, `useTextLayout`. +- `…/eidos/components/s-text/s-text.svelte` — el componente. + +--- + +## Invariantes (NO romper) + +- **No cambiar las decisiones de salto de línea.** El walker debe seguir replicando el greedy del navegador. No introducir Knuth-Plass, justificación, balanceo, ni guionado nuevo: desincronizaría el conteo con el render real del DOM. +- **No pintar a canvas.** El canvas solo mide. El DOM sigue siendo el que renderiza. +- **Nada de observadores/listeners crudos.** Igual que `useContainerWidth` enruta el resize por `ActiveDom.observeResize` (iframe/popup-safe, lifecycle-tracked), cualquier suscripción nueva (p. ej. a `document.fonts`) debe enrutarse por `ActiveDom`. Si `ActiveDom` no expone un helper para esto, **añadirlo** siguiendo el patrón de `observeResize` (devuelve su propia función de limpieza). No usar `addEventListener` directo sobre `document`. +- **SSR-safe.** El servidor no tiene canvas ni `document.fonts`. Todo lo nuevo debe estar guardado tras los checks de browser existentes (`typeof window`, `canvasActive`). +- **TS estricto.** Sin `any` nuevos, sin `@ts-expect-error`. Respetar el branding opaco de `PreparedText`. +- **Mantener el código de tipo puro (`measurement.ts`, `layout.ts`, etc.) libre de Svelte.** La reactividad vive solo en `*.svelte.ts` y en el componente. + +--- + +## T1 — (P0) Invalidación por carga de fuente + +### Problema +En `s-text.svelte`, `font` es un `$derived.by` que lee `getComputedStyle(textEl).fontFamily/fontSize/fontWeight`. `getComputedStyle` devuelve la **cadena de familia pedida**, que **no cambia** cuando el binario de la web font termina de descargar. Por tanto: + +- Si la primera medición ocurre antes de que la fuente cargue, `measureText` mide contra el **fallback**. +- Cuando la fuente real carga, el DOM repinta y el número de líneas real cambia, pero **ninguna dependencia reactiva cambió** → el `$derived` no se reejecuta → `useTextLayout` no re-prepara → `tl.lineCount` se queda obsoleto. +- Consecuencia visible: `clamp` y el hint "+N more lines" quedan mal tras el swap de fuente. + +### Causa raíz crítica (no pasar por alto) +**Bumpear la reactividad NO basta.** Las métricas por segmento se cachean en `measurement.ts` con clave por **string de fuente** (p. ej. `"400 14px Inter"`), que es **idéntico antes y después** de cargar la fuente. Si solo fuerzas el re-`prepare` sin **desalojar la caché de esa fuente**, `getSegmentMetrics` devolverá los anchos viejos (medidos contra el fallback). El orden obligatorio es: **desalojar caché de la fuente → luego disparar el re-layout reactivo.** + +### Cambios requeridos + +**(a) `measurement.ts`: invalidación granular por fuente.** +Añadir una función pública que desaloje solo la caché de una fuente concreta (no el `clear` global todo-o-nada actual): + +```ts +// Desaloja la caché de métricas de una fuente concreta (string exacto usado como clave) +// y su corrección de emoji asociada. No toca el resto de fuentes. +export function invalidateFont(font: string): void { /* segmentMetricCaches.delete(font); emojiCorrectionCache.delete(font); */ } + +// Opcional pero útil: desaloja por familia (substring/normalización), ya que el string +// de fuente incluye peso y tamaño y una misma familia genera múltiples claves. +export function invalidateFontFamily(family: string): void { /* … */ } +``` + +**(b) `use-canvas.svelte.ts`: hook reactivo de readiness de fuentes.** +Crear `useFontReady(dom)` que: +- Lea `document.fonts.status` y se suscriba a los eventos de carga (`loadingdone`) **a través de `ActiveDom`** (añadir helper en `ActiveDom` si hace falta; debe devolver cleanup como `observeResize`). +- Al resolver una carga: llame a la invalidación de `measurement.ts` para la(s) fuente(s) afectada(s) **y después** incremente un contador reactivo (`$state`). +- Exponga ese contador como `{ get epoch() }`. +- Sea no-op y seguro en SSR (`typeof document === 'undefined'` o sin `document.fonts`). + +Decisión de diseño aceptable (elige una, documenta cuál): +- **B (preferida):** hook separado `useFontReady(dom)` que el componente compone, y cuyo `epoch` se incluye en el objeto que pasa a `useTextLayout` (ver (c)). Mantiene `useTextLayout` genérico. +- **A:** extender `useTextLayout(getOpts, dom?)` para que acepte el `dom` y gestione la readiness internamente. Solo si no complica la firma para los demás llamantes. + +Si varias instancias de `` se suscriben a `document.fonts`, está bien; la invalidación es idempotente. Centralizar en un único listener compartido (módulo reactivo en este `.svelte.ts`) es una optimización **opcional** — si la haces, sigue enrutando la suscripción por `ActiveDom`. + +**(c) `s-text.svelte`: cablear el epoch al pipeline.** +Incluir el `epoch` de `useFontReady(eidos.dom)` dentro del getter que se pasa a `useTextLayout`, de modo que el `$derived.by` interno se reejecute cuando la fuente cargue. Garantizar que la invalidación de caché de (a) ocurre **antes** de que el derived relea (el handler de (b) debe invalidar y luego bumpear). + +### Criterio de aceptación +- Render de `` con una web font que carga **después** del mount: tras `document.fonts.ready`, `tl.lineCount` (y el `bind:lineCount`) coincide con el conteo real del DOM, y el hint "+N more lines" se actualiza. +- Sin web fonts (solo fuentes de sistema), comportamiento idéntico al actual; sin re-mediciones espurias. +- SSR no ejecuta nada del nuevo camino; sin warnings de hidratación nuevos más allá del ya conocido (ver T4). +- Test unitario de `invalidateFont`: medir un segmento, mutar el contexto/fuente, invalidar, volver a medir y comprobar que se remide (no devuelve el valor cacheado). + +--- + +## T2 — (P1) Acotar las cachés de medición + +### Problema +En `measurement.ts`, `segmentMetricCaches: Map>` crece sin límite: cada segmento distinto medido por cada fuente queda cacheado para siempre. En apps de vida larga con mucho texto único (comentarios, feeds) es crecimiento de memoria monótono. `clearMeasurementCaches()` es todo-o-nada. + +### Cambios requeridos +- Acotar la **caché interna por fuente** (`seg → SegmentMetrics`) con desalojo **LRU** y capacidad configurable (constante con un default razonable, p. ej. unos pocos miles de entradas por fuente; documentar el número elegido). +- Considerar también un tope en el número de fuentes simultáneas en `segmentMetricCaches` (LRU de segundo nivel) si es barato; si no, dejarlo documentado como límite conocido. +- Reusar la implementación LRU para que `invalidateFont` (T1) encaje limpio (borrar la entrada de fuente = borrar su LRU). +- `emojiCorrectionCache` es pequeña (una entrada por fuente); basta con que `invalidateFont` la limpie también. No requiere LRU propio salvo que sea trivial. + +### Criterio de aceptación +- Medir N segmentos únicos por encima de la capacidad y comprobar que el tamaño de la caché de esa fuente se mantiene acotado (entradas ≤ capacidad). +- El desalojo es LRU (la entrada usada más recientemente sobrevive). +- Sin regresión de rendimiento medible en el camino caliente de `layout()` (el desalojo solo ocurre en `prepare`/medición, no en el walk de líneas). + +--- + +## T3 — (P2) Una sola lectura de `getComputedStyle` + +### Problema +En `s-text.svelte`, `font` y `lineHeightPx` son dos `$derived.by` independientes que cada uno llama a `getComputedStyle(textEl)`. Son dos recalcs de estilo donde basta uno. + +### Cambio requerido +Unificar en un único `$derived.by` que lea `getComputedStyle(textEl)` una vez y derive de ahí `{ font, lineHeightPx }` (o un objeto `computed` del que cuelguen ambos). Mantener exactamente los mismos fallbacks actuales (`'14px sans-serif'`, `21`, `1.5×fontSize` cuando `line-height: normal`). + +### Criterio de aceptación +- Una sola llamada a `getComputedStyle` por evaluación reactiva. +- Valores resueltos idénticos a los actuales en los mismos casos (incluido `line-height: normal`). + +--- + +## T4 — (P2) Flash de hidratación del hint de clamp (documentar/mitigar) + +### Problema +`canvasActive` es `false` en SSR, así que el servidor pinta con `-webkit-line-clamp` (sin conteo exacto) y el hint "+N more lines" solo aparece tras la medición en cliente → pequeño salto de layout en la hidratación. + +### Cambio requerido (ligero) +- Como mínimo: **documentarlo** con un comentario en el componente. +- Mitigación opcional si es de bajo riesgo: reservar espacio para el hint o evitar el salto (p. ej. no insertar el nodo del hint hasta que `isClipped` sea estable). No introducir dependencias nuevas ni cambiar el contrato SSR. + +### Criterio de aceptación +- Comportamiento SSR/hidratación documentado; si se mitiga, sin romper el render SSR ni el camino sin `text`. + +--- + +## T5 — (P3) Metadata bidi: hacerla opt-in + +### Problema +`computeSegmentLevels` se ejecuta en el rich path (`prepareWithSegments`) y rellena `segLevels`, pero **el line-break no lo consume** (el DOM hace su propio reordenado bidi). Es cómputo desperdiciado en cada `prepareWithSegments`. + +### Cambio requerido +- Hacer el cálculo de niveles bidi **opt-in** vía `PrepareOptions` (p. ej. `computeBidiLevels?: boolean`, default `false`). Solo calcular `segLevels` cuando se pida. +- `` no lo pide → se ahorra el coste en el camino por defecto. +- No eliminar `bidi.ts` ni la capacidad; solo dejar de pagarla cuando nadie la usa. + +### Criterio de aceptación +- Por defecto, `prepareWithSegments` no invoca `computeSegmentLevels` y `segLevels` queda `null`. +- Con la opción activada, el comportamiento es el actual. +- Sin cambios en el conteo de líneas en ningún caso (la metadata bidi nunca afectó al walk). + +--- + +## Verificación global + +- `tsc` sin errores nuevos; lint limpio. +- Suite existente del motor en verde (no debe cambiar ningún conteo de líneas en los casos actuales: T1–T5 son correcciones de invalidación/memoria/perf, **no** de semántica de salto). +- Añadir tests para: `invalidateFont` (T1), acotación LRU (T2), unificación de `getComputedStyle` (T3, si hay test de componente), y opt-in bidi (T5). +- Comprobación manual en navegador para T1: página con web font de carga diferida + ``; el conteo debe corregirse al cargar la fuente. + +## Fuera de alcance (NO hacer) + +- Algoritmos de salto "mejores" (Knuth-Plass, balanceo, justificación, guionado nuevo). +- Pintar texto en canvas / capa de render propia. +- Extracción de métricas verticales (ascent/descent/cap-height) o recorte estilo Capsize: el DOM ya gestiona el render vertical. +- Cualquier cambio que pueda hacer que el `lineCount` deje de coincidir con lo que pinta el navegador. diff --git a/src/uix/eidos/lib/canvas-text/layout.ts b/src/uix/eidos/lib/canvas-text/layout.ts index e1093a73c..b71496c41 100644 --- a/src/uix/eidos/lib/canvas-text/layout.ts +++ b/src/uix/eidos/lib/canvas-text/layout.ts @@ -146,6 +146,12 @@ export type PrepareProfile = { export type PrepareOptions = { whiteSpace?: WhiteSpaceMode; + /** + * Compute rich-path bidi levels (`segLevels`). Off by default: the line-break + * walker never consumes them (the DOM does its own bidi reordering), so it is + * wasted work unless a custom renderer asks for it. (fix-stext.md T5) + */ + computeBidiLevels?: boolean; }; export type PreparedLineChunk = { @@ -193,7 +199,8 @@ function createEmptyPrepared( function measureAnalysis( analysis: TextAnalysis, font: string, - includeSegments: boolean + includeSegments: boolean, + computeBidiLevels: boolean ): InternalPreparedText | PreparedTextWithSegments { const graphemeSegmenter = getSharedGraphemeSegmenter(); const engineProfile = getEngineProfile(); @@ -366,8 +373,12 @@ function measureAnalysis( preparedStartByAnalysisIndex, preparedEndByAnalysisIndex ); + // Opt-in (fix-stext.md T5): the walker never reads segLevels; skip the cost + // unless a custom renderer explicitly asks for bidi metadata. const segLevels = - segStarts === null ? null : computeSegmentLevels(analysis.normalized, segStarts); + segStarts === null || !computeBidiLevels + ? null + : computeSegmentLevels(analysis.normalized, segStarts); if (segments !== null) { return { widths, @@ -436,7 +447,7 @@ function prepareInternal( options?: PrepareOptions ): InternalPreparedText | PreparedTextWithSegments { const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace); - return measureAnalysis(analysis, font, includeSegments); + return measureAnalysis(analysis, font, includeSegments, options?.computeBidiLevels ?? false); } // Diagnostic-only helper used by the browser benchmark harness to separate the @@ -449,7 +460,7 @@ export function profilePrepare( const t0 = performance.now(); const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace); const t1 = performance.now(); - const prepared = measureAnalysis(analysis, font, false) as InternalPreparedText; + const prepared = measureAnalysis(analysis, font, false, false) as InternalPreparedText; const t2 = performance.now(); let breakableSegments = 0; diff --git a/src/uix/eidos/lib/canvas-text/measurement.ts b/src/uix/eidos/lib/canvas-text/measurement.ts index d36e5c312..2120c8972 100644 --- a/src/uix/eidos/lib/canvas-text/measurement.ts +++ b/src/uix/eidos/lib/canvas-text/measurement.ts @@ -15,8 +15,63 @@ export type EngineProfile = { preferEarlySoftHyphenBreak: boolean; }; +/** + * Map-backed LRU. Bounds the per-font segment-metric caches so long-lived apps + * with lots of unique text (feeds, comments) don't grow memory monotonically + * (fix-stext.md T2). Move-to-recent on `get`; evict the oldest on `set` over + * capacity. Every cache touch happens during `prepare` / measurement, never on the + * `layout` resize hot path (which walks pre-computed width arrays), so the extra + * Map churn stays off the critical path. + */ +/** @internal — exported for unit tests; not part of the engine's public surface. */ +export class LruCache { + readonly #max: number; + readonly #map = new Map(); + constructor(max: number) { + this.#max = max; + } + get size(): number { + return this.#map.size; + } + has(key: K): boolean { + return this.#map.has(key); + } + get(key: K): V | undefined { + const value = this.#map.get(key); + if (value !== undefined) { + // Move to most-recently-used (delete + re-insert at the tail). + this.#map.delete(key); + this.#map.set(key, value); + } + return value; + } + set(key: K, value: V): void { + if (this.#map.has(key)) this.#map.delete(key); + this.#map.set(key, value); + if (this.#map.size > this.#max) { + const oldest = this.#map.keys().next().value; + if (oldest !== undefined) this.#map.delete(oldest); + } + } + delete(key: K): boolean { + return this.#map.delete(key); + } + clear(): void { + this.#map.clear(); + } + keys(): IterableIterator { + return this.#map.keys(); + } +} + +// Capacity bounds (documented per fix-stext.md T2). Each font caches up to +// MAX_SEGMENTS_PER_FONT distinct measured segments (words / graphemes / prefixes); +// at most MAX_FONTS distinct font strings are held simultaneously. Both evict LRU. +const MAX_SEGMENTS_PER_FONT = 4096; +const MAX_FONTS = 24; + let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null; -const segmentMetricCaches = new Map>(); +const segmentMetricCaches = new LruCache>(MAX_FONTS); let cachedEngineProfile: EngineProfile | null = null; const emojiPresentationRe = /\p{Emoji_Presentation}/u; @@ -41,16 +96,16 @@ export function getMeasureContext(): CanvasRenderingContext2D | OffscreenCanvasR throw new Error('Text measurement requires OffscreenCanvas or a DOM canvas context.'); } -export function getSegmentMetricCache(font: string): Map { +export function getSegmentMetricCache(font: string): LruCache { let cache = segmentMetricCaches.get(font); if (!cache) { - cache = new Map(); + cache = new LruCache(MAX_SEGMENTS_PER_FONT); segmentMetricCaches.set(font, cache); } return cache; } -export function getSegmentMetrics(seg: string, cache: Map): SegmentMetrics { +export function getSegmentMetrics(seg: string, cache: LruCache): SegmentMetrics { let metrics = cache.get(seg); if (metrics === undefined) { const ctx = getMeasureContext(); @@ -175,7 +230,7 @@ export function getCorrectedSegmentWidth( export function getSegmentGraphemeWidths( seg: string, metrics: SegmentMetrics, - cache: Map, + cache: LruCache, emojiCorrection: number ): number[] | null { if (metrics.graphemeWidths !== undefined) return metrics.graphemeWidths; @@ -194,7 +249,7 @@ export function getSegmentGraphemeWidths( export function getSegmentGraphemePrefixWidths( seg: string, metrics: SegmentMetrics, - cache: Map, + cache: LruCache, emojiCorrection: number ): number[] | null { if (metrics.graphemePrefixWidths !== undefined) return metrics.graphemePrefixWidths; @@ -216,7 +271,7 @@ export function getFontMeasurementState( font: string, needsEmojiCorrection: boolean ): { - cache: Map; + cache: LruCache; fontSize: number; emojiCorrection: number; } { @@ -228,6 +283,38 @@ export function getFontMeasurementState( return { cache, fontSize, emojiCorrection }; } +function normalizeFamily(value: string): string { + return value.replace(/["']/g, '').toLowerCase(); +} + +/** + * Evict the metric cache (+ emoji correction) for ONE exact font string + * (fix-stext.md T1). The cache key is the full font string (e.g. "400 14px Inter"), + * identical before and after a web font swaps in — so bumping reactivity alone would + * return the stale fallback-measured widths. Evict first, then re-measure. + */ +export function invalidateFont(font: string): void { + segmentMetricCaches.delete(font); + emojiCorrectionCache.delete(font); +} + +/** + * Evict every cached font whose key contains `family` (quote- / case-insensitive). + * A single family yields many font-string keys (per weight / size), and a + * `loadingdone` event reports the family name, not the exact string — so match by + * substring. No-op for an empty family. + */ +export function invalidateFontFamily(family: string): void { + const needle = normalizeFamily(family); + if (needle === '') return; + for (const key of [...segmentMetricCaches.keys()]) { + if (normalizeFamily(key).includes(needle)) segmentMetricCaches.delete(key); + } + for (const key of [...emojiCorrectionCache.keys()]) { + if (normalizeFamily(key).includes(needle)) emojiCorrectionCache.delete(key); + } +} + export function clearMeasurementCaches(): void { segmentMetricCaches.clear(); emojiCorrectionCache.clear(); diff --git a/src/uix/eidos/lib/canvas-text/use-canvas.svelte.ts b/src/uix/eidos/lib/canvas-text/use-canvas.svelte.ts index 5c4d40cad..6e73c3b12 100644 --- a/src/uix/eidos/lib/canvas-text/use-canvas.svelte.ts +++ b/src/uix/eidos/lib/canvas-text/use-canvas.svelte.ts @@ -30,6 +30,7 @@ import type { LayoutLine, PrepareOptions } from './layout.js'; +import { invalidateFontFamily } from './measurement.js'; import type { ActiveDom } from '$adom'; // ─── useContainerWidth ──────────────────────────────────────────────────────── @@ -136,3 +137,59 @@ export function useTextLayout(getOpts: () => TextLayoutOptions): TextLayoutState } }; } + +// ─── useFontReady ─────────────────────────────────────────────────────────────── + +export type FontReadyState = { + /** + * Increments each time a web font finishes loading. Include it in the getter + * passed to `useTextLayout` so the prepare → layout pipeline re-runs against the + * real font once it swaps in. + */ + readonly epoch: number; +}; + +/** + * Tracks web-font load completion so canvas measurements re-run against the REAL + * font, not the fallback (fix-stext.md T1). + * + * The problem: `getComputedStyle().fontFamily` returns the *requested* family, which + * does NOT change when the web-font binary finishes downloading. The measurement + * cache is keyed by the font string ("400 14px Inter"), identical before/after the + * swap — so without invalidation the engine keeps the stale fallback widths and the + * line count lies. + * + * On each `loadingdone`, this evicts the loaded families' caches **first**, then bumps + * `epoch`. A consumer that folds `epoch` into its layout getter re-prepares against + * freshly measured widths (eviction-before-reactivity is the required order). + * + * SSR-safe: the `$effect` is client-only and it no-ops without `document.fonts`. The + * subscription is routed through `ActiveDom.listen` (lifecycle-tracked, iframe/popup- + * safe) — never a raw `addEventListener` on `document`. + * + * @param dom — the ActiveDom (e.g. `eidos.dom`) + */ +export function useFontReady(dom: Pick): FontReadyState { + let epoch = $state(0); + + $effect(() => { + const fonts = dom.getDocument()?.fonts; + if (!fonts || typeof fonts.addEventListener !== 'function') return; + + const handler = (event: Event): void => { + // Evict the loaded families BEFORE bumping reactivity, so the next prepare + // re-measures instead of returning cached fallback widths. + const loaded = (event as FontFaceSetLoadEvent).fontfaces; + for (const face of loaded) invalidateFontFamily(face.family); + epoch += 1; + }; + + return dom.listen(fonts, 'loadingdone', handler as EventListener); + }); + + return { + get epoch() { + return epoch; + } + }; +}