use std::fmt;
use std::sync::Arc;
pub(crate) const DEFAULT_CAPTURE: usize = 4 << 20;
pub(crate) const HISTORY: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum GraphicsProtocol {
Kitty,
Sixel,
}
impl fmt::Display for GraphicsProtocol {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
GraphicsProtocol::Kitty => "kitty",
GraphicsProtocol::Sixel => "sixel",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum GraphicsAction {
Transmit,
Place,
TransmitAndPlace,
Delete,
Other,
}
impl GraphicsAction {
#[must_use]
pub fn carries_image(self) -> bool {
matches!(
self,
GraphicsAction::Transmit | GraphicsAction::TransmitAndPlace
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum GraphicsFormat {
Rgb,
Rgba,
Png,
Sixel,
Other(u32),
}
impl fmt::Display for GraphicsFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GraphicsFormat::Rgb => f.write_str("rgb"),
GraphicsFormat::Rgba => f.write_str("rgba"),
GraphicsFormat::Png => f.write_str("png"),
GraphicsFormat::Sixel => f.write_str("sixel"),
GraphicsFormat::Other(value) => write!(f, "f={value}"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct GraphicsPayload {
protocol: GraphicsProtocol,
action: GraphicsAction,
format: GraphicsFormat,
compressed: bool,
id: Option<u32>,
size: Option<(u32, u32)>,
cells: Option<(u16, u16)>,
chunks: u32,
bytes: u64,
at: (u16, u16),
data: Option<Arc<[u8]>>,
}
impl fmt::Debug for GraphicsPayload {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{} {:?} {}",
self.protocol,
self.action,
match self.size {
Some((w, h)) => format!("{w}x{h}px"),
None => "?px".into(),
}
)?;
if let Some((cols, rows)) = self.cells {
write!(f, " {cols}x{rows}cells")?;
}
write!(f, " at {:?}", self.at)?;
if let Some(id) = self.id {
write!(f, " i={id}")?;
}
write!(f, " {} {} bytes", self.format, self.bytes)?;
if self.compressed {
f.write_str(" zlib")?;
}
if self.chunks > 1 {
write!(f, " in {} chunks", self.chunks)?;
}
if self.data.is_none() {
f.write_str(" (not captured)")?;
}
Ok(())
}
}
impl GraphicsPayload {
#[must_use]
pub fn protocol(&self) -> GraphicsProtocol {
self.protocol
}
#[must_use]
pub fn action(&self) -> GraphicsAction {
self.action
}
#[must_use]
pub fn format(&self) -> GraphicsFormat {
self.format
}
#[must_use]
pub fn compressed(&self) -> bool {
self.compressed
}
#[must_use]
pub fn id(&self) -> Option<u32> {
self.id
}
#[must_use]
pub fn size(&self) -> Option<(u32, u32)> {
self.size
}
#[must_use]
pub fn cells(&self) -> Option<(u16, u16)> {
self.cells
}
#[must_use]
pub fn at(&self) -> (u16, u16) {
self.at
}
#[must_use]
pub fn chunks(&self) -> u32 {
self.chunks
}
#[must_use]
pub fn bytes(&self) -> u64 {
self.bytes
}
#[must_use]
pub fn data(&self) -> Option<&[u8]> {
self.data.as_deref()
}
#[cfg(feature = "decode")]
pub fn decode(&self) -> Result<Bitmap, DecodeError> {
let data = self.data.as_deref().ok_or(DecodeError::NotCaptured)?;
match self.protocol {
GraphicsProtocol::Kitty => self.decode_kitty(data),
GraphicsProtocol::Sixel => decode_sixel(data),
}
}
#[cfg(feature = "decode")]
fn decode_kitty(&self, data: &[u8]) -> Result<Bitmap, DecodeError> {
let (channels, has_alpha) = match self.format {
GraphicsFormat::Rgb => (3usize, false),
GraphicsFormat::Rgba => (4usize, true),
GraphicsFormat::Png => return Err(DecodeError::Unsupported("kitty f=100 (PNG)")),
other => {
return Err(DecodeError::Unsupported(match other {
GraphicsFormat::Sixel => "a sixel stream sent as kitty data",
_ => "an unknown kitty f= format",
}))
}
};
if !self.action.carries_image() {
return Err(DecodeError::NoImage(self.action));
}
let (width, height) = self.size.ok_or(DecodeError::Malformed(
"a kitty transmission without s= and v=",
))?;
if width as usize > MAX_DECODED_WIDTH || height as usize > MAX_DECODED_HEIGHT {
return Err(DecodeError::TooLarge(
"a kitty transmission declaring more than 4096x4096",
));
}
let wanted = (width as usize)
.checked_mul(height as usize)
.and_then(|pixels| pixels.checked_mul(channels))
.ok_or(DecodeError::Malformed("a declared size that overflows"))?;
let raw = crate::emu::decode_base64(data).ok_or(DecodeError::Malformed("bad base64"))?;
let raw = if self.compressed {
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&raw, wanted).map_err(|_| {
DecodeError::Malformed("zlib data that would not inflate within the declared size")
})?
} else {
raw
};
if raw.len() < wanted {
return Err(DecodeError::Malformed(
"fewer bytes than the declared size needs",
));
}
let mut pixels = Vec::with_capacity(wanted / channels);
for chunk in raw[..wanted].chunks_exact(channels) {
pixels.push([
chunk[0],
chunk[1],
chunk[2],
if has_alpha { chunk[3] } else { 0xff },
]);
}
Ok(Bitmap {
width,
height,
pixels,
})
}
pub(crate) fn place(&mut self, at: (u16, u16)) {
self.at = at;
}
}
#[cfg(feature = "decode")]
#[derive(Clone, PartialEq, Eq)]
pub struct Bitmap {
width: u32,
height: u32,
pixels: Vec<[u8; 4]>,
}
#[cfg(feature = "decode")]
impl fmt::Debug for Bitmap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Bitmap {}x{}", self.width, self.height)
}
}
#[cfg(feature = "decode")]
impl Bitmap {
#[must_use]
pub fn width(&self) -> u32 {
self.width
}
#[must_use]
pub fn height(&self) -> u32 {
self.height
}
#[must_use]
pub fn pixel(&self, x: u32, y: u32) -> Option<[u8; 4]> {
if x >= self.width || y >= self.height {
return None;
}
let index = (y as usize) * (self.width as usize) + (x as usize);
self.pixels.get(index).copied()
}
#[must_use]
pub fn colours(&self) -> Vec<([u8; 4], u32)> {
use std::collections::HashMap;
let mut counts: HashMap<[u8; 4], (u32, usize)> = HashMap::new();
for (index, pixel) in self.pixels.iter().enumerate() {
counts
.entry(*pixel)
.and_modify(|(count, _)| *count += 1)
.or_insert((1, index));
}
let mut seen: Vec<([u8; 4], u32, usize)> = counts
.into_iter()
.map(|(colour, (count, first))| (colour, count, first))
.collect();
seen.sort_unstable_by_key(|&(_, count, first)| (std::cmp::Reverse(count), first));
seen.into_iter()
.map(|(colour, count, _)| (colour, count))
.collect()
}
}
#[cfg(feature = "decode")]
const MAX_DECODED_WIDTH: usize = 4096;
#[cfg(feature = "decode")]
const MAX_DECODED_HEIGHT: usize = 4096;
#[cfg(feature = "decode")]
const MAX_SIXEL_REGISTERS: usize = 65_536;
#[cfg(feature = "decode")]
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DecodeError {
NotCaptured,
NoImage(GraphicsAction),
Unsupported(&'static str),
Malformed(&'static str),
TooLarge(&'static str),
}
#[cfg(feature = "decode")]
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DecodeError::NotCaptured => f.write_str(
"the payload was counted but not kept — raise TerminalBuilder::capture_graphics",
),
DecodeError::NoImage(action) => {
write!(f, "a {action:?} action carries no image data")
}
DecodeError::Unsupported(what) => write!(f, "termlens does not decode {what}"),
DecodeError::Malformed(what) => write!(f, "the payload carries {what}"),
DecodeError::TooLarge(what) => write!(f, "termlens will not decode {what}"),
}
}
}
#[cfg(feature = "decode")]
impl std::error::Error for DecodeError {}
#[cfg(feature = "decode")]
fn decode_sixel(data: &[u8]) -> Result<Bitmap, DecodeError> {
type Registers = Vec<Option<[u8; 4]>>;
fn percent(value: u32) -> u8 {
((value.min(100) * 255 + 50) / 100) as u8
}
let mut at = 0usize;
let mut declared: Option<(u32, u32)> = None;
let mut registers: Registers = vec![None; 256];
let mut current = 0usize;
let mut rows: Vec<Vec<Option<[u8; 4]>>> = Vec::new();
let mut band_top = 0usize;
let mut x = 0usize;
let mut width_seen = 0usize;
fn params(data: &[u8], at: &mut usize) -> Vec<u32> {
let mut out = vec![0u32];
while *at < data.len() {
match data[*at] {
b'0'..=b'9' => {
let last = out.last_mut().expect("seeded with one parameter");
*last = last
.saturating_mul(10)
.saturating_add(u32::from(data[*at] - b'0'));
}
b';' => out.push(0),
_ => break,
}
*at += 1;
}
out
}
while at < data.len() {
match data[at] {
b'"' => {
at += 1;
let raster = params(data, &mut at);
if let (Some(&width), Some(&height)) = (raster.get(2), raster.get(3)) {
declared = Some((width, height));
}
}
b'#' => {
at += 1;
let values = params(data, &mut at);
let index = values.first().copied().unwrap_or(0) as usize;
if index >= MAX_SIXEL_REGISTERS {
return Err(DecodeError::TooLarge(
"a sixel colour register index past 65536",
));
}
if index >= registers.len() {
registers.resize(index + 1, None);
}
if values.len() >= 5 {
if values[1] != 2 {
return Err(DecodeError::Unsupported("sixel HLS colours"));
}
registers[index] = Some([
percent(values[2]),
percent(values[3]),
percent(values[4]),
0xff,
]);
}
current = index;
}
b'!' => {
at += 1;
let counts = params(data, &mut at);
let count = counts.first().copied().unwrap_or(0) as usize;
if at < data.len() && (0x3f..=0x7e).contains(&data[at]) {
let bits = data[at] - 0x3f;
at += 1;
paint(
&mut rows,
&mut width_seen,
band_top,
&mut x,
bits,
count,
registers.get(current).copied().flatten(),
)?;
}
}
0x3f..=0x7e => {
let bits = data[at] - 0x3f;
at += 1;
paint(
&mut rows,
&mut width_seen,
band_top,
&mut x,
bits,
1,
registers.get(current).copied().flatten(),
)?;
}
b'$' => {
at += 1;
x = 0;
}
b'-' => {
at += 1;
band_top += 6;
x = 0;
}
_ => at += 1,
}
}
fn paint(
rows: &mut Vec<Vec<Option<[u8; 4]>>>,
width_seen: &mut usize,
band_top: usize,
x: &mut usize,
bits: u8,
count: usize,
colour: Option<[u8; 4]>,
) -> Result<(), DecodeError> {
for _ in 0..count {
if *x >= MAX_DECODED_WIDTH {
return Err(DecodeError::TooLarge("a sixel wider than 4096 pixels"));
}
if let Some(colour) = colour {
for bit in 0..6 {
if bits & (1 << bit) != 0 {
let y = band_top + bit;
if y >= MAX_DECODED_HEIGHT {
return Err(DecodeError::TooLarge("a sixel taller than 4096 pixels"));
}
if rows.len() <= y {
rows.resize(y + 1, Vec::new());
}
let row = &mut rows[y];
if row.len() <= *x {
row.resize(*x + 1, None);
}
row[*x] = Some(colour);
}
}
}
*x += 1;
*width_seen = (*width_seen).max(*x);
}
Ok(())
}
let (width, height) = match declared {
Some((width, height)) if width > 0 && height > 0 => (width, height),
_ => (width_seen as u32, rows.len() as u32),
};
if width as usize > MAX_DECODED_WIDTH || height as usize > MAX_DECODED_HEIGHT {
return Err(DecodeError::TooLarge(
"a sixel declaring more than 4096x4096",
));
}
let mut pixels = Vec::with_capacity((width as usize).saturating_mul(height as usize));
for y in 0..height as usize {
for x in 0..width as usize {
pixels.push(
rows.get(y)
.and_then(|row| row.get(x))
.copied()
.flatten()
.unwrap_or([0, 0, 0, 0]),
);
}
}
Ok(Bitmap {
width,
height,
pixels,
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GraphicsSeen {
pub(crate) counts: GraphicsCounts,
pub(crate) payloads: Arc<Vec<GraphicsPayload>>,
}
impl GraphicsSeen {
pub(crate) fn new(counts: GraphicsCounts, payloads: Arc<Vec<GraphicsPayload>>) -> Self {
Self { counts, payloads }
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct GraphicsCounts {
pub(crate) kitty: u32,
pub(crate) sixel: u32,
pub(crate) deletes: u32,
pub(crate) bytes: u64,
}
impl GraphicsCounts {
pub(crate) fn record(&mut self, payload: &GraphicsPayload) {
self.bytes += payload.bytes();
if payload.action() == GraphicsAction::Delete {
self.deletes += 1;
}
if !payload.action().carries_image() {
return;
}
match payload.protocol() {
GraphicsProtocol::Kitty => self.kitty += 1,
GraphicsProtocol::Sixel => self.sixel += 1,
}
}
}
impl GraphicsSeen {
#[must_use]
pub fn kitty(&self) -> u32 {
self.counts.kitty
}
#[must_use]
pub fn sixel(&self) -> u32 {
self.counts.sixel
}
#[must_use]
pub fn total(&self) -> u32 {
self.counts.kitty + self.counts.sixel
}
#[must_use]
pub fn deletes(&self) -> u32 {
self.counts.deletes
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.total() == 0
}
#[must_use]
pub fn bytes(&self) -> u64 {
self.counts.bytes
}
#[must_use]
pub fn payloads(&self) -> &[GraphicsPayload] {
&self.payloads
}
#[must_use]
pub fn last(&self) -> Option<&GraphicsPayload> {
self.payloads.last()
}
#[cfg(test)]
pub(crate) fn for_test(kitty: u32, sixel: u32, deletes: u32, bytes: u64) -> Self {
Self {
counts: GraphicsCounts {
kitty,
sixel,
deletes,
bytes,
},
payloads: Arc::new(Vec::new()),
}
}
}
#[derive(Debug)]
pub(crate) struct GraphicsBuilder {
protocol: Option<GraphicsProtocol>,
action: GraphicsAction,
format: GraphicsFormat,
compressed: bool,
id: Option<u32>,
size: Option<(u32, u32)>,
cells: Option<(u16, u16)>,
chunks: u32,
bytes: u64,
data: Vec<u8>,
dropped: bool,
}
impl Default for GraphicsBuilder {
fn default() -> Self {
Self {
protocol: None,
action: GraphicsAction::Other,
format: GraphicsFormat::Rgba,
compressed: false,
id: None,
size: None,
cells: None,
chunks: 0,
bytes: 0,
data: Vec::new(),
dropped: false,
}
}
}
impl GraphicsBuilder {
pub(crate) fn in_progress(&self) -> bool {
self.protocol.is_some()
}
pub(crate) fn kitty(&mut self, control: &[u8]) {
if self.protocol.is_none() {
self.protocol = Some(GraphicsProtocol::Kitty);
self.action = match key(control, b"a") {
Some(b"t") => GraphicsAction::Transmit,
Some(b"p") => GraphicsAction::Place,
Some(b"T") | None => GraphicsAction::TransmitAndPlace,
Some(b"d") => GraphicsAction::Delete,
Some(_) => GraphicsAction::Other,
};
self.format = match number(control, b"f") {
Some(24) => GraphicsFormat::Rgb,
Some(32) | None => GraphicsFormat::Rgba,
Some(100) => GraphicsFormat::Png,
Some(other) => GraphicsFormat::Other(other),
};
self.compressed = key(control, b"o") == Some(b"z");
self.id = number(control, b"i");
self.size = match (number(control, b"s"), number(control, b"v")) {
(Some(width), Some(height)) => Some((width, height)),
_ => None,
};
self.cells = match (number(control, b"c"), number(control, b"r")) {
(Some(cols), Some(rows)) => Some((
cols.min(u32::from(u16::MAX)) as u16,
rows.min(u32::from(u16::MAX)) as u16,
)),
_ => None,
};
}
}
pub(crate) fn sixel(&mut self) {
self.protocol = Some(GraphicsProtocol::Sixel);
self.action = GraphicsAction::TransmitAndPlace;
self.format = GraphicsFormat::Sixel;
}
pub(crate) fn chunk(&mut self, bytes: u64, data: &[u8], complete: bool, cap: usize) {
self.chunks += 1;
self.bytes += bytes;
let fits = self.data.len().saturating_add(data.len()) <= cap;
if complete && fits && !self.dropped {
self.data.extend_from_slice(data);
} else {
self.dropped = true;
self.data = Vec::new();
}
}
pub(crate) fn finish(&mut self) -> Option<GraphicsPayload> {
let protocol = self.protocol.take()?;
let mut size = self.size;
if protocol == GraphicsProtocol::Sixel {
size = raster_size(&self.data).or(size);
}
let payload = GraphicsPayload {
protocol,
action: self.action,
format: self.format,
compressed: self.compressed,
id: self.id,
size,
cells: self.cells,
chunks: self.chunks,
bytes: self.bytes,
at: (0, 0),
data: (!self.dropped)
.then(|| Arc::from(std::mem::take(&mut self.data).into_boxed_slice())),
};
*self = Self::default();
Some(payload)
}
}
fn raster_size(data: &[u8]) -> Option<(u32, u32)> {
let at = data.iter().position(|&b| b == b'"')?;
if data[..at].iter().any(|b| (0x3f..=0x7e).contains(b)) {
return None;
}
let mut values = vec![0u32];
for &b in &data[at + 1..] {
match b {
b'0'..=b'9' => {
let last = values.last_mut().expect("seeded with one parameter");
*last = last.saturating_mul(10).saturating_add(u32::from(b - b'0'));
}
b';' => values.push(0),
_ => break,
}
}
match (values.get(2), values.get(3)) {
(Some(&width), Some(&height)) if width > 0 && height > 0 => Some((width, height)),
_ => None,
}
}
fn key<'a>(control: &'a [u8], name: &[u8]) -> Option<&'a [u8]> {
control.split(|&b| b == b',').find_map(|pair| {
let (found, value) = split_once(pair, b'=')?;
(found == name).then_some(value)
})
}
fn number(control: &[u8], name: &[u8]) -> Option<u32> {
std::str::from_utf8(key(control, name)?).ok()?.parse().ok()
}
fn split_once(bytes: &[u8], separator: u8) -> Option<(&[u8], &[u8])> {
let at = bytes.iter().position(|&b| b == separator)?;
Some((&bytes[..at], &bytes[at + 1..]))
}
#[cfg(test)]
mod tests {
use super::*;
fn kitty_payload(control: &[u8], data: &[u8]) -> GraphicsPayload {
let mut builder = GraphicsBuilder::default();
builder.kitty(control);
builder.chunk(data.len() as u64, data, true, usize::MAX);
builder.finish().expect("a payload")
}
#[test]
fn a_kitty_control_block_yields_every_fact_it_states() {
let payload = kitty_payload(b"a=T,q=2,f=32,o=z,s=954,v=133,i=7,c=106,r=7", b"AAAA");
assert_eq!(payload.protocol(), GraphicsProtocol::Kitty);
assert_eq!(payload.action(), GraphicsAction::TransmitAndPlace);
assert_eq!(payload.format(), GraphicsFormat::Rgba);
assert!(payload.compressed());
assert_eq!(payload.id(), Some(7));
assert_eq!(payload.size(), Some((954, 133)));
assert_eq!(payload.cells(), Some((106, 7)));
assert_eq!(payload.data(), Some(&b"AAAA"[..]));
}
#[test]
fn the_protocols_defaults_are_the_protocols_defaults() {
let payload = kitty_payload(b"s=1,v=1", b"AAAA");
assert_eq!(payload.action(), GraphicsAction::TransmitAndPlace);
assert_eq!(payload.format(), GraphicsFormat::Rgba);
assert!(!payload.compressed());
}
#[test]
fn a_delete_is_not_an_image() {
let payload = kitty_payload(b"a=d,d=I,i=1,q=2", b"");
assert_eq!(payload.action(), GraphicsAction::Delete);
assert!(!payload.action().carries_image());
}
#[test]
fn an_unknown_action_is_not_guessed_to_carry_one() {
let payload = kitty_payload(b"a=z,i=1", b"");
assert_eq!(payload.action(), GraphicsAction::Other);
assert!(!payload.action().carries_image());
}
#[test]
fn chunks_join_into_one_payload() {
let mut builder = GraphicsBuilder::default();
builder.kitty(b"a=T,f=32,s=2,v=1,m=1");
builder.chunk(20, b"AAAA", true, usize::MAX);
builder.chunk(10, b"BBBB", true, usize::MAX);
let payload = builder.finish().expect("a payload");
assert_eq!(payload.chunks(), 2);
assert_eq!(payload.bytes(), 30);
assert_eq!(payload.data(), Some(&b"AAAABBBB"[..]));
}
#[test]
fn a_payload_past_the_bound_is_counted_and_not_kept() {
let mut builder = GraphicsBuilder::default();
builder.kitty(b"a=T,f=32,s=2,v=1");
builder.chunk(64, b"AAAABBBB", false, usize::MAX);
let payload = builder.finish().expect("a payload");
assert_eq!(payload.bytes(), 64, "the cost is still known");
assert_eq!(payload.data(), None, "and the bytes are not kept");
}
#[test]
fn a_sixel_reads_its_size_off_its_raster_attributes() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(30, b"\"1;1;18;12#0;2;100;100;100~", true, usize::MAX);
let payload = builder.finish().expect("a payload");
assert_eq!(payload.protocol(), GraphicsProtocol::Sixel);
assert_eq!(payload.size(), Some((18, 12)));
assert_eq!(payload.format(), GraphicsFormat::Sixel);
}
#[test]
fn a_sixel_that_declares_no_size_says_so_rather_than_guessing() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(10, b"#0;2;100;100;100~~~", true, usize::MAX);
assert_eq!(builder.finish().expect("a payload").size(), None);
}
#[cfg(feature = "decode")]
#[test]
fn a_kitty_rgba_transmission_decodes_to_its_pixels() {
let raw = [0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0xff, 0x80];
let data = base64(&raw);
let payload = kitty_payload(b"a=T,f=32,s=2,v=1", data.as_bytes());
let bitmap = payload.decode().expect("decodes");
assert_eq!((bitmap.width(), bitmap.height()), (2, 1));
assert_eq!(bitmap.pixel(0, 0), Some([0xff, 0, 0, 0xff]));
assert_eq!(bitmap.pixel(1, 0), Some([0, 0, 0xff, 0x80]));
assert_eq!(bitmap.pixel(2, 0), None, "out of bounds is None");
}
#[cfg(feature = "decode")]
#[test]
fn an_rgb_transmission_is_opaque() {
let data = base64(&[0x11, 0x22, 0x33]);
let payload = kitty_payload(b"a=T,f=24,s=1,v=1", data.as_bytes());
let bitmap = payload.decode().expect("decodes");
assert_eq!(bitmap.pixel(0, 0), Some([0x11, 0x22, 0x33, 0xff]));
}
#[cfg(feature = "decode")]
fn sixel_payload(data: &[u8]) -> GraphicsPayload {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(data.len() as u64, data, true, usize::MAX);
builder.finish().expect("a payload")
}
#[cfg(feature = "decode")]
#[test]
fn a_payload_cannot_choose_how_much_memory_a_decode_spends() {
assert!(
matches!(
kitty_payload(b"a=T,f=32,s=65535,v=65535", b"AAAA").decode(),
Err(DecodeError::TooLarge(_))
),
"a 65535x65535 kitty transmission was not refused"
);
let bomb = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 4 << 20], 9);
assert!(bomb.len() < 64 << 10, "the fixture is meant to be small");
assert!(
matches!(
kitty_payload(b"a=T,f=32,o=z,s=1,v=1", base64(&bomb).as_bytes()).decode(),
Err(DecodeError::Malformed(_))
),
"a payload inflating past its declared size was not refused"
);
assert!(
matches!(
sixel_payload(b"#4000000000;2;100;100;100~").decode(),
Err(DecodeError::TooLarge(_))
),
"a four-billion colour register was not refused"
);
assert!(
matches!(
sixel_payload(b"#0;2;100;100;100!4294967295~").decode(),
Err(DecodeError::TooLarge(_))
),
"a four-billion repeat was not refused"
);
assert!(
matches!(
sixel_payload(b"\"1;1;65535;65535#0;2;100;100;100~").decode(),
Err(DecodeError::TooLarge(_))
),
"a 65535x65535 declared sixel was not refused"
);
}
#[cfg(feature = "decode")]
#[test]
fn an_ordinary_image_is_untouched_by_the_ceiling() {
let raw = vec![0x40u8; 64 * 32 * 4];
let bitmap = kitty_payload(b"a=T,f=32,s=64,v=32", base64(&raw).as_bytes())
.decode()
.expect("a 64x32 image still decodes");
assert_eq!((bitmap.width(), bitmap.height()), (64, 32));
assert_eq!(bitmap.pixel(63, 31), Some([0x40, 0x40, 0x40, 0x40]));
let sixel = sixel_payload(b"\"1;1;4;6#0;2;100;0;0~~~~")
.decode()
.expect("a small sixel still decodes");
assert_eq!((sixel.width(), sixel.height()), (4, 6));
}
#[cfg(feature = "decode")]
#[test]
fn a_compressed_transmission_is_inflated_first() {
let raw = vec![0x40u8; 4 * 16 * 16];
let data = base64(&miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6));
let payload = kitty_payload(b"a=T,f=32,o=z,s=16,v=16", data.as_bytes());
let bitmap = payload.decode().expect("decodes");
assert_eq!((bitmap.width(), bitmap.height()), (16, 16));
assert_eq!(bitmap.pixel(15, 15), Some([0x40, 0x40, 0x40, 0x40]));
}
#[cfg(feature = "decode")]
#[test]
fn every_refusal_names_its_reason() {
let png = kitty_payload(b"a=T,f=100,s=1,v=1", b"AAAA");
assert!(matches!(png.decode(), Err(DecodeError::Unsupported(_))));
let delete = kitty_payload(b"a=d,i=1", b"");
assert!(matches!(
delete.decode(),
Err(DecodeError::NoImage(GraphicsAction::Delete))
));
let short = kitty_payload(b"a=T,f=32,s=64,v=64", b"AAAA");
assert!(matches!(short.decode(), Err(DecodeError::Malformed(_))));
let sizeless = kitty_payload(b"a=T,f=32", b"AAAA");
assert!(matches!(sizeless.decode(), Err(DecodeError::Malformed(_))));
let mut builder = GraphicsBuilder::default();
builder.kitty(b"a=T,f=32,s=2,v=1");
builder.chunk(64, b"AAAABBBB", false, usize::MAX);
let dropped = builder.finish().expect("a payload");
assert_eq!(dropped.decode(), Err(DecodeError::NotCaptured));
}
#[cfg(feature = "decode")]
#[test]
fn a_sixel_decodes_into_the_pixels_it_paints() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(40, b"\"1;1;4;6#0;2;100;100;100!4~-", true, usize::MAX);
let bitmap = builder
.finish()
.expect("a payload")
.decode()
.expect("decodes");
assert_eq!((bitmap.width(), bitmap.height()), (4, 6));
for y in 0..6 {
for x in 0..4 {
assert_eq!(bitmap.pixel(x, y), Some([255, 255, 255, 255]), "({x},{y})");
}
}
}
#[cfg(feature = "decode")]
#[test]
fn a_sixel_pixel_nothing_painted_is_transparent_rather_than_black() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(30, b"\"1;1;1;6#0;2;0;100;0@-", true, usize::MAX);
let bitmap = builder
.finish()
.expect("a payload")
.decode()
.expect("decodes");
assert_eq!(bitmap.pixel(0, 0), Some([0, 255, 0, 255]));
assert_eq!(bitmap.pixel(0, 1), Some([0, 0, 0, 0]));
}
#[cfg(feature = "decode")]
#[test]
fn sixel_bands_stack_downwards_and_carriage_returns_overprint() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(
60,
b"\"1;1;2;12#0;2;100;0;0~~$#1;2;0;0;100?~-#0??-",
true,
usize::MAX,
);
let bitmap = builder
.finish()
.expect("a payload")
.decode()
.expect("decodes");
assert_eq!((bitmap.width(), bitmap.height()), (2, 12));
assert_eq!(bitmap.pixel(0, 0), Some([255, 0, 0, 255]), "first colour");
assert_eq!(bitmap.pixel(1, 0), Some([0, 0, 255, 255]), "overprinted");
assert_eq!(bitmap.pixel(0, 6), Some([0, 0, 0, 0]), "second band");
}
#[cfg(feature = "decode")]
#[test]
fn a_sixel_without_raster_attributes_takes_its_size_from_its_data() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(20, b"#0;2;100;100;100!3~-", true, usize::MAX);
let bitmap = builder
.finish()
.expect("a payload")
.decode()
.expect("decodes");
assert_eq!((bitmap.width(), bitmap.height()), (3, 6));
}
#[cfg(feature = "decode")]
#[test]
fn colours_are_counted_most_common_first() {
let mut raw = vec![0u8; 0];
for _ in 0..3 {
raw.extend_from_slice(&[1, 2, 3, 255]);
}
raw.extend_from_slice(&[9, 9, 9, 255]);
let data = base64(&raw);
let payload = kitty_payload(b"a=T,f=32,s=4,v=1", data.as_bytes());
let colours = payload.decode().expect("decodes").colours();
assert_eq!(colours[0], ([1, 2, 3, 255], 3));
assert_eq!(colours[1], ([9, 9, 9, 255], 1));
}
#[cfg(feature = "decode")]
#[test]
fn colours_break_ties_by_first_appearance() {
let a = [1, 1, 1, 255];
let b = [2, 2, 2, 255];
let c = [3, 3, 3, 255];
let image = |pixels: &[[u8; 4]]| {
let raw: Vec<u8> = pixels.iter().flatten().copied().collect();
let data = base64(&raw);
kitty_payload(
format!("a=T,f=32,s={},v=1", pixels.len()).as_bytes(),
data.as_bytes(),
)
.decode()
.expect("decodes")
.colours()
};
assert_eq!(image(&[a, b, b, a, c]), vec![(a, 2), (b, 2), (c, 1)]);
assert_eq!(image(&[b, a, a, b, c]), vec![(b, 2), (a, 2), (c, 1)]);
assert_eq!(image(&[a, b, b]), vec![(b, 2), (a, 1)]);
}
#[cfg(feature = "decode")]
#[test]
fn colours_on_a_photograph_sized_image_completes() {
let side = 512u32;
let pixels = (side * side) as usize;
let mut raw = Vec::with_capacity(pixels * 4);
for i in 0..pixels {
raw.extend_from_slice(&[(i >> 16) as u8, (i >> 8) as u8, i as u8, 0xff]);
}
let data = base64(&raw);
let bitmap = kitty_payload(
format!("a=T,f=32,s={side},v={side}").as_bytes(),
data.as_bytes(),
)
.decode()
.expect("decodes");
let colours = bitmap.colours();
assert_eq!(colours.len(), pixels, "every pixel is its own colour");
assert!(colours.iter().all(|&(_, count)| count == 1));
assert_eq!(colours[0].0, [0, 0, 0, 0xff]);
assert_eq!(colours[1].0, [0, 0, 1, 0xff]);
assert_eq!(colours[pixels - 1].0, [3, 0xff, 0xff, 0xff]);
}
#[cfg(feature = "decode")]
#[test]
fn hls_colours_are_refused_rather_than_converted() {
let mut builder = GraphicsBuilder::default();
builder.sixel();
builder.chunk(30, b"\"1;1;1;6#0;1;120;50;100~-", true, usize::MAX);
assert!(matches!(
builder.finish().expect("a payload").decode(),
Err(DecodeError::Unsupported(_))
));
}
#[test]
fn the_debug_rendering_stays_short_enough_for_a_log() {
let payload = kitty_payload(b"a=T,f=32,o=z,s=954,v=133,i=7,c=106,r=7", &[b'A'; 4096]);
let rendered = format!("{payload:?}");
assert!(rendered.len() < 120, "{rendered}");
assert!(rendered.contains("954x133px"), "{rendered}");
assert!(rendered.contains("106x7cells"), "{rendered}");
assert!(!rendered.contains("AAAA"), "the data must not be in it");
}
#[cfg(feature = "decode")]
fn base64(data: &[u8]) -> String {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for group in data.chunks(3) {
let mut bits = 0u32;
for (index, byte) in group.iter().enumerate() {
bits |= u32::from(*byte) << (16 - 8 * index);
}
for index in 0..=group.len() {
out.push(ALPHABET[(bits >> (18 - 6 * index) & 0x3f) as usize] as char);
}
for _ in group.len()..3 {
out.push('=');
}
}
out
}
}