fix(canvas-text): implement fix-stext T1-T5 (font-load invalidation, LRU, bidi opt-in)

Closes all 5 items of fix-stext.md for the <SText> 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) <noreply@anthropic.com>
active-uix
dev 4 months ago
parent 458137c027
commit 0c1f202186

@ -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<HTMLElement | null>(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 `<length>` ("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 `<length>` ("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(() => ({
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,
font: computed.font,
maxWidth: measure.width,
lineHeight: lineHeightPx
}));
lineHeight: computed.lineHeightPx
};
});
const measuredLineCount = $derived(canvasActive ? tl.lineCount : 0);
const hiddenLines = $derived(

@ -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<string, number>(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<string, number>(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<string, number>(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);
});
});

@ -0,0 +1,181 @@
# fix-stext.md — correcciones del motor de medición de texto (eidos `<SText>`)
> **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 `<SText>`, **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)
`<SText>` es **DOM-painted, canvas-measured**. El texto lo pinta el DOM (`<p>{text}</p>` 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 `<SText>` 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 `<SText text=… clamp=N>` 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<string /*font*/, Map<string /*seg*/, SegmentMetrics>>` 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.
- `<SText>` 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 + `<SText clamp>`; 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.

@ -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;

@ -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<K, V> {
readonly #max: number;
readonly #map = new Map<K, V>();
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<K> {
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<string, Map<string, SegmentMetrics>>();
const segmentMetricCaches = new LruCache<string, LruCache<string, SegmentMetrics>>(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<string, SegmentMetrics> {
export function getSegmentMetricCache(font: string): LruCache<string, SegmentMetrics> {
let cache = segmentMetricCaches.get(font);
if (!cache) {
cache = new Map();
cache = new LruCache<string, SegmentMetrics>(MAX_SEGMENTS_PER_FONT);
segmentMetricCaches.set(font, cache);
}
return cache;
}
export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
export function getSegmentMetrics(seg: string, cache: LruCache<string, SegmentMetrics>): 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<string, SegmentMetrics>,
cache: LruCache<string, SegmentMetrics>,
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<string, SegmentMetrics>,
cache: LruCache<string, SegmentMetrics>,
emojiCorrection: number
): number[] | null {
if (metrics.graphemePrefixWidths !== undefined) return metrics.graphemePrefixWidths;
@ -216,7 +271,7 @@ export function getFontMeasurementState(
font: string,
needsEmojiCorrection: boolean
): {
cache: Map<string, SegmentMetrics>;
cache: LruCache<string, SegmentMetrics>;
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();

@ -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<ActiveDom, 'listen' | 'getDocument'>): 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;
}
};
}

Loading…
Cancel
Save

Powered by TurnKey Linux.