gioser

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Definitions

⎇ main llimphi-glifos/src/lib.rs Rust · 731 lines · 27.4 KB History Who touched what raw

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! **Atlas de cobertura de glifos** — lo que le faltaba al camino GPU-directo
2//! para poder pintar texto.
3//!
4//! `llimphi_raster::GpuBatch` sabe emitir cientos de miles de rects, discos,
5//! líneas y triángulos en una draw call por tipo. Lo que no sabe es qué forma
6//! tiene una «a»: el batcher instanciado habla de quads, no de contornos. Por
7//! eso el path directo estaba fichado como «sin texto» en
8//! `PLAN-MOTOR-PROFUNDO.md` §4a — y era el último hueco real de esa iniciativa.
9//!
10//! La solución es la de siempre en un rasterizador de texto por GPU: **cada
11//! glifo se rasteriza una sola vez** a un mapa de cobertura de 8 bits, se
12//! empaqueta en una textura grande, y de ahí en más pintar texto es pintar
13//! quads con una UV. Un renglón de 80 caracteres pasa de 80 contornos rellenos
14//! a 80 instancias de 48 B.
15//!
16//! ## Por qué un crate aparte y no un módulo de `llimphi-text`
17//!
18//! Porque agrega `swash` con sus features `scale` + `render` (y detrás, `yazi`
19//! y `zeno`). parley ya trae swash —lo usa para shapear— pero con
20//! `default-features = false`, así que el **rasterizador** no está enlazado
21//! hasta que alguien lo pida. Quien no pinte texto por GPU-directo no tiene por
22//! qué cargarlo. Y `llimphi-text` está en ~1.900 LOC, en el borde del umbral de
23//! split de la Regla 1 del repo.
24//!
25//! ## Lo que este crate NO hace, dicho de frente
26//!
27//! - **No shapea.** Recibe un `parley::Layout` ya resuelto. Quién lo arma
28//!   —`llimphi_text::Typesetter`— sigue siendo el mismo de siempre, así que el
29//!   texto por GPU-directo y el texto por vello parten del **mismo** layout: si
30//!   difieren, difieren en el rasterizado y en nada más.
31//! - **No pinta emoji ni glifos de color.** El atlas es de un canal
32//!   (`Alpha`/R8): una carita de color no entra ahí. Sólo se piden contornos
33//!   (`Source::Outline`). Un glifo de color pediría un atlas RGBA y un pipeline
34//!   propio, y meterlos «por las dudas» sería construir sin caller.
35//! - **No hintea.** Es deliberado y no una simplificación: vello rasteriza el
36//!   contorno crudo, y si acá se hinteara, el mismo texto saldría con métricas
37//!   distintas por cada camino. La comparación entre los dos —que es cómo se
38//!   certifica esto— dejaría de significar algo.
39//! - **No crece ni desaloja.** El atlas es de tamaño fijo; lleno, devuelve
40//!   `None` y cuenta el fallo. Una política de desalojo sin una app que la
41//!   presione se diseña a ciegas; lo honesto es que el número esté a la vista
42//!   (`AtlasGlifos::fallos`) y que se decida cuando alguien lo choque.
43
44use std::collections::HashMap;
45use std::hash::{BuildHasherDefault, Hasher};
46
47pub use swash;
48
49/// Hasher barato para las claves de glifo, en vez del SipHash de `std`.
50///
51/// **No es una micro-optimización a ciegas: sale de una medición, y la
52/// medición dice que ayuda pero no alcanza.** El bench (`llimphi-gpu-bench`,
53/// sección «Texto») separa *preparar* —armar la escena de vello contra armar
54/// los quads— del total, y en esa mitad el atlas sale **más lento** que vello
55/// aunque gane 3-5× en el total. Con ~12.000 glifos por cuadro se hacen 12.000
56/// búsquedas, y ahí el hash deja de ser gratis.
57///
58/// A/B en la misma máquina (Iris Xe, 12.096 glifos, 60 muestras, 3 corridas):
59///
60/// | hasher | preparar |
61/// |---|---:|
62/// | SipHash (el de `std`) | ~3,4 ms |
63/// | éste | ~2,5 ms |
64/// | *vello, para comparar* | *~1,9 ms* |
65///
66/// O sea **−27 %**, pero sin dar vuelta el signo: preparar sigue costando más
67/// que por vello, que lo único que hace por glifo es apilar id y posición.
68/// Queda anotado como lo que es: una mejora medida, no la solución.
69///
70/// Es el algoritmo de rustc (FxHash): multiplicar, rotar, xor. No sirve para
71/// entradas hostiles —no hay resistencia a colisiones a propósito— y acá eso da
72/// igual: las claves las genera el shaper, no la red.
73#[derive(Default)]
74pub struct HasherGlifo(u64);
75
76impl Hasher for HasherGlifo {
77    fn finish(&self) -> u64 {
78        self.0
79    }
80    fn write(&mut self, bytes: &[u8]) {
81        for b in bytes {
82            self.agregar(*b as u64);
83        }
84    }
85    fn write_u8(&mut self, v: u8) {
86        self.agregar(v as u64);
87    }
88    fn write_u16(&mut self, v: u16) {
89        self.agregar(v as u64);
90    }
91    fn write_u32(&mut self, v: u32) {
92        self.agregar(v as u64);
93    }
94    fn write_i32(&mut self, v: i32) {
95        self.agregar(v as u32 as u64);
96    }
97    fn write_u64(&mut self, v: u64) {
98        self.agregar(v);
99    }
100    fn write_usize(&mut self, v: usize) {
101        self.agregar(v as u64);
102    }
103}
104
105impl HasherGlifo {
106    #[inline]
107    fn agregar(&mut self, v: u64) {
108        const SEMILLA: u64 = 0x51_7c_c1_b7_27_22_0a_95;
109        self.0 = (self.0.rotate_left(5) ^ v).wrapping_mul(SEMILLA);
110    }
111}
112
113type MapaGlifos = HashMap<ClaveGlifo, Option<Colocacion>, BuildHasherDefault<HasherGlifo>>;
114
115/// Cuántas posiciones subpíxel se distinguen por eje. El glifo se rasteriza una
116/// vez por cada fracción, así que la misma «a» a 14 px puede ocupar hasta
117/// `SUBPIXELES²` entradas del atlas.
118///
119/// Cuatro por eje —y no una— porque el texto no cae en la grilla: parley da
120/// posiciones fraccionarias, y redondearlas corre cada glifo hasta medio píxel.
121/// En un renglón eso se ve como espaciado irregular. En los dos ejes y no sólo
122/// en X porque la línea base también es fraccionaria, y ahí el error no se
123/// reparte: **corre el renglón entero**.
124///
125/// En la práctica el costo es mucho menor que el peor caso: un texto normal
126/// tiene una sola fracción de línea base por renglón.
127pub const SUBPIXELES: u32 = 4;
128
129/// Píxeles de aire entre glifos vecinos dentro del atlas. Sin esto, el filtrado
130/// del sampler puede traer un pedazo del glifo de al lado.
131const AIRE: u32 = 1;
132
133/// Lo que identifica a un glifo ya rasterizado. Dos pedidos con la misma clave
134/// dan el mismo mapa de cobertura, y por eso se puede cachear.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
136pub struct ClaveGlifo {
137    /// Identidad del archivo de fuente. Es el `id` del `Blob` que trae parley: un
138    /// contador global asignado al construirlo, O(1) y estable entre clones —
139    /// el mismo truco que usa el caché de boundaries del compositor. Dos blobs
140    /// con bytes idénticos reciben ids distintos, así que a lo sumo se
141    /// rasteriza de más; nunca se devuelve el glifo equivocado.
142    pub fuente: u64,
143    /// Índice dentro de una colección (`.ttc`). 0 para un archivo suelto.
144    pub indice: u32,
145    /// Id del glifo dentro de la fuente (ya shapeado, no un carácter).
146    pub glifo: u16,
147    /// Tamaño en px × 64, redondeado. Cuantizar evita que un zoom continuo
148    /// llene el atlas con una entrada por frame.
149    pub tam_64: u32,
150    /// Fracción subpíxel: `sx + sy * SUBPIXELES`, con cada una en `0..SUBPIXELES`.
151    pub subpx: u8,
152    /// Negrita sintética (la fuente no tiene corte bold y el shaper lo pidió).
153    pub engrosado: bool,
154    /// Itálica sintética, en centésimas de grado de inclinación.
155    pub inclinacion_c: i32,
156}
157
158impl ClaveGlifo {
159    /// El caso simple: un glifo suelto de una fuente suelta, en posición
160    /// entera y sin síntesis. Es lo que pide quien **no** viene de un shaper —
161    /// una grilla de terminal, que mapea `char` → glifo por charmap y coloca
162    /// todo en celdas enteras.
163    pub fn de_glifo(glifo: u16, tam_px: f32) -> Self {
164        Self {
165            fuente: 0,
166            indice: 0,
167            glifo,
168            tam_64: (tam_px * 64.0).round().max(1.0) as u32,
169            subpx: 0,
170            engrosado: false,
171            inclinacion_c: 0,
172        }
173    }
174}
175
176/// Un glifo rasterizado **suelto**: la cobertura y dónde colocarla, sin atlas
177/// de por medio.
178///
179/// Existe para quien empaqueta a su manera. `llimphi_widget_terminal::GlyphAtlas`
180/// es el caso: una grilla de celdas iguales es la estructura correcta para un
181/// terminal —y parte de por qué es rápido— pero el **rasterizador** no tiene por
182/// qué ser otro. Antes lo era (`fontdue`), y llimphi cargaba dos.
183#[derive(Debug, Clone, PartialEq)]
184pub struct MapaGlifo {
185    pub ancho: u32,
186    pub alto: u32,
187    /// Igual que en [`Colocacion`]: `izq` hacia la derecha, `arriba` hacia
188    /// **arriba** (convención OpenType).
189    pub izq: i32,
190    pub arriba: i32,
191    /// `ancho × alto` bytes, fila por fila.
192    pub cobertura: Vec<u8>,
193}
194
195/// El rasterizador de glifos, sin el atlas.
196///
197/// Guarda el `ScaleContext` de swash, que cachea el trabajo de abrir la fuente
198/// entre pedidos — por eso conviene tener uno vivo y no crearlo por glifo.
199#[derive(Default)]
200pub struct Rasterizador {
201    ctx: swash::scale::ScaleContext,
202}
203
204impl Rasterizador {
205    pub fn new() -> Self {
206        Self::default()
207    }
208
209    /// Rasteriza un glifo. `None` si la fuente no parsea o si el glifo no tiene
210    /// mapa (un espacio).
211    ///
212    /// `clave.fuente` **no se mira acá**: los bytes llegan por `datos`. Ese
213    /// campo identifica la fuente para cachear, no para rasterizar.
214    pub fn glifo(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<MapaGlifo> {
215        let fuente = swash::FontRef::from_index(datos, clave.indice as usize)?;
216        let tam = clave.tam_64 as f32 / 64.0;
217        let sx = (clave.subpx as u32 % SUBPIXELES) as f32 / SUBPIXELES as f32;
218        let sy = (clave.subpx as u32 / SUBPIXELES) as f32 / SUBPIXELES as f32;
219
220        let mut escalador = self
221            .ctx
222            .builder(fuente)
223            .size(tam)
224            // Sin hinting: ver el encabezado del crate.
225            .hint(false)
226            .build();
227
228        let mut render = swash::scale::Render::new(&[swash::scale::Source::Outline]);
229        render.format(swash::zeno::Format::Alpha);
230        // El desplazamiento subpíxel se aplica ANTES de rasterizar: así el
231        // antialiasing corresponde a la posición real del glifo y no a una
232        // versión corrida después.
233        render.offset(swash::zeno::Vector::new(sx, sy));
234        if clave.engrosado {
235            render.embolden(tam * 0.02);
236        }
237        if clave.inclinacion_c != 0 {
238            let grados = clave.inclinacion_c as f32 / 100.0;
239            render.transform(Some(swash::zeno::Transform::skew(
240                swash::zeno::Angle::from_degrees(-grados),
241                swash::zeno::Angle::from_degrees(0.0),
242            )));
243        }
244        let img = render.render(&mut escalador, clave.glifo)?;
245        let (w, h) = (img.placement.width, img.placement.height);
246        if w == 0 || h == 0 {
247            return None;
248        }
249        Some(MapaGlifo {
250            ancho: w,
251            alto: h,
252            izq: img.placement.left,
253            arriba: img.placement.top,
254            cobertura: img.data,
255        })
256    }
257}
258
259/// Métricas de fuente que **no** necesitan shaping, en píxeles ya escalados.
260#[derive(Debug, Clone, Copy, PartialEq)]
261pub struct MetricasFuente {
262    pub ascenso: f32,
263    pub descenso: f32,
264    pub interlinea: f32,
265}
266
267impl MetricasFuente {
268    /// Alto de renglón: la suma de las tres. Es la misma definición que usa
269    /// `fontdue` para su `new_line_size`, y da el **mismo número hasta la
270    /// tercera decimal** en Liberation Mono a 11/12/13/14/16/18 px — verificado
271    /// antes de migrar el rasterizador del terminal, porque un píxel de más en
272    /// la celda cambiaría la grilla y eso se ve.
273    pub fn alto_linea(&self) -> f32 {
274        self.ascenso + self.descenso + self.interlinea
275    }
276}
277
278/// Métricas de la fuente a un tamaño dado.
279pub fn metricas(datos: &[u8], indice: u32, tam_px: f32) -> Option<MetricasFuente> {
280    let f = swash::FontRef::from_index(datos, indice as usize)?;
281    let m = f.metrics(&[]).scale(tam_px);
282    Some(MetricasFuente {
283        ascenso: m.ascent,
284        descenso: m.descent,
285        interlinea: m.leading,
286    })
287}
288
289/// Id del glifo de un carácter según el `cmap` de la fuente. **0 = notdef**
290/// (la fuente no lo tiene), igual que en el formato.
291///
292/// Esto NO es shaping: no resuelve ligaduras, ni marcas combinantes, ni
293/// escrituras complejas. Sirve para quien ya decidió que un carácter es un
294/// glifo — una grilla de terminal— y sería incorrecto para texto general.
295pub fn id_de_caracter(datos: &[u8], indice: u32, ch: char) -> Option<u16> {
296    let f = swash::FontRef::from_index(datos, indice as usize)?;
297    Some(f.charmap().map(ch))
298}
299
300/// Avance horizontal de un glifo, en píxeles.
301pub fn avance(datos: &[u8], indice: u32, tam_px: f32, glifo: u16) -> Option<f32> {
302    let f = swash::FontRef::from_index(datos, indice as usize)?;
303    Some(f.glyph_metrics(&[]).scale(tam_px).advance_width(glifo))
304}
305
306/// Dónde quedó un glifo dentro del atlas y cómo colocarlo en pantalla.
307#[derive(Debug, Clone, Copy, PartialEq)]
308pub struct Colocacion {
309    /// Rect en el atlas, en coordenadas de textura normalizadas `0..1`.
310    pub u0: f32,
311    pub v0: f32,
312    pub u1: f32,
313    pub v1: f32,
314    /// Desplazamiento del mapa respecto del origen del glifo, en px. `izq` es
315    /// hacia la derecha; `arriba` es hacia **arriba** (convención OpenType, eje
316    /// Y para arriba), así que en pantalla el borde superior del mapa va a
317    /// `y_base - arriba`.
318    pub izq: i32,
319    pub arriba: i32,
320    /// Tamaño del mapa en px.
321    pub ancho: u32,
322    pub alto: u32,
323}
324
325/// Un quad listo para el batcher: dónde va en pantalla y de dónde sale su
326/// cobertura. El color no viaja acá porque es del run entero, no del glifo.
327#[derive(Debug, Clone, Copy, PartialEq)]
328pub struct QuadGlifo {
329    pub x: f32,
330    pub y: f32,
331    pub w: f32,
332    pub h: f32,
333    pub u0: f32,
334    pub v0: f32,
335    pub u1: f32,
336    pub v1: f32,
337}
338
339/// Región rectangular del atlas que cambió desde la última subida a GPU.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub struct Sucio {
342    pub x: u32,
343    pub y: u32,
344    pub ancho: u32,
345    pub alto: u32,
346}
347
348/// El atlas: una textura cuadrada de un canal, empaquetada por **estantes**.
349///
350/// Estantes y no un empaquetador óptimo a propósito. Los glifos de un texto
351/// tienen alturas parecidas, así que una fila por rango de altura desperdicia
352/// poco, y el algoritmo cabe en veinte líneas que se pueden leer. Un packer con
353/// árbol se justifica cuando se empaquetan sprites de tamaños arbitrarios.
354pub struct AtlasGlifos {
355    lado: u32,
356    pixeles: Vec<u8>,
357    mapa: MapaGlifos,
358    /// Cursor del estante actual.
359    x: u32,
360    y: u32,
361    alto_estante: u32,
362    sucio: Option<Sucio>,
363    /// Sube cada vez que se escribe en el atlas. Quien tiene una copia en GPU
364    /// compara este número para saber si hay que volver a subir.
365    version: u64,
366    /// Glifos que no entraron porque el atlas está lleno. Que sea un contador
367    /// público y no un panic es lo que permite que una app siga andando (sin
368    /// ese texto) en vez de morirse.
369    pub fallos: u64,
370    /// Cuántas veces un pedido se resolvió con lo que ya estaba.
371    pub aciertos: u64,
372    /// Cuántas veces hubo que rasterizar.
373    pub rasterizados: u64,
374    /// El rasterizador, compartido con quien empaqueta a su manera.
375    rasterizador: Rasterizador,
376}
377
378impl AtlasGlifos {
379    /// Atlas cuadrado de `lado × lado` px, un byte por px. 1024 son 1 MiB y
380    /// alcanzan de sobra para el juego de caracteres de una UI en dos o tres
381    /// tamaños.
382    pub fn new(lado: u32) -> Self {
383        let lado = lado.max(16);
384        Self {
385            lado,
386            pixeles: vec![0u8; (lado * lado) as usize],
387            mapa: MapaGlifos::default(),
388            x: 0,
389            y: 0,
390            alto_estante: 0,
391            sucio: None,
392            version: 0,
393            fallos: 0,
394            aciertos: 0,
395            rasterizados: 0,
396            rasterizador: Rasterizador::new(),
397        }
398    }
399
400    pub fn lado(&self) -> u32 {
401        self.lado
402    }
403
404    /// Los bytes del atlas, en orden de filas, un byte de cobertura por píxel.
405    pub fn pixeles(&self) -> &[u8] {
406        &self.pixeles
407    }
408
409    /// Versión del contenido. Cambia sólo cuando se escribió algo nuevo.
410    pub fn version(&self) -> u64 {
411        self.version
412    }
413
414    /// Cuántos glifos distintos hay guardados (incluye los que no entraron).
415    pub fn entradas(&self) -> usize {
416        self.mapa.len()
417    }
418
419    /// La región que cambió desde la última llamada, y la limpia. Quien sube el
420    /// atlas a una textura puede subir sólo eso en vez del millón de bytes.
421    pub fn tomar_sucio(&mut self) -> Option<Sucio> {
422        self.sucio.take()
423    }
424
425    /// Vacía el atlas. Es la única salida cuando `fallos` empieza a subir: se
426    /// tira todo y se vuelve a llenar con lo que el frame siguiente pida. Es
427    /// brusco —un frame paga la re-rasterización entera— pero es honesto y no
428    /// finge una política de desalojo que nadie midió.
429    pub fn limpiar(&mut self) {
430        self.pixeles.fill(0);
431        self.mapa.clear();
432        self.x = 0;
433        self.y = 0;
434        self.alto_estante = 0;
435        self.version += 1;
436        self.sucio = Some(Sucio { x: 0, y: 0, ancho: self.lado, alto: self.lado });
437    }
438
439    /// Busca un glifo, rasterizándolo si es la primera vez.
440    ///
441    /// `datos`/`indice` son el archivo de fuente; `clave.fuente` es su
442    /// identidad. Devuelve `None` si el glifo está vacío (un espacio no tiene
443    /// mapa) o si el atlas está lleno — el caller no distingue, y no necesita:
444    /// en los dos casos no hay nada que dibujar. Para saber si fue por lleno,
445    /// mirar `fallos`.
446    pub fn glifo(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<Colocacion> {
447        if let Some(v) = self.mapa.get(&clave) {
448            self.aciertos += 1;
449            return *v;
450        }
451        self.rasterizados += 1;
452        let col = self.rasterizar(datos, clave);
453        self.mapa.insert(clave, col);
454        col
455    }
456
457    /// Rasteriza y **empaqueta**. El rasterizado en sí es de
458    /// [`Rasterizador`] — acá sólo se decide dónde va.
459    fn rasterizar(&mut self, datos: &[u8], clave: ClaveGlifo) -> Option<Colocacion> {
460        let m = self.rasterizador.glifo(datos, clave)?;
461        let (w, h) = (m.ancho, m.alto);
462        let (px, py) = self.reservar(w, h)?;
463        for fila in 0..h {
464            let orig = (fila * w) as usize;
465            let dest = ((py + fila) * self.lado + px) as usize;
466            self.pixeles[dest..dest + w as usize]
467                .copy_from_slice(&m.cobertura[orig..orig + w as usize]);
468        }
469        self.ensuciar(px, py, w, h);
470        self.version += 1;
471
472        let l = self.lado as f32;
473        Some(Colocacion {
474            u0: px as f32 / l,
475            v0: py as f32 / l,
476            u1: (px + w) as f32 / l,
477            v1: (py + h) as f32 / l,
478            izq: m.izq,
479            arriba: m.arriba,
480            ancho: w,
481            alto: h,
482        })
483    }
484
485    /// Reserva `w × h` px por estantes. `None` = no entra.
486    fn reservar(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
487        if w > self.lado || h > self.lado {
488            self.fallos += 1;
489            return None;
490        }
491        if self.x + w > self.lado {
492            // Estante siguiente.
493            self.y += self.alto_estante + AIRE;
494            self.x = 0;
495            self.alto_estante = 0;
496        }
497        if self.y + h > self.lado {
498            self.fallos += 1;
499            return None;
500        }
501        let pos = (self.x, self.y);
502        self.x += w + AIRE;
503        self.alto_estante = self.alto_estante.max(h);
504        Some(pos)
505    }
506
507    fn ensuciar(&mut self, x: u32, y: u32, w: u32, h: u32) {
508        self.sucio = Some(match self.sucio {
509            None => Sucio { x, y, ancho: w, alto: h },
510            Some(s) => {
511                let x0 = s.x.min(x);
512                let y0 = s.y.min(y);
513                let x1 = (s.x + s.ancho).max(x + w);
514                let y1 = (s.y + s.alto).max(y + h);
515                Sucio { x: x0, y: y0, ancho: x1 - x0, alto: y1 - y0 }
516            }
517        });
518    }
519}
520
521/// Cuantiza una coordenada a la grilla subpíxel: devuelve la parte entera y el
522/// índice de fracción.
523fn cuantizar(v: f32) -> (f32, u32) {
524    let ent = v.floor();
525    let frac = v - ent;
526    let i = (frac * SUBPIXELES as f32).round() as u32;
527    // La fracción puede redondear al escalón siguiente (0,95 → 4/4): eso es el
528    // píxel de al lado con fracción 0, no un índice fuera de rango.
529    if i >= SUBPIXELES {
530        (ent + 1.0, 0)
531    } else {
532        (ent, i)
533    }
534}
535
536/// Recorre un layout ya shapeado y emite un quad por glifo visible.
537///
538/// `origen` es la esquina superior-izquierda del bloque en píxeles de pantalla,
539/// el mismo argumento que `llimphi_text::draw_layout`. Devuelve cuántos quads
540/// se agregaron; los glifos sin mapa (espacios) y los que no entraron en el
541/// atlas simplemente no aparecen.
542///
543/// Es genérico en el brush del layout porque acá el color no se mira: quien
544/// llama sabe de qué color pinta el run. Un layout multicolor
545/// (`Typesetter::layout_runs`) se recorre igual, run por run, con
546/// [`quads_de_run`].
547pub fn quads_de_layout<B: parley::Brush>(
548    atlas: &mut AtlasGlifos,
549    layout: &parley::Layout<B>,
550    origen: (f32, f32),
551    salida: &mut Vec<QuadGlifo>,
552) -> usize {
553    let antes = salida.len();
554    for linea in layout.lines() {
555        for item in linea.items() {
556            if let parley::PositionedLayoutItem::GlyphRun(run) = item {
557                quads_de_run(atlas, &run, origen, salida);
558            }
559        }
560    }
561    salida.len() - antes
562}
563
564/// Un solo run. Se expone aparte para que un layout multicolor pueda mirar el
565/// brush de cada run y emitir sus quads con el color que corresponde.
566pub fn quads_de_run<B: parley::Brush>(
567    atlas: &mut AtlasGlifos,
568    run: &parley::GlyphRun<'_, B>,
569    origen: (f32, f32),
570    salida: &mut Vec<QuadGlifo>,
571) {
572    let interno = run.run();
573    let fuente = interno.font();
574    let datos: &[u8] = fuente.data.as_ref();
575    let id = fuente.data.id();
576    let indice = fuente.index;
577    let tam_64 = (interno.font_size() * 64.0).round().max(1.0) as u32;
578    let sintesis = interno.synthesis();
579    let engrosado = sintesis.embolden();
580    let inclinacion_c = sintesis
581        .skew()
582        .map(|g| (g * 100.0).round() as i32)
583        .unwrap_or(0);
584
585    for g in run.positioned_glyphs() {
586        let (gx, sx) = cuantizar(origen.0 + g.x);
587        let (gy, sy) = cuantizar(origen.1 + g.y);
588        let clave = ClaveGlifo {
589            fuente: id,
590            indice,
591            // parley da el id en `u32`; swash lo pide en `u16`. No se pierde
592            // nada: `numGlyphs` es un `uint16` en el formato, así que una
593            // fuente no puede tener más de 65.536 glifos.
594            glifo: g.id as u16,
595            tam_64,
596            subpx: (sx + sy * SUBPIXELES) as u8,
597            engrosado,
598            inclinacion_c,
599        };
600        let Some(c) = atlas.glifo(datos, clave) else {
601            continue;
602        };
603        salida.push(QuadGlifo {
604            x: gx + c.izq as f32,
605            // `arriba` va hacia arriba (OpenType); en pantalla Y crece hacia
606            // abajo, así que el borde superior del mapa cae por encima de la
607            // línea base.
608            y: gy - c.arriba as f32,
609            w: c.ancho as f32,
610            h: c.alto as f32,
611            u0: c.u0,
612            v0: c.v0,
613            u1: c.u1,
614            v1: c.v1,
615        });
616    }
617}
618
619#[cfg(test)]
620mod pruebas {
621    use super::*;
622
623    #[test]
624    fn la_cuantizacion_reparte_las_fracciones_y_no_se_sale() {
625        // El caso normal: cuatro escalones por píxel.
626        assert_eq!(cuantizar(10.0), (10.0, 0));
627        assert_eq!(cuantizar(10.25), (10.0, 1));
628        assert_eq!(cuantizar(10.5), (10.0, 2));
629        assert_eq!(cuantizar(10.75), (10.0, 3));
630        // Y el borde que rompería un índice sin guarda: 0,95 redondea al
631        // escalón 4, que no existe — es el píxel siguiente con fracción 0.
632        assert_eq!(cuantizar(10.95), (11.0, 0));
633        assert_eq!(cuantizar(10.9), (11.0, 0));
634        // Negativos: `floor` los manda para abajo, la fracción sigue en 0..1.
635        assert_eq!(cuantizar(-0.25), (-1.0, 3));
636    }
637
638    #[test]
639    fn el_hasher_barato_reparte_y_no_colapsa() {
640        // Un hasher degenerado —uno que devolviera una constante, o que ignorara
641        // algún campo— seguiría siendo **correcto**: la tabla compara las claves
642        // igual y nunca devuelve el glifo equivocado. Lo que haría es convertir
643        // el mapa en una lista, y entonces el cambio que se hizo para acelerar
644        // el `preparar` lo volvería más lento **en silencio**. Ningún test de
645        // píxeles agarraría eso.
646        use std::hash::Hash;
647        fn h(c: &ClaveGlifo) -> u64 {
648            let mut x = HasherGlifo::default();
649            c.hash(&mut x);
650            x.finish()
651        }
652        let base = ClaveGlifo {
653            fuente: 7,
654            indice: 0,
655            glifo: 42,
656            tam_64: 11 * 64,
657            subpx: 0,
658            engrosado: false,
659            inclinacion_c: 0,
660        };
661        // Cada campo, por separado, tiene que mover el hash: si uno no lo mueve,
662        // ese campo no está entrando en la clave efectiva.
663        let variantes = [
664            ClaveGlifo { fuente: 8, ..base },
665            ClaveGlifo { indice: 1, ..base },
666            ClaveGlifo { glifo: 43, ..base },
667            ClaveGlifo { tam_64: 12 * 64, ..base },
668            ClaveGlifo { subpx: 1, ..base },
669            ClaveGlifo { engrosado: true, ..base },
670            ClaveGlifo { inclinacion_c: 1400, ..base },
671        ];
672        for (i, v) in variantes.iter().enumerate() {
673            assert_ne!(h(&base), h(v), "el campo {i} no mueve el hash");
674        }
675
676        // Y el caso realista: el juego de claves de un texto —un puñado de
677        // glifos por las 16 fracciones subpíxel— tiene que repartirse. Se exige
678        // que no haya colisiones sobre 800 claves; con un hash decente la
679        // probabilidad de una colisión de 64 bits ahí es ~1 en 10^14.
680        let mut vistos = std::collections::HashSet::new();
681        for glifo in 0..50u16 {
682            for subpx in 0..16u8 {
683                vistos.insert(h(&ClaveGlifo { glifo, subpx, ..base }));
684            }
685        }
686        assert_eq!(vistos.len(), 800, "el hasher colapsó claves distintas");
687    }
688
689    #[test]
690    fn el_estante_avanza_y_el_atlas_lleno_no_revienta() {
691        let mut a = AtlasGlifos::new(16);
692        // Dos de 6×4 entran en el primer estante (6+1+6 = 13 ≤ 16).
693        assert_eq!(a.reservar(6, 4), Some((0, 0)));
694        assert_eq!(a.reservar(6, 4), Some((7, 0)));
695        // El tercero no: pasa al estante siguiente, que arranca bajo el más
696        // alto del anterior más el aire.
697        assert_eq!(a.reservar(6, 4), Some((0, 5)));
698        // Y algo más alto que el atlas entero es un fallo contado, no un panic.
699        assert_eq!(a.reservar(6, 99), None);
700        assert_eq!(a.fallos, 1);
701    }
702
703    #[test]
704    fn lo_sucio_es_la_union_de_lo_escrito_y_se_vacia_al_tomarlo() {
705        let mut a = AtlasGlifos::new(64);
706        assert_eq!(a.tomar_sucio(), None);
707        a.ensuciar(2, 3, 4, 5);
708        a.ensuciar(10, 1, 2, 2);
709        // Union: x 2..12, y 1..8.
710        assert_eq!(a.tomar_sucio(), Some(Sucio { x: 2, y: 1, ancho: 10, alto: 7 }));
711        // Tomarlo lo limpia: quien ya subió esa región no la vuelve a subir.
712        assert_eq!(a.tomar_sucio(), None);
713    }
714
715    #[test]
716    fn limpiar_ensucia_todo_y_sube_la_version() {
717        let mut a = AtlasGlifos::new(32);
718        a.ensuciar(0, 0, 1, 1);
719        let v = a.version();
720        a.tomar_sucio();
721        a.limpiar();
722        assert!(a.version() > v);
723        // Todo el atlas quedó sucio: quien tenga una copia en GPU tiene que
724        // volver a subirla entera, porque los UV viejos ya no valen.
725        assert_eq!(
726            a.tomar_sucio(),
727            Some(Sucio { x: 0, y: 0, ancho: 32, alto: 32 })
728        );
729        assert_eq!(a.entradas(), 0);
730    }
731}