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