use std::cell::RefCell;
use std::io::{self, Write};
use std::rc::Rc;
use std::sync::Arc;
use ratatui_core::layout::Rect;
use ratatui_core::style::{Modifier, Style};
use crate::geometry::Size;
use crate::surface::Surface;
use crate::view::{RenderCtx, View};
const ST: &str = "\x1b\\";
const CHUNK: usize = 4096;
#[derive(Clone, Debug)]
pub struct ImageData {
rgba: Arc<[u8]>,
pixel_width: u32,
pixel_height: u32,
}
impl ImageData {
pub fn from_rgba(
pixel_width: u32,
pixel_height: u32,
rgba: impl Into<Arc<[u8]>>,
) -> Option<Self> {
let rgba = rgba.into();
let expected = (pixel_width as usize)
.checked_mul(pixel_height as usize)?
.checked_mul(4)?;
if pixel_width == 0 || pixel_height == 0 || rgba.len() != expected {
return None;
}
Some(Self {
rgba,
pixel_width,
pixel_height,
})
}
pub fn pixel_width(&self) -> u32 {
self.pixel_width
}
pub fn pixel_height(&self) -> u32 {
self.pixel_height
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImageSupport {
None,
Kitty,
ITerm2,
Sixel,
}
impl ImageSupport {
pub fn detect() -> Self {
Self::detect_from(
std::env::var("TERM").ok().as_deref(),
std::env::var("TERM_PROGRAM").ok().as_deref(),
std::env::var("KITTY_WINDOW_ID").ok().as_deref(),
std::env::var("GHOSTTY_RESOURCES_DIR").ok().as_deref(),
)
}
pub fn detect_from(
term: Option<&str>,
term_program: Option<&str>,
kitty_window_id: Option<&str>,
ghostty_resources_dir: Option<&str>,
) -> Self {
let program = term_program.map(|p| p.to_ascii_lowercase());
let kitty = kitty_window_id.is_some_and(|s| !s.is_empty())
|| ghostty_resources_dir.is_some_and(|s| !s.is_empty())
|| term.is_some_and(|t| t.contains("kitty"))
|| program
.as_deref()
.is_some_and(|p| p == "ghostty" || p == "wezterm");
if kitty {
return ImageSupport::Kitty;
}
if program.as_deref().is_some_and(|p| p.contains("iterm")) {
return ImageSupport::ITerm2;
}
if term.is_some_and(|t| t.contains("foot") || t.contains("mlterm") || t.contains("contour"))
{
return ImageSupport::Sixel;
}
ImageSupport::None
}
}
#[derive(Clone, Debug)]
struct Placement {
rect: Rect,
data: ImageData,
support: ImageSupport,
}
#[derive(Clone, Debug, Default)]
pub struct ImageLayer(Rc<RefCell<Vec<Placement>>>);
impl ImageLayer {
pub fn new() -> Self {
Self::default()
}
fn record(&self, rect: Rect, data: ImageData, support: ImageSupport) {
if rect.width == 0 || rect.height == 0 {
return;
}
self.0.borrow_mut().push(Placement {
rect,
data,
support,
});
}
pub fn clear(&self) {
self.0.borrow_mut().clear();
}
pub fn is_empty(&self) -> bool {
self.0.borrow().is_empty()
}
pub fn len(&self) -> usize {
self.0.borrow().len()
}
pub fn emit(&self, out: &mut impl Write) -> io::Result<()> {
let placements = self.0.borrow();
if placements.is_empty() {
return Ok(());
}
out.write_all(b"\x1b7")?;
for p in placements.iter() {
let (row, col) = (p.rect.y + 1, p.rect.x + 1);
write!(out, "\x1b[{row};{col}H")?;
let escape = match p.support {
ImageSupport::ITerm2 => encode_iterm2(&p.data, p.rect.width, p.rect.height),
ImageSupport::Sixel => encode_sixel(&p.data, p.rect.width, p.rect.height),
_ => encode_kitty(&p.data, p.rect.width, p.rect.height),
};
out.write_all(escape.as_bytes())?;
}
out.write_all(b"\x1b8")?;
out.flush()
}
}
pub struct Image {
data: ImageData,
cols: u16,
rows: u16,
alt: String,
support: ImageSupport,
layer: Option<ImageLayer>,
}
impl Image {
pub fn new(data: ImageData, cols: u16, rows: u16) -> Self {
Self {
data,
cols,
rows,
alt: String::new(),
support: ImageSupport::None,
layer: None,
}
}
pub fn support(mut self, support: ImageSupport) -> Self {
self.support = support;
self
}
pub fn in_layer(mut self, layer: &ImageLayer) -> Self {
self.layer = Some(layer.clone());
self
}
pub fn alt(mut self, alt: impl Into<String>) -> Self {
self.alt = alt.into();
self
}
fn will_paint(&self) -> bool {
matches!(
self.support,
ImageSupport::Kitty | ImageSupport::ITerm2 | ImageSupport::Sixel
) && self.layer.is_some()
}
}
impl View for Image {
fn measure(&self, available: Size) -> Size {
Size::new(self.cols, self.rows).clamp_to(available)
}
fn render(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
if area.width == 0 || area.height == 0 {
return;
}
if self.will_paint() {
surface.fill(Style::default().bg(ctx.theme.background));
if let Some(layer) = &self.layer {
layer.record(area, self.data.clone(), self.support);
}
} else {
self.render_fallback(area, surface, ctx);
}
}
}
impl Image {
fn render_fallback(&self, area: Rect, surface: &mut Surface, ctx: &RenderCtx) {
surface.fill(Style::default().bg(ctx.theme.background));
let label = if self.alt.is_empty() {
"[image]".to_string()
} else {
format!("[image: {}]", self.alt)
};
let style = Style::default()
.fg(ctx.theme.muted)
.add_modifier(Modifier::ITALIC);
let width = crate::width::str_cols(&label);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height / 2;
surface.set_string(x, y, &label, style);
}
}
fn encode_kitty(data: &ImageData, cols: u16, rows: u16) -> String {
let payload = base64_encode(&data.rgba);
let chunks: Vec<&str> = split_chunks(&payload, CHUNK);
let mut out = String::new();
let last = chunks.len().saturating_sub(1);
for (i, chunk) in chunks.iter().enumerate() {
let more = u8::from(i != last);
out.push_str("\x1b_G");
if i == 0 {
out.push_str(&format!(
"f=32,s={},v={},a=T,c={},r={},q=2,m={}",
data.pixel_width, data.pixel_height, cols, rows, more
));
} else {
out.push_str(&format!("m={more}"));
}
out.push(';');
out.push_str(chunk);
out.push_str(ST);
}
out
}
fn split_chunks(s: &str, size: usize) -> Vec<&str> {
if s.is_empty() {
return vec![""];
}
let mut chunks = Vec::with_capacity(s.len().div_ceil(size));
let mut i = 0;
while i < s.len() {
let end = (i + size).min(s.len());
chunks.push(&s[i..end]);
i = end;
}
chunks
}
fn base64_encode(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for group in bytes.chunks(3) {
let b0 = group[0] as u32;
let b1 = *group.get(1).unwrap_or(&0) as u32;
let b2 = *group.get(2).unwrap_or(&0) as u32;
let n = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[(n >> 18) as usize & 0x3f] as char);
out.push(ALPHABET[(n >> 12) as usize & 0x3f] as char);
out.push(if group.len() > 1 {
ALPHABET[(n >> 6) as usize & 0x3f] as char
} else {
'='
});
out.push(if group.len() > 2 {
ALPHABET[n as usize & 0x3f] as char
} else {
'='
});
}
out
}
fn encode_iterm2(data: &ImageData, cols: u16, rows: u16) -> String {
let png = png_rgba(data.pixel_width, data.pixel_height, &data.rgba);
let payload = base64_encode(&png);
format!(
"\x1b]1337;File=inline=1;width={cols};height={rows};preserveAspectRatio=0;size={}:{payload}\x07",
png.len()
)
}
const SIXEL_CELL_W: u32 = 10;
const SIXEL_CELL_H: u32 = 20;
fn encode_sixel(data: &ImageData, cols: u16, rows: u16) -> String {
let tw = (cols as u32 * SIXEL_CELL_W).max(1);
let th = (rows as u32 * SIXEL_CELL_H).max(1);
let (sw, sh) = (data.pixel_width.max(1), data.pixel_height.max(1));
let mut idx = vec![0u8; (tw * th) as usize];
for ty in 0..th {
let sy = ty * sh / th;
for tx in 0..tw {
let sx = tx * sw / tw;
let p = ((sy * sw + sx) * 4) as usize;
let q = |c: u8| (c as u32 * 5 + 127) / 255; idx[(ty * tw + tx) as usize] =
(q(data.rgba[p]) * 36 + q(data.rgba[p + 1]) * 6 + q(data.rgba[p + 2])) as u8;
}
}
let mut out = String::new();
out.push_str("\x1bPq"); out.push_str(&format!("\"1;1;{tw};{th}")); for i in 0u32..216 {
let (r, g, b) = (i / 36 % 6, i / 6 % 6, i % 6);
out.push_str(&format!("#{};2;{};{};{}", i, r * 20, g * 20, b * 20));
}
let bands = th.div_ceil(6);
for band in 0..bands {
let y0 = band * 6;
let mut present = [false; 216];
for p in 0..6 {
let y = y0 + p;
if y >= th {
break;
}
for tx in 0..tw {
present[idx[(y * tw + tx) as usize] as usize] = true;
}
}
let colors: Vec<usize> = (0..216).filter(|&c| present[c]).collect();
for (ci, &c) in colors.iter().enumerate() {
out.push_str(&format!("#{c}"));
let (mut run, mut len) = (0u8, 0u32);
for tx in 0..tw {
let mut bits = 0u8;
for p in 0..6 {
let y = y0 + p;
if y < th && idx[(y * tw + tx) as usize] as usize == c {
bits |= 1 << p;
}
}
if bits == run {
len += 1;
} else {
sixel_run(&mut out, run, len);
run = bits;
len = 1;
}
}
sixel_run(&mut out, run, len);
if ci + 1 < colors.len() {
out.push('$');
}
}
if band + 1 < bands {
out.push('-');
}
}
out.push_str("\x1b\\");
out
}
fn sixel_run(out: &mut String, bits: u8, len: u32) {
if len == 0 {
return;
}
let ch = (0x3f + bits) as char;
if len >= 3 {
out.push_str(&format!("!{len}"));
out.push(ch);
} else {
for _ in 0..len {
out.push(ch);
}
}
}
fn png_rgba(w: u32, h: u32, rgba: &[u8]) -> Vec<u8> {
let row = (w * 4) as usize;
let mut raw = Vec::with_capacity(rgba.len() + h as usize);
for y in 0..h as usize {
raw.push(0); raw.extend_from_slice(&rgba[y * row..(y + 1) * row]);
}
let mut png = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&w.to_be_bytes());
ihdr.extend_from_slice(&h.to_be_bytes());
ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); png_chunk(&mut png, b"IHDR", &ihdr);
png_chunk(&mut png, b"IDAT", &zlib_stored(&raw));
png_chunk(&mut png, b"IEND", &[]);
png
}
fn png_chunk(out: &mut Vec<u8>, tag: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(tag);
out.extend_from_slice(data);
let mut crc_in = Vec::with_capacity(4 + data.len());
crc_in.extend_from_slice(tag);
crc_in.extend_from_slice(data);
out.extend_from_slice(&crc32(&crc_in).to_be_bytes());
}
fn zlib_stored(data: &[u8]) -> Vec<u8> {
let mut out = vec![0x78, 0x01]; let mut i = 0;
loop {
let end = (i + 0xffff).min(data.len());
let block = &data[i..end];
let last = end == data.len();
out.push(u8::from(last)); let len = block.len() as u16;
out.extend_from_slice(&len.to_le_bytes());
out.extend_from_slice(&(!len).to_le_bytes());
out.extend_from_slice(block);
i = end;
if last {
break;
}
}
out.extend_from_slice(&adler32(data).to_be_bytes());
out
}
fn crc32(data: &[u8]) -> u32 {
let mut crc = 0xffff_ffffu32;
for &b in data {
crc ^= b as u32;
for _ in 0..8 {
crc = if crc & 1 != 0 {
(crc >> 1) ^ 0xedb8_8320
} else {
crc >> 1
};
}
}
!crc
}
fn adler32(data: &[u8]) -> u32 {
let (mut a, mut b) = (1u32, 0u32);
for &x in data {
a = (a + x as u32) % 65521;
b = (b + a) % 65521;
}
(b << 16) | a
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::Theme;
use crate::testing::render;
use crate::view::element;
fn solid(w: u32, h: u32, rgba: [u8; 4]) -> ImageData {
let buf: Vec<u8> = std::iter::repeat_n(rgba, (w * h) as usize)
.flatten()
.collect();
ImageData::from_rgba(w, h, buf).expect("valid rgba")
}
#[test]
fn from_rgba_validates_length() {
assert!(ImageData::from_rgba(2, 2, vec![0u8; 16]).is_some());
assert!(ImageData::from_rgba(2, 2, vec![0u8; 15]).is_none());
assert!(ImageData::from_rgba(0, 2, vec![0u8; 0]).is_none());
}
#[test]
fn base64_matches_known_vectors() {
assert_eq!(base64_encode(b""), "");
assert_eq!(base64_encode(b"f"), "Zg==");
assert_eq!(base64_encode(b"fo"), "Zm8=");
assert_eq!(base64_encode(b"foo"), "Zm9v");
assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
}
#[test]
fn encode_kitty_single_chunk_shape() {
let img = solid(1, 1, [1, 2, 3, 4]);
let out = encode_kitty(&img, 4, 2);
assert!(out.starts_with("\x1b_Gf=32,s=1,v=1,a=T,c=4,r=2,q=2,m=0;"));
assert!(out.ends_with("\x1b\\"));
assert!(out.contains(";AQIDBA==\x1b\\"));
assert_eq!(out.matches("\x1b_G").count(), 1);
}
#[test]
fn encode_kitty_chunks_large_payloads() {
let img = solid(64, 64, [9, 9, 9, 9]);
let out = encode_kitty(&img, 10, 5);
let commands = out.matches("\x1b_G").count();
assert!(commands > 1, "large image should span multiple chunks");
assert!(out.starts_with("\x1b_Gf=32,s=64,v=64,a=T,c=10,r=5,q=2,m=1;"));
assert!(out.contains("\x1b_Gm=1;"));
assert!(out.contains("\x1b_Gm=0;"));
assert_eq!(out.matches("\x1b\\").count(), commands);
}
#[test]
fn detect_recognizes_kitty_signals() {
let kitty = ImageSupport::Kitty;
let none = ImageSupport::None;
assert_eq!(
ImageSupport::detect_from(Some("xterm-kitty"), None, None, None),
kitty
);
assert_eq!(
ImageSupport::detect_from(Some("xterm-256color"), None, Some("1"), None),
kitty
);
assert_eq!(
ImageSupport::detect_from(None, Some("ghostty"), None, None),
kitty
);
assert_eq!(
ImageSupport::detect_from(None, Some("WezTerm"), None, None),
kitty
);
assert_eq!(
ImageSupport::detect_from(None, None, None, Some("/x")),
kitty
);
assert_eq!(
ImageSupport::detect_from(Some("xterm-256color"), Some("Apple_Terminal"), None, None),
none
);
assert_eq!(
ImageSupport::detect_from(None, None, Some(""), Some("")),
none
);
}
#[test]
fn image_reserves_its_cell_footprint() {
let img = Image::new(solid(2, 2, [0, 0, 0, 255]), 6, 3);
assert_eq!(img.measure(Size::new(80, 24)), Size::new(6, 3));
assert_eq!(img.measure(Size::new(4, 2)), Size::new(4, 2));
}
#[test]
fn supported_image_records_a_placement_and_leaves_cells_blank() {
let theme = Theme::default();
let layer = ImageLayer::new();
let view = element(
Image::new(solid(2, 2, [0, 0, 0, 255]), 4, 2)
.support(ImageSupport::Kitty)
.in_layer(&layer)
.alt("cat"),
);
let buf = render(&view, 4, 2, &theme);
let text: String = (0..buf.area.width).map(|x| buf[(x, 0)].symbol()).collect();
assert!(
!text.contains("cat"),
"supported image must not paint alt text"
);
assert_eq!(layer.len(), 1, "a placement was recorded");
assert!(!layer.is_empty());
}
#[test]
fn unsupported_image_paints_alt_text_and_records_nothing() {
let theme = Theme::default();
let layer = ImageLayer::new();
let view = element(
Image::new(solid(2, 2, [0, 0, 0, 255]), 20, 3)
.in_layer(&layer)
.alt("cat"),
);
let buf = render(&view, 20, 3, &theme);
let mut whole = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
whole.push_str(buf[(x, y)].symbol());
}
}
assert!(
whole.contains("[image: cat]"),
"fallback should show alt text"
);
assert!(layer.is_empty(), "fallback records no placement");
}
#[test]
fn emit_positions_each_image_and_brackets_with_cursor_save_restore() {
let layer = ImageLayer::new();
let k = ImageSupport::Kitty;
layer.record(Rect::new(2, 1, 4, 2), solid(1, 1, [1, 2, 3, 4]), k);
layer.record(Rect::new(0, 5, 3, 3), solid(1, 1, [5, 6, 7, 8]), k);
let mut out: Vec<u8> = Vec::new();
layer.emit(&mut out).expect("emit");
let s = String::from_utf8(out).expect("utf8");
assert!(s.starts_with("\x1b7"));
assert!(s.ends_with("\x1b8"));
assert!(s.contains("\x1b[2;3H\x1b_G"));
assert!(s.contains("\x1b[6;1H\x1b_G"));
assert_eq!(s.matches("\x1b_G").count(), 2, "one command per image");
}
#[test]
fn emit_dispatches_the_recorded_protocol() {
let layer = ImageLayer::new();
layer.record(
Rect::new(0, 0, 4, 2),
solid(1, 1, [1, 2, 3, 4]),
ImageSupport::Kitty,
);
layer.record(
Rect::new(0, 3, 4, 2),
solid(1, 1, [5, 6, 7, 8]),
ImageSupport::ITerm2,
);
let mut out: Vec<u8> = Vec::new();
layer.emit(&mut out).expect("emit");
let s = String::from_utf8(out).expect("utf8");
assert_eq!(s.matches("\x1b_G").count(), 1, "one Kitty command");
assert_eq!(
s.matches("\x1b]1337;File=").count(),
1,
"one iTerm2 command"
);
}
#[test]
fn emit_is_a_noop_when_empty() {
let layer = ImageLayer::new();
let mut out: Vec<u8> = Vec::new();
layer.emit(&mut out).expect("emit");
assert!(
out.is_empty(),
"no images → no bytes, no stray cursor moves"
);
}
#[test]
fn clear_resets_between_frames() {
let layer = ImageLayer::new();
layer.record(
Rect::new(0, 0, 2, 2),
solid(1, 1, [0, 0, 0, 0]),
ImageSupport::Kitty,
);
assert_eq!(layer.len(), 1);
layer.clear();
assert!(layer.is_empty());
}
#[test]
fn zero_area_placements_are_dropped() {
let layer = ImageLayer::new();
let k = ImageSupport::Kitty;
layer.record(Rect::new(0, 0, 0, 3), solid(1, 1, [0, 0, 0, 0]), k);
layer.record(Rect::new(0, 0, 3, 0), solid(1, 1, [0, 0, 0, 0]), k);
assert!(
layer.is_empty(),
"clipped-away images have nothing to paint"
);
}
#[test]
fn encode_iterm2_wraps_a_png_file() {
let img = solid(2, 2, [10, 20, 30, 255]);
let out = encode_iterm2(&img, 8, 4);
assert!(
out.starts_with("\x1b]1337;File=inline=1;width=8;height=4;preserveAspectRatio=0;size=")
);
assert!(out.ends_with('\x07'));
let payload = out
.trim_end_matches('\x07')
.rsplit(':')
.next()
.expect("payload");
let decoded = b64_decode(payload);
assert_eq!(
&decoded[..8],
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
);
let size: usize = out
.split("size=")
.nth(1)
.and_then(|s| s.split(':').next())
.and_then(|n| n.parse().ok())
.expect("size field");
assert_eq!(size, decoded.len());
}
#[test]
fn png_rgba_is_well_formed() {
let img = solid(3, 2, [1, 2, 3, 4]);
let png = png_rgba(img.pixel_width, img.pixel_height, &img.rgba);
assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]);
let s = String::from_utf8_lossy(&png);
assert!(s.contains("IHDR") && s.contains("IDAT") && s.contains("IEND"));
}
#[test]
fn encode_sixel_has_dcs_frame_palette_and_data() {
let img = solid(2, 2, [255, 0, 0, 255]); let out = encode_sixel(&img, 1, 1);
assert!(
out.starts_with("\x1bPq\"1;1;10;20"),
"intro/raster: {:?}",
&out[..24]
);
assert!(out.ends_with("\x1b\\"), "ST terminated");
assert!(out.contains("#180;2;100;0;0"), "red register defined");
assert!(out.contains("#180"), "red register selected in data");
assert!(
out.contains("!"),
"run-length encoding used for the solid fill"
);
assert!(
out.contains('~'),
"a fully-set sixel column (0x3f+63) present"
);
}
#[test]
fn encode_sixel_bytes_stay_in_range() {
let img = solid(3, 2, [0, 128, 255, 255]);
let out = encode_sixel(&img, 2, 1);
for b in out.bytes() {
let ok = b == 0x1b
|| b == b'P'
|| b == b'q'
|| b == b'\\'
|| b == b'"'
|| b == b'#'
|| b == b'!'
|| b == b'$'
|| b == b'-'
|| b == b';'
|| b.is_ascii_digit()
|| (0x3f..=0x7e).contains(&b);
assert!(ok, "unexpected sixel byte {b:#x}");
}
}
#[test]
fn detect_recognizes_sixel_terminals() {
assert_eq!(
ImageSupport::detect_from(Some("foot"), None, None, None),
ImageSupport::Sixel
);
assert_eq!(
ImageSupport::detect_from(Some("xterm-mlterm"), None, None, None),
ImageSupport::Sixel
);
}
#[test]
fn emit_dispatches_sixel() {
let layer = ImageLayer::new();
layer.record(
Rect::new(0, 0, 2, 1),
solid(1, 1, [1, 2, 3, 255]),
ImageSupport::Sixel,
);
let mut out: Vec<u8> = Vec::new();
layer.emit(&mut out).expect("emit");
let s = String::from_utf8(out).expect("utf8");
assert!(s.contains("\x1bPq"), "sixel DCS emitted: {s:?}");
}
#[test]
fn detect_recognizes_iterm2_and_prefers_kitty() {
assert_eq!(
ImageSupport::detect_from(Some("xterm-256color"), Some("iTerm.app"), None, None),
ImageSupport::ITerm2
);
assert_eq!(
ImageSupport::detect_from(None, Some("WezTerm"), None, None),
ImageSupport::Kitty
);
}
fn b64_decode(s: &str) -> Vec<u8> {
fn val(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some((c - b'A') as u32),
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut acc = 0u32;
let mut bits = 0;
let mut out = Vec::new();
for &c in s.as_bytes() {
let Some(v) = val(c) else { continue }; acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
out
}
}