tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1
llimphi-term-graphics/src/sixel.rs
Historia
Quién tocó qué
crudo
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}