gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-term-graphics/src/sixel.rs Rust · 317 lines · 9.7 KB History Who touched what raw

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! Decodificador sixel (a mano — no hay crate en el workspace).
2//!
3//! Entrada: el cuerpo DCS (sin el `\eP` inicial ni el `\e\\` final). Estructura:
4//! `<params>q` y luego los datos sixel:
5//! - `"Pan;Pad;Ph;Pv` — atributos raster (aspecto + ancho `Ph` × alto `Pv`).
6//! - `#Pc;Pu;Px;Py;Pz` — define el color del registro `Pc` (`Pu=2` ⇒ RGB en
7//!   porcentaje 0..100). `#Pc` solo ⇒ selecciona el registro `Pc`.
8//! - `!Pn <char>` — repite el sixel `<char>` `Pn` veces (RLE).
9//! - `$` — carriage return (vuelve al inicio de la banda, misma `y`).
10//! - `-` — line feed (avanza 6 filas).
11//! - chars `0x3F..=0x7E` — 6 píxeles verticales; bit `i` (LSB = arriba) prende
12//!   la fila `y+i` con el color actual.
13//!
14//! Estrategia de tamaño: si hay atributos raster los usamos; si no, dos
15//! pasadas — la primera mide (`max_x`/`max_y`), la segunda pinta.
16
17use crate::DecodedImage;
18
19/// Tope defensivo de píxeles (16 Mpx ≈ 64 MiB RGBA) para no estallar con un
20/// stream sixel malicioso/roto.
21const MAX_PIXELS: u64 = 16 * 1024 * 1024;
22
23pub fn decode(seq: &[u8]) -> Option<DecodedImage> {
24    let qpos = seq.iter().position(|&b| b == b'q')?;
25    let data = &seq[qpos + 1..];
26
27    // Tamaño: de atributos raster si están; si no, midiendo.
28    let (mut w, mut h) = raster_size(data);
29    if w == 0 || h == 0 {
30        let (mx, my) = measure(data);
31        if w == 0 {
32            w = mx;
33        }
34        if h == 0 {
35            h = my;
36        }
37    }
38    if w == 0 || h == 0 {
39        return None;
40    }
41    if (w as u64) * (h as u64) > MAX_PIXELS {
42        return None;
43    }
44
45    let mut rgba = vec![0u8; (w as usize) * (h as usize) * 4];
46    paint(data, w, h, &mut rgba);
47    Some(DecodedImage {
48        width: w,
49        height: h,
50        rgba,
51    })
52}
53
54/// Lee los atributos raster `"Pan;Pad;Ph;Pv` si aparecen al inicio. Devuelve
55/// `(Ph, Pv)` o `(0,0)` si no hay.
56fn raster_size(data: &[u8]) -> (u32, u32) {
57    let mut i = 0;
58    // saltar whitespace inicial
59    while i < data.len() && (data[i] == b'\n' || data[i] == b'\r' || data[i] == b' ') {
60        i += 1;
61    }
62    if i >= data.len() || data[i] != b'"' {
63        return (0, 0);
64    }
65    let (nums, _) = parse_nums(data, i + 1);
66    if nums.len() >= 4 {
67        (nums[2], nums[3])
68    } else {
69        (0, 0)
70    }
71}
72
73/// Pasada de medición: corre la máquina sin pintar, devolviendo
74/// `(max_x+1, max_y+1)`.
75fn measure(data: &[u8]) -> (u32, u32) {
76    let mut x: u32 = 0;
77    let mut y0: u32 = 0;
78    let mut max_x: u32 = 0;
79    let mut max_y: u32 = 0;
80    let mut i = 0;
81    while i < data.len() {
82        let b = data[i];
83        match b {
84            b'"' | b'#' => {
85                let (_, ni) = parse_nums(data, i + 1);
86                i = ni;
87            }
88            b'!' => {
89                let (nums, ni) = parse_nums(data, i + 1);
90                i = ni;
91                let count = nums.first().copied().unwrap_or(0).max(1);
92                if i < data.len() {
93                    let c = data[i];
94                    i += 1;
95                    if (0x3f..=0x7e).contains(&c) {
96                        let bits = c - 0x3f;
97                        if bits != 0 {
98                            max_x = max_x.max(x + count - 1);
99                            max_y = max_y.max(y0 + top_bit(bits));
100                        }
101                        x += count;
102                    }
103                }
104            }
105            b'$' => {
106                x = 0;
107                i += 1;
108            }
109            b'-' => {
110                x = 0;
111                y0 += 6;
112                i += 1;
113            }
114            0x3f..=0x7e => {
115                let bits = b - 0x3f;
116                if bits != 0 {
117                    max_x = max_x.max(x);
118                    max_y = max_y.max(y0 + top_bit(bits));
119                }
120                x += 1;
121                i += 1;
122            }
123            _ => i += 1,
124        }
125    }
126    (max_x + 1, max_y + 1)
127}
128
129/// Pasada de pintura sobre `rgba` (ya dimensionado a `w`×`h`).
130fn paint(data: &[u8], w: u32, h: u32, rgba: &mut [u8]) {
131    let mut palette = default_palette();
132    let mut cur: u16 = 0;
133    let mut x: u32 = 0;
134    let mut y0: u32 = 0;
135    let mut i = 0;
136    while i < data.len() {
137        let b = data[i];
138        match b {
139            b'"' => {
140                let (_, ni) = parse_nums(data, i + 1);
141                i = ni;
142            }
143            b'#' => {
144                let (nums, ni) = parse_nums(data, i + 1);
145                i = ni;
146                if let Some(&pc) = nums.first() {
147                    cur = pc as u16;
148                    if nums.len() >= 5 {
149                        let pu = nums[1];
150                        let (px, py, pz) = (nums[2], nums[3], nums[4]);
151                        let color = if pu == 2 {
152                            [pct(px), pct(py), pct(pz), 255]
153                        } else {
154                            // Pu=1 (HLS) es raro; aproximamos como RGB para no
155                            // bajar a negro. Suficiente para chafa/img2sixel
156                            // que emiten siempre Pu=2.
157                            [pct(px), pct(py), pct(pz), 255]
158                        };
159                        palette.insert(cur, color);
160                    }
161                }
162            }
163            b'!' => {
164                let (nums, ni) = parse_nums(data, i + 1);
165                i = ni;
166                let count = nums.first().copied().unwrap_or(0).max(1);
167                if i < data.len() {
168                    let c = data[i];
169                    i += 1;
170                    if (0x3f..=0x7e).contains(&c) {
171                        let bits = c - 0x3f;
172                        let color = *palette.get(&cur).unwrap_or(&[0, 0, 0, 255]);
173                        for _ in 0..count {
174                            put(rgba, w, h, x, y0, bits, color);
175                            x += 1;
176                        }
177                    }
178                }
179            }
180            b'$' => {
181                x = 0;
182                i += 1;
183            }
184            b'-' => {
185                x = 0;
186                y0 += 6;
187                i += 1;
188            }
189            0x3f..=0x7e => {
190                let bits = b - 0x3f;
191                let color = *palette.get(&cur).unwrap_or(&[0, 0, 0, 255]);
192                put(rgba, w, h, x, y0, bits, color);
193                x += 1;
194                i += 1;
195            }
196            _ => i += 1,
197        }
198    }
199}
200
201/// Pinta los 6 píxeles verticales de un sixel en `(x, y0..y0+6)`.
202fn put(rgba: &mut [u8], w: u32, h: u32, x: u32, y0: u32, bits: u8, color: [u8; 4]) {
203    if x >= w {
204        return;
205    }
206    for r in 0..6u32 {
207        if bits & (1 << r) != 0 {
208            let y = y0 + r;
209            if y >= h {
210                continue;
211            }
212            let idx = ((y as usize) * (w as usize) + x as usize) * 4;
213            rgba[idx..idx + 4].copy_from_slice(&color);
214        }
215    }
216}
217
218/// Fila más alta (0-based) prendida por `bits` (LSB = arriba).
219fn top_bit(bits: u8) -> u32 {
220    let mut top = 0;
221    for r in 0..6u32 {
222        if bits & (1 << r) != 0 {
223            top = r;
224        }
225    }
226    top
227}
228
229/// Porcentaje sixel (0..100) → byte (0..255).
230fn pct(v: u32) -> u8 {
231    ((v.min(100) * 255 + 50) / 100) as u8
232}
233
234/// Lee una secuencia de enteros separados por `;` a partir de `start`.
235/// Devuelve los números y el índice del primer byte no consumido.
236fn parse_nums(data: &[u8], start: usize) -> (Vec<u32>, usize) {
237    let mut nums = Vec::new();
238    let mut i = start;
239    let mut cur: u32 = 0;
240    let mut any = false;
241    loop {
242        if i >= data.len() {
243            break;
244        }
245        let b = data[i];
246        if b.is_ascii_digit() {
247            cur = cur.saturating_mul(10).saturating_add((b - b'0') as u32);
248            any = true;
249            i += 1;
250        } else if b == b';' {
251            nums.push(cur);
252            cur = 0;
253            any = true;
254            i += 1;
255        } else {
256            break;
257        }
258    }
259    if any {
260        nums.push(cur);
261    }
262    (nums, i)
263}
264
265/// Paleta por defecto (negro en el registro 0; el resto se llena con las
266/// definiciones `#Pc;2;…` del stream). chafa/img2sixel siempre definen sus
267/// colores, así que no hace falta la paleta VT340 completa.
268fn default_palette() -> std::collections::HashMap<u16, [u8; 4]> {
269    let mut m = std::collections::HashMap::new();
270    m.insert(0, [0, 0, 0, 255]);
271    m
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    /// Sixel mínimo 1×6: un color RGB puro y un carácter con todos los bits.
279    #[test]
280    fn sixel_columna_1x6() {
281        // ESC P ya removido. Params vacíos + 'q'. Color 0 = rojo (100,0,0).
282        // '~' = 0x7e → bits = 0x3f = 0b111111 → 6 px prendidos.
283        let body = b"q#0;2;100;0;0~";
284        let img = decode(body).expect("decodifica");
285        assert_eq!((img.width, img.height), (1, 6));
286        // Todos los 6 px rojos opacos.
287        for px in img.rgba.chunks_exact(4) {
288            assert_eq!(px, &[255, 0, 0, 255]);
289        }
290    }
291
292    /// RLE: `!5~` = 5 columnas de 6 px.
293    #[test]
294    fn sixel_rle_ancho() {
295        let body = b"q#0;2;0;100;0!5~";
296        let img = decode(body).expect("decodifica");
297        assert_eq!((img.width, img.height), (5, 6));
298        // verde
299        assert_eq!(&img.rgba[0..4], &[0, 255, 0, 255]);
300    }
301
302    /// Atributos raster fijan el tamaño aunque los datos sean más chicos.
303    #[test]
304    fn sixel_raster_attrs() {
305        let body = b"q\"1;1;10;12#0;2;0;0;100~";
306        let img = decode(body).expect("decodifica");
307        assert_eq!((img.width, img.height), (10, 12));
308    }
309
310    /// LF `-` baja una banda (6 px) → alto 12.
311    #[test]
312    fn sixel_dos_bandas() {
313        let body = b"q#0;2;100;100;100~-~";
314        let img = decode(body).expect("decodifica");
315        assert_eq!(img.height, 12);
316    }
317}