use crate::gfx::base64;
use crate::gfx::bitmap::Bitmap;
use crate::gfx::png_encode;
pub const CHUNK: usize = 4096;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum Format {
Rgba32,
Rgb24,
Png,
}
#[derive(Clone, Debug)]
pub struct Options {
pub id: u32,
pub format: Format,
pub fit_cols: Option<u32>,
pub fit_rows: Option<u32>,
pub z: i32,
pub compress: bool,
}
impl Default for Options {
fn default() -> Self {
Options {
id: 1,
format: Format::Rgba32,
fit_cols: None,
fit_rows: None,
z: 0,
compress: true,
}
}
}
pub fn transmit_display(img: &Bitmap, opts: &Options) -> Vec<u8> {
let (fmt_key, payload): (u32, Vec<u8>) = match opts.format {
Format::Rgba32 => {
let mut raw = Vec::with_capacity(img.pixels().len() * 4);
for p in img.pixels() {
raw.extend_from_slice(&[p.r, p.g, p.b, p.a]);
}
(32, raw)
}
Format::Rgb24 => {
let mut raw = Vec::with_capacity(img.pixels().len() * 3);
for p in img.pixels() {
raw.extend_from_slice(&[p.r, p.g, p.b]);
}
(24, raw)
}
Format::Png => (100, png_encode::encode(img)),
};
let compressed = opts.compress && opts.format != Format::Png;
let payload = if compressed {
miniz_oxide::deflate::compress_to_vec_zlib(&payload, 6)
} else {
payload
};
let mut keys = format!("a=T,f={fmt_key},q=2,i={},p=1", opts.id);
match opts.format {
Format::Rgba32 | Format::Rgb24 => {
keys.push_str(&format!(",s={},v={}", img.width(), img.height()));
}
Format::Png => {} }
if compressed {
keys.push_str(",o=z");
}
if let Some(c) = opts.fit_cols {
keys.push_str(&format!(",c={c}"));
}
if let Some(r) = opts.fit_rows {
keys.push_str(&format!(",r={r}"));
}
if opts.z != 0 {
keys.push_str(&format!(",z={}", opts.z));
}
let encoded = base64::encode(&payload);
chunked_apc(&keys, encoded.as_bytes())
}
pub fn place(id: u32, fit_cols: Option<u32>, fit_rows: Option<u32>, z: i32) -> Vec<u8> {
let mut keys = format!("a=p,q=2,i={id},p=1");
if let Some(c) = fit_cols {
keys.push_str(&format!(",c={c}"));
}
if let Some(r) = fit_rows {
keys.push_str(&format!(",r={r}"));
}
if z != 0 {
keys.push_str(&format!(",z={z}"));
}
apc(&keys, b"")
}
pub fn delete_by_id(id: u32, free_data: bool) -> Vec<u8> {
let d = if free_data { 'I' } else { 'i' };
apc(&format!("a=d,q=2,d={d},i={id}"), b"")
}
pub fn delete_all(free_data: bool) -> Vec<u8> {
let d = if free_data { 'A' } else { 'a' };
apc(&format!("a=d,q=2,d={d}"), b"")
}
fn apc(keys: &str, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(keys.len() + payload.len() + 8);
out.extend_from_slice(b"\x1b_G");
out.extend_from_slice(keys.as_bytes());
if !payload.is_empty() {
out.push(b';');
out.extend_from_slice(payload);
}
out.extend_from_slice(b"\x1b\\");
out
}
fn chunked_apc(first_keys: &str, encoded: &[u8]) -> Vec<u8> {
if encoded.len() <= CHUNK {
return apc(first_keys, encoded);
}
let mut out =
Vec::with_capacity(encoded.len() + encoded.len() / CHUNK * 16 + first_keys.len() + 16);
let mut chunks = encoded.chunks(CHUNK).peekable();
let mut first = true;
while let Some(chunk) = chunks.next() {
let last = chunks.peek().is_none();
let m = if last { 0 } else { 1 };
let keys = if first {
first = false;
format!("{first_keys},m={m}")
} else {
format!("m={m},q=2")
};
out.extend_from_slice(&apc(&keys, chunk));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::base::Rgba;
fn parse_escapes(bytes: &[u8]) -> Vec<(String, Vec<u8>)> {
let mut out = Vec::new();
let mut rest = bytes;
while !rest.is_empty() {
assert!(rest.starts_with(b"\x1b_G"), "APC intro");
rest = &rest[3..];
let end = rest.windows(2).position(|w| w == b"\x1b\\").expect("ST");
let body = &rest[..end];
rest = &rest[end + 2..];
let (keys, payload) = match body.iter().position(|&b| b == b';') {
Some(i) => (&body[..i], &body[i + 1..]),
None => (body, &[][..]),
};
out.push((String::from_utf8(keys.to_vec()).unwrap(), payload.to_vec()));
}
out
}
#[test]
fn small_rgba_single_escape() {
let img = Bitmap::new(2, 2, Rgba::rgb(1, 2, 3));
let opts = Options {
compress: false,
..Options::default()
};
let frames = parse_escapes(&transmit_display(&img, &opts));
assert_eq!(frames.len(), 1);
let (keys, payload) = &frames[0];
assert!(keys.contains("a=T"));
assert!(keys.contains("f=32"));
assert!(keys.contains("s=2") && keys.contains("v=2"));
assert!(keys.contains("q=2"));
assert!(
keys.contains(",p=1"),
"one-placement-per-image policy: {keys}"
);
assert!(!keys.contains(",m="), "single chunk must not carry m");
let decoded = crate::gfx::base64::decode(std::str::from_utf8(payload).unwrap()).unwrap();
assert_eq!(decoded.len(), 16);
assert_eq!(&decoded[..4], &[1, 2, 3, 255]);
}
#[test]
fn compression_round_trips() {
let img = Bitmap::new(8, 8, Rgba::rgb(200, 100, 50));
let opts = Options {
compress: true,
..Options::default()
};
let frames = parse_escapes(&transmit_display(&img, &opts));
let (keys, payload) = &frames[0];
assert!(keys.contains("o=z"));
let decoded = crate::gfx::base64::decode(std::str::from_utf8(payload).unwrap()).unwrap();
let raw = miniz_oxide::inflate::decompress_to_vec_zlib(&decoded).unwrap();
assert_eq!(raw.len(), 8 * 8 * 4);
assert_eq!(&raw[..4], &[200, 100, 50, 255]);
}
#[test]
fn large_payload_chunking_rules() {
let img = Bitmap::from_fn(64, 64, |x, y| {
Rgba::new(x as u8, y as u8, (x ^ y) as u8, 255)
});
let opts = Options {
compress: false,
..Options::default()
};
let frames = parse_escapes(&transmit_display(&img, &opts));
assert!(frames.len() > 1);
let mut reassembled = String::new();
for (i, (keys, payload)) in frames.iter().enumerate() {
let last = i == frames.len() - 1;
if i == 0 {
assert!(keys.contains("a=T") && keys.contains("m=1"));
} else if last {
assert!(keys.starts_with("m=0"), "{keys}");
assert!(!keys.contains("a=T"));
} else {
assert!(keys.starts_with("m=1"), "{keys}");
}
if !last {
assert_eq!(payload.len() % 4, 0, "chunk {i} not 4-aligned");
assert!(payload.len() <= CHUNK);
}
reassembled.push_str(std::str::from_utf8(payload).unwrap());
}
let decoded = crate::gfx::base64::decode(&reassembled).unwrap();
assert_eq!(decoded.len(), 64 * 64 * 4);
}
#[test]
fn png_format_carries_no_geometry_keys() {
let img = Bitmap::new(3, 2, Rgba::rgb(9, 9, 9));
let opts = Options {
format: Format::Png,
..Options::default()
};
let frames = parse_escapes(&transmit_display(&img, &opts));
let (keys, payload) = &frames[0];
assert!(keys.contains("f=100"));
assert!(!keys.contains("s=") && !keys.contains("v="));
assert!(!keys.contains("o=z"), "no double compression for PNG");
let decoded = crate::gfx::base64::decode(std::str::from_utf8(payload).unwrap()).unwrap();
assert_eq!(crate::gfx::png::decode(&decoded).unwrap(), img);
}
#[test]
fn cell_fit_and_z_keys() {
let img = Bitmap::new(2, 2, Rgba::WHITE);
let opts = Options {
fit_cols: Some(10),
fit_rows: Some(5),
z: -7,
compress: false,
..Options::default()
};
let frames = parse_escapes(&transmit_display(&img, &opts));
let keys = &frames[0].0;
assert!(keys.contains("c=10") && keys.contains("r=5") && keys.contains("z=-7"));
}
#[test]
fn place_and_delete_forms() {
let frames = parse_escapes(&place(42, Some(8), Some(4), 3));
assert_eq!(frames.len(), 1);
assert!(frames[0].0.contains("a=p") && frames[0].0.contains("i=42"));
assert!(
frames[0].0.contains("p=1"),
"re-place must REPLACE via the fixed placement id: {}",
frames[0].0
);
let frames = parse_escapes(&delete_by_id(42, false));
assert!(frames[0].0.contains("d=i") && frames[0].0.contains("i=42"));
let frames = parse_escapes(&delete_by_id(42, true));
assert!(frames[0].0.contains("d=I"));
let frames = parse_escapes(&delete_all(false));
assert!(frames[0].0.contains("a=d") && frames[0].0.contains("d=a"));
}
}