rs-mrxvt/src/sixel.rs

399 lines
13 KiB
Rust

// SPDX-License-Identifier: GPL-2.0-only
//
// rs-mrxvt — a modernized, distro-agnostic mrxvt-inspired terminal emulator.
//
// Copyright (C) 2024 rs-mrxvt contributors
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation; either version 2 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License along
// with this program; if not, see <https://www.gnu.org/licenses/>.
//! Sixel image protocol parser.
//!
//! Sixel is an old DEC image format that pre-dates the modern PNG/JPEG era.
//! It's still used by `mlterm`, `xterm -ti vt340`, and various embedded
//! terminals. The format is a sequence of escape codes that describe a
//! raster image as runs of six-pixel-tall vertical strips ("sixels").
//!
//! ## Format
//!
//! Sixel data is sent inside a DCS (Device Control String):
//! ```text
//! DCS q <params> <sixel-data> ST
//! ```
//! - `DCS` = `ESC P` (0x1b 0x50)
//! - `q` is the Sixel command introducer
//! - `<params>` are semicolon-separated `key=val` pairs
//! - `<sixel-data>` is a mix of:
//! - `?` repeat count (e.g. `!10?` = repeat 10 times)
//! - `#` color register selection (e.g. `#0` = use color 0)
//! - `#N;r;g;b` define color N as r,g,b (0..100)
//! - `!N<char>` repeat character N times
//! - `$` carriage return (move to start of current line)
//! - `-` new line of sixels
//! - chars `?` (0x3f, 0b000000) to `~` (0x7e, 0b111111): 6 pixels high
//! - `ST` = `ESC \` (0x1b 0x5c)
//!
//! ## Status
//!
//! This module parses Sixel data into an RGBA buffer. It compiles only with
//! `--features images` (reuses [`crate::images::InlineImage`] for storage).
use crate::images::{ImageError, InlineImage};
/// Parse a Sixel data stream into an [`InlineImage`].
///
/// `data` is the bytes between `DCS q` and `ST` (exclusive). The caller is
/// responsible for extracting this from the PTY stream; the DCS handler in
/// `alacritty_terminal` would normally intercept this and we'd hook it.
///
/// For the MVP we expose the parser as a pure function so it can be tested
/// without a PTY.
pub fn parse_sixel(
data: &[u8],
start_col: u32,
start_row: u32,
cell_w_px: u32,
cell_h_px: u32,
) -> Result<InlineImage, ImageError> {
let mut parser = SixelParser::new();
parser.feed(data)?;
let (pixels, w, h) = parser.finalize();
let cell_width = w.div_ceil(cell_w_px);
let cell_height = h.div_ceil(cell_h_px);
Ok(InlineImage {
pixels,
width: w,
height: h,
start_col,
start_row,
cell_width,
cell_height,
})
}
/// Color register: RGB in 0..255.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ColorReg {
r: u8,
g: u8,
b: u8,
}
/// Sixel parser state machine.
struct SixelParser {
/// The output pixel buffer in row-major RGBA order.
pixels: Vec<u8>,
/// Width in pixels (grows as we see more sixels in a row).
width: u32,
/// Height in pixels (grows as we see more rows).
height: u32,
/// Current X position (in sixel columns).
x: u32,
/// Current "row" of sixels (each sixel is 6 pixels tall; the row counter
/// advances by 6 when we see `-`).
y_base: u32,
/// Color registers (index → RGB).
color_regs: Vec<ColorReg>,
/// Currently selected color register.
current_color: u32,
/// Parser state for parameter parsing (after `#` or `!`).
state: SixelState,
/// Buffer for accumulating multi-digit parameter values.
param_buf: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SixelState {
Ground,
/// Saw `#`, accumulating color register index (or `;r;g;b` definition).
ColorIntroducer,
/// Saw `!`, accumulating repeat count.
RepeatCount,
}
impl SixelParser {
fn new() -> Self {
Self {
pixels: Vec::new(),
width: 0,
height: 0,
x: 0,
y_base: 0,
color_regs: vec![ColorReg { r: 255, g: 255, b: 255 }; 256],
current_color: 0,
state: SixelState::Ground,
param_buf: String::new(),
}
}
fn feed(&mut self, data: &[u8]) -> Result<(), ImageError> {
for &b in data {
let c = b as char;
match self.state {
SixelState::Ground => self.handle_ground(c)?,
SixelState::ColorIntroducer => self.handle_color_param(c)?,
SixelState::RepeatCount => self.handle_repeat(c)?,
}
}
Ok(())
}
fn handle_ground(&mut self, c: char) -> Result<(), ImageError> {
match c {
// Color register selection / definition.
'#' => {
self.state = SixelState::ColorIntroducer;
self.param_buf.clear();
}
// Repeat introducer.
'!' => {
self.state = SixelState::RepeatCount;
self.param_buf.clear();
}
// Carriage return (within the current sixel row).
'$' => {
self.x = 0;
}
// New sixel row (advances by 6 pixels vertically).
'-' => {
self.x = 0;
self.y_base += 6;
let new_h = self.y_base + 6;
if new_h > self.height {
self.height = new_h;
self.grow_buffer();
}
}
// Sixel character: 6 pixels tall, encoded as ?..~ (0x3f..0x7e).
'?'..='~' => {
let sixel = (c as u8) - 0x3f; // 0b000000..0b111111
self.draw_sixel(sixel);
self.x += 1;
if self.x > self.width {
self.width = self.x;
self.grow_buffer();
}
}
// Whitespace and unknown chars are ignored.
_ => {}
}
Ok(())
}
fn handle_color_param(&mut self, c: char) -> Result<(), ImageError> {
if c.is_ascii_digit() || c == ';' {
self.param_buf.push(c);
return Ok(());
}
// End of param: parse what we have.
let parts: Vec<&str> = self.param_buf.split(';').collect();
if parts.len() == 4 {
// #N;r;g;b — define color N.
let n: u32 = parts[0].parse().unwrap_or(0);
let r: u32 = parts[1].parse().unwrap_or(0).min(100);
let g: u32 = parts[2].parse().unwrap_or(0).min(100);
let b: u32 = parts[3].parse().unwrap_or(0).min(100);
let idx = n as usize;
if idx < self.color_regs.len() {
// Sixel colors are 0..100; scale to 0..255.
self.color_regs[idx] = ColorReg {
r: (r * 255 / 100) as u8,
g: (g * 255 / 100) as u8,
b: (b * 255 / 100) as u8,
};
}
self.current_color = n;
} else if parts.len() == 1 && !parts[0].is_empty() {
// #N — select color N.
self.current_color = parts[0].parse().unwrap_or(0);
}
self.state = SixelState::Ground;
// Re-process the current char in Ground state.
self.handle_ground(c)
}
fn handle_repeat(&mut self, c: char) -> Result<(), ImageError> {
if c.is_ascii_digit() {
self.param_buf.push(c);
return Ok(());
}
// End of count: parse and repeat the next char.
let count: u32 = self.param_buf.parse().unwrap_or(1).max(1);
self.state = SixelState::Ground;
if ('?'..='~').contains(&c) {
let sixel = (c as u8) - 0x3f;
for _ in 0..count {
self.draw_sixel(sixel);
self.x += 1;
if self.x > self.width {
self.width = self.x;
self.grow_buffer();
}
}
}
// Other chars: ignore (the spec says the char after !N must be a sixel).
Ok(())
}
/// Draw one sixel (6 vertical pixels) at the current (x, y_base).
fn draw_sixel(&mut self, sixel: u8) {
let color = self.color_regs.get(self.current_color as usize)
.copied()
.unwrap_or(ColorReg { r: 255, g: 255, b: 255 });
for bit in 0..6 {
if (sixel >> bit) & 1 == 1 {
let py = self.y_base + bit;
self.set_pixel(self.x, py, color);
}
}
}
/// Set a single pixel, growing the buffer if necessary.
fn set_pixel(&mut self, x: u32, y: u32, color: ColorReg) {
// Grow height if needed.
let needed_h = y + 1;
if needed_h > self.height {
self.height = needed_h;
self.grow_buffer();
}
// Grow width if needed.
let needed_w = x + 1;
if needed_w > self.width {
self.width = needed_w;
self.grow_buffer();
}
let idx = ((y * self.width + x) * 4) as usize;
if idx + 3 < self.pixels.len() {
self.pixels[idx] = color.r;
self.pixels[idx + 1] = color.g;
self.pixels[idx + 2] = color.b;
self.pixels[idx + 3] = 255;
}
}
/// Resize the pixel buffer to match current width/height.
fn grow_buffer(&mut self) {
let new_size = (self.width * self.height * 4) as usize;
if self.pixels.len() < new_size {
self.pixels.resize(new_size, 0);
}
}
/// Finalize: return (pixels, width, height).
fn finalize(self) -> (Vec<u8>, u32, u32) {
(self.pixels, self.width, self.height)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_sixel_yields_empty_image() {
let img = parse_sixel(b"", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 0);
assert_eq!(img.height, 0);
}
#[test]
fn single_sixel_draws_six_pixels() {
// '?' = 0b000000 (no pixels). '~' = 0b111111 (all 6 pixels).
// Draw one column of all-on sixels using the default color (white).
let img = parse_sixel(b"~", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 1);
assert_eq!(img.height, 6);
// Top pixel should be the default white.
assert_eq!(img.pixels[0], 255); // R
assert_eq!(img.pixels[1], 255); // G
assert_eq!(img.pixels[2], 255); // B
assert_eq!(img.pixels[3], 255); // A
}
#[test]
fn color_register_definition() {
// Define color 0 as red (100,0,0), then draw with it.
// #0;1;0;0 means: color 0 = (100%, 0%, 0%)
let img = parse_sixel(b"#0;100;0;0~", 0, 0, 8, 16).unwrap();
assert_eq!(img.pixels[0], 255); // R = 100% → 255
assert_eq!(img.pixels[1], 0); // G = 0
assert_eq!(img.pixels[2], 0); // B = 0
}
#[test]
fn color_register_selection() {
// Define color 1 as green, select it, draw with it.
let img = parse_sixel(b"#1;0;100;0#1~", 0, 0, 8, 16).unwrap();
assert_eq!(img.pixels[0], 0); // R
assert_eq!(img.pixels[1], 255); // G
assert_eq!(img.pixels[2], 0); // B
}
#[test]
fn repeat_count() {
// !5~ = draw 5 columns of all-on sixels.
let img = parse_sixel(b"!5~", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 5);
assert_eq!(img.height, 6);
}
#[test]
fn newline_advances_by_six() {
// ~ - ~ draws one sixel, then a new row, then another.
let img = parse_sixel(b"~-~", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 1);
assert_eq!(img.height, 12); // two rows of 6
}
#[test]
fn carriage_return_resets_x() {
// ~ $ ~ draws one sixel, CR, then another in the same column.
let img = parse_sixel(b"~$~", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 1);
assert_eq!(img.height, 6);
}
#[test]
fn multiple_sixels_in_row_grow_width() {
// ~~~ = three columns of all-on sixels.
let img = parse_sixel(b"~~~", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 3);
assert_eq!(img.height, 6);
}
#[test]
fn whitespace_ignored() {
// Spaces and newlines (real ones, not '-') in the data are ignored.
let img = parse_sixel(b" ~ \n ", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 1);
}
#[test]
fn cell_footprint_rounded_up() {
// 9-pixel-wide image with 8px cells → 2 cells wide.
let img = parse_sixel(b"!9~", 0, 0, 8, 16).unwrap();
assert_eq!(img.width, 9);
assert_eq!(img.cell_width, 2);
}
#[test]
fn start_position_propagates() {
let img = parse_sixel(b"~", 5, 3, 8, 16).unwrap();
assert_eq!(img.start_col, 5);
assert_eq!(img.start_row, 3);
}
}