use std::{
borrow::Cow,
collections::{HashMap, HashSet},
f32::consts::PI,
sync::LazyLock,
};
use fontdue::{Font as TtfFace, FontSettings, Metrics};
#[derive(Debug, thiserror::Error)]
pub enum SnapcompactError {
#[error("{0}")]
Render(String),
#[error("png encode: {0}")]
PngEncode(String),
}
impl From<std::io::Error> for SnapcompactError {
fn from(e: std::io::Error) -> Self {
SnapcompactError::PngEncode(e.to_string())
}
}
impl From<std::fmt::Error> for SnapcompactError {
fn from(e: std::fmt::Error) -> Self {
SnapcompactError::PngEncode(e.to_string())
}
}
const MAX_FRAME_SIZE: u32 = 16384;
const PALETTE: [[u8; 3]; 10] = [
[255, 255, 255],
[109, 2, 2], [109, 53, 2], [24, 109, 2], [2, 109, 109], [2, 32, 109], [75, 2, 109], [0, 0, 0], [255, 247, 194], [128, 128, 128], ];
const INK_COLORS: usize = 6;
const INK_BLACK: u8 = 7;
const BG_REPEAT: u8 = 8;
const INK_DIM: u8 = 9;
const DIM_ON: u32 = 0x0e;
const DIM_OFF: u32 = 0x0f;
const FULL_BLOCK: u32 = 0x2588;
static FONT_5X8: LazyLock<Font> = LazyLock::new(|| parse_bdf(include_str!("fonts/5x8.bdf"), 5, 8));
static FONT_8X8: LazyLock<Font> = LazyLock::new(|| parse_hex(include_str!("fonts/unscii-8.hex")));
static FONT_6X12: LazyLock<Font> =
LazyLock::new(|| parse_bdf(include_str!("fonts/6x12.bdf"), 6, 12));
static FONT_8X13: LazyLock<Font> =
LazyLock::new(|| parse_bdf(include_str!("fonts/8x13.bdf"), 8, 13));
static FONT_SILVER: LazyLock<TtfFont> =
LazyLock::new(|| parse_ttf(include_bytes!("fonts/Silver.ttf"), 16.0, 16, 16));
struct Glyph {
w: u8,
h: i32,
xoff: i32,
yoff: i32,
rows: Vec<u8>,
}
struct Font {
glyphs: HashMap<u32, Glyph>,
ascent: i32,
cell_w: usize,
cell_h: usize,
}
struct TtfFont {
face: TtfFace,
supported: HashSet<char>,
px: f32,
ascent: f32,
cell_w: usize,
cell_h: usize,
}
struct RasterizedGlyph {
metrics: Metrics,
bitmap: Vec<u8>,
}
fn parse_bdf(text: &str, cell_w: usize, cell_h: usize) -> Font {
let mut glyphs = HashMap::new();
let mut ascent = 0i32;
let mut enc = -1i64;
let mut bbx = [0i32; 4];
let mut lines = text.lines();
while let Some(line) = lines.next() {
if let Some(rest) = line.strip_prefix("FONT_ASCENT") {
ascent = rest.trim().parse().unwrap_or(0);
} else if let Some(rest) = line.strip_prefix("ENCODING") {
enc = rest.trim().parse().unwrap_or(-1);
} else if let Some(rest) = line.strip_prefix("BBX") {
let mut parts = rest.split_ascii_whitespace();
for slot in &mut bbx {
*slot = parts.next().and_then(|part| part.parse().ok()).unwrap_or(0);
}
} else if line.starts_with("BITMAP") {
let mut rows = Vec::new();
for row in lines.by_ref() {
if row.starts_with("ENDCHAR") {
break;
}
rows.push(u8::from_str_radix(row.trim(), 16).unwrap_or(0));
}
if enc >= 0 {
glyphs.insert(
enc as u32,
Glyph {
w: bbx[0].clamp(0, 8) as u8,
h: bbx[1],
xoff: bbx[2],
yoff: bbx[3],
rows,
},
);
}
}
}
Font {
glyphs,
ascent,
cell_w,
cell_h,
}
}
fn parse_hex(text: &str) -> Font {
let mut glyphs = HashMap::new();
for line in text.lines() {
let Some((cp, bits)) = line.split_once(':') else {
continue;
};
let Ok(enc) = u32::from_str_radix(cp.trim(), 16) else {
continue;
};
let bits = bits.trim();
if bits.len() != 16 {
continue;
}
let rows: Vec<u8> = (0..8)
.map(|i| u8::from_str_radix(&bits[i * 2..i * 2 + 2], 16).unwrap_or(0))
.collect();
glyphs.insert(
enc,
Glyph {
w: 8,
h: 8,
xoff: 0,
yoff: -1,
rows,
},
);
}
Font {
glyphs,
ascent: 7,
cell_w: 8,
cell_h: 8,
}
}
fn parse_ttf(data: &'static [u8], px: f32, cell_w: usize, cell_h: usize) -> TtfFont {
let face =
TtfFace::from_bytes(data, FontSettings::default()).expect("bundled Silver.ttf must parse");
let supported = face.chars().keys().copied().collect();
let ascent = face
.horizontal_line_metrics(px)
.map_or(px * 0.8, |metrics| metrics.ascent);
TtfFont {
face,
supported,
px,
ascent,
cell_w,
cell_h,
}
}
enum RenderFont<'a> {
Bitmap(&'a Font),
Ttf(&'a TtfFont),
}
impl RenderFont<'_> {
const fn cell_w(&self) -> usize {
match self {
Self::Bitmap(font) => font.cell_w,
Self::Ttf(font) => font.cell_w,
}
}
const fn cell_h(&self) -> usize {
match self {
Self::Bitmap(font) => font.cell_h,
Self::Ttf(font) => font.cell_h,
}
}
fn supports(&self, code: u32) -> bool {
if matches!(code, DIM_ON | DIM_OFF | FULL_BLOCK | 0x0a) {
return true;
}
match self {
Self::Bitmap(font) => font.glyphs.contains_key(&code),
Self::Ttf(font) => char::from_u32(code).is_some_and(|ch| font.supported.contains(&ch)),
}
}
}
fn resolve_font(name: &str) -> Option<RenderFont<'static>> {
match name {
"5x8" => Some(RenderFont::Bitmap(&FONT_5X8)),
"8x8" => Some(RenderFont::Bitmap(&FONT_8X8)),
"6x12" => Some(RenderFont::Bitmap(&FONT_6X12)),
"8x13" => Some(RenderFont::Bitmap(&FONT_8X13)),
"silver" => Some(RenderFont::Ttf(&FONT_SILVER)),
_ => None,
}
}
struct Grid {
cols: usize,
rows: usize,
repeat: usize,
cell_w: usize,
cell_h: usize,
}
const fn is_wide(cp: u32) -> bool {
matches!(cp,
0x1100..=0x115F
| 0x2E80..=0x2EFF
| 0x2F00..=0x2FDF
| 0x3000..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE30..=0xFE4F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
| 0x20000..=0x2FFFD
| 0x30000..=0x3FFFD
)
}
const fn cell_units(code: u32, wide_cells: bool) -> usize {
match code {
DIM_ON | DIM_OFF => 0,
_ if wide_cells && is_wide(code) => 2,
_ => 1,
}
}
const fn place_cell(
cursor: usize,
cols: usize,
code: u32,
wide_cells: bool,
) -> Option<(usize, usize, usize)> {
let units = cell_units(code, wide_cells);
if units == 0 {
return None;
}
let mut cell = cursor;
if units == 2 && cols >= 2 && cell % cols == cols - 1 {
cell += 1; }
Some((cell, units, cell + units))
}
fn used_rows(text: &str, grid: &Grid, doc: bool, wide_cells: bool) -> usize {
let rows = if doc {
text.split('\n').count()
} else {
let mut cursor = 0usize;
for ch in text.chars() {
if let Some((_, _, next)) = place_cell(cursor, grid.cols, ch as u32, wide_cells) {
cursor = next;
}
}
cursor.div_ceil(grid.cols)
};
rows.clamp(1, grid.rows)
}
fn fill_repeat_bands(pixels: &mut [u8], width: usize, height: usize, grid: &Grid) {
if grid.repeat <= 1 {
return;
}
for row in 0..grid.rows {
for copy in 1..grid.repeat {
let band_top = (row * grid.repeat + copy) * grid.cell_h;
for y in band_top..(band_top + grid.cell_h).min(height) {
pixels[y * width..y * width + width].fill(BG_REPEAT);
}
}
}
}
fn blit_glyph(
pixels: &mut [u8],
width: usize,
height: usize,
glyph: &Glyph,
left: i32,
top: i32,
ink: u8,
) {
for (r, &bits) in glyph.rows.iter().enumerate() {
if bits == 0 {
continue;
}
let y = top + r as i32;
if y < 0 || y >= height as i32 {
continue;
}
let row_base = y as usize * width;
for b in 0..glyph.w {
if bits & (0x80u8 >> b) != 0 {
let x = left + i32::from(b);
if x >= 0 && (x as usize) < width {
pixels[row_base + x as usize] = ink;
}
}
}
}
}
fn fill_cell(
pixels: &mut [u8],
width: usize,
height: usize,
grid: &Grid,
x_origin: usize,
row: usize,
ink: u8,
) {
let x0 = x_origin.min(width);
let x1 = (x_origin + grid.cell_w).min(width);
if x0 >= x1 {
return;
}
for copy in 0..grid.repeat {
let top = (row * grid.repeat + copy) * grid.cell_h;
for y in top..(top + grid.cell_h).min(height) {
pixels[y * width + x0..y * width + x1].fill(ink);
}
}
}
fn fill_repeat_bands_rgb(pixels: &mut [u8], width: usize, height: usize, grid: &Grid) {
if grid.repeat <= 1 {
return;
}
let band = PALETTE[BG_REPEAT as usize];
for row in 0..grid.rows {
for copy in 1..grid.repeat {
let band_top = (row * grid.repeat + copy) * grid.cell_h;
for y in band_top..(band_top + grid.cell_h).min(height) {
for px in pixels[y * width * 3..(y + 1) * width * 3].chunks_exact_mut(3) {
px.copy_from_slice(&band);
}
}
}
}
}
fn fill_cell_rgb(
pixels: &mut [u8],
width: usize,
height: usize,
grid: &Grid,
x_origin: usize,
row: usize,
ink: u8,
) {
let x0 = x_origin.min(width);
let x1 = (x_origin + grid.cell_w).min(width);
if x0 >= x1 {
return;
}
let color = PALETTE[ink as usize];
for copy in 0..grid.repeat {
let top = (row * grid.repeat + copy) * grid.cell_h;
for y in top..(top + grid.cell_h).min(height) {
let row = &mut pixels[y * width * 3..(y + 1) * width * 3];
for x in x0..x1 {
row[x * 3..x * 3 + 3].copy_from_slice(&color);
}
}
}
}
fn ttf_pixel_size(font: &TtfFont, grid: &Grid) -> f32 {
let sx = grid.cell_w as f32 / font.cell_w as f32;
let sy = grid.cell_h as f32 / font.cell_h as f32;
font.px * sx.min(sy)
}
fn ttf_wide_pixel_size(font: &TtfFont, grid: &Grid) -> f32 {
let sx = (2 * grid.cell_w) as f32 / font.cell_w as f32;
let sy = grid.cell_h as f32 / font.cell_h as f32;
font.px * sx.min(sy)
}
fn ttf_ascent(font: &TtfFont, px: f32) -> f32 {
font.face
.horizontal_line_metrics(px)
.map_or(font.ascent * px / font.px, |metrics| metrics.ascent)
}
fn cached_ttf_glyph<'a>(
cache: &'a mut HashMap<char, RasterizedGlyph>,
font: &TtfFont,
ch: char,
px: f32,
) -> Option<&'a RasterizedGlyph> {
if !font.supported.contains(&ch) {
return None;
}
Some(cache.entry(ch).or_insert_with(|| {
let (metrics, bitmap) = font.face.rasterize(ch, px);
RasterizedGlyph { metrics, bitmap }
}))
}
fn blit_ttf_glyph(
pixels: &mut [u8],
width: usize,
height: usize,
glyph: &RasterizedGlyph,
left: i32,
top: i32,
ink: u8,
) {
if glyph.metrics.width == 0 || glyph.metrics.height == 0 {
return;
}
let color = PALETTE[ink as usize];
for y in 0..glyph.metrics.height {
let dst_y = top + y as i32;
if dst_y < 0 || dst_y >= height as i32 {
continue;
}
for x in 0..glyph.metrics.width {
let alpha = u16::from(glyph.bitmap[y * glyph.metrics.width + x]);
if alpha == 0 {
continue;
}
let dst_x = left + x as i32;
if dst_x < 0 || dst_x >= width as i32 {
continue;
}
let offset = (dst_y as usize * width + dst_x as usize) * 3;
let inv = 255 - alpha;
for c in 0..3 {
let bg = u16::from(pixels[offset + c]);
let fg = u16::from(color[c]);
pixels[offset + c] = ((bg * inv + fg * alpha + 127) / 255) as u8;
}
}
}
}
fn blit_ttf_glyph_indexed(
pixels: &mut [u8],
width: usize,
height: usize,
glyph: &RasterizedGlyph,
left: i32,
top: i32,
ink: u8,
) {
if glyph.metrics.width == 0 || glyph.metrics.height == 0 {
return;
}
for y in 0..glyph.metrics.height {
let dst_y = top + y as i32;
if dst_y < 0 || dst_y >= height as i32 {
continue;
}
let row_base = dst_y as usize * width;
for x in 0..glyph.metrics.width {
let coverage = glyph.bitmap[y * glyph.metrics.width + x];
let cell = if coverage >= 170 {
ink
} else if ink == INK_BLACK && coverage >= 56 {
INK_DIM } else if coverage >= 110 {
ink
} else {
continue;
};
let dst_x = left + x as i32;
if dst_x >= 0 && dst_x < width as i32 {
pixels[row_base + dst_x as usize] = cell;
}
}
}
}
fn ttf_glyph_origin(x_origin: usize, cell_w: usize, metrics: &Metrics) -> i32 {
let advance = metrics.advance_width.ceil() as i32;
let pad = (cell_w as i32 - advance).max(0) / 2;
x_origin as i32 + pad + metrics.xmin
}
fn ttf_glyph_top(cell_top: usize, ascent: f32, metrics: &Metrics) -> i32 {
(cell_top as f32 + ascent - metrics.height as f32 - metrics.ymin as f32).round() as i32
}
fn render_bitmap(
text: &str,
width: usize,
height: usize,
font: &Font,
grid: &Grid,
black_ink: bool,
) -> Vec<u8> {
let mut pixels = vec![0u8; width * height]; let capacity = grid.cols * grid.rows;
if capacity == 0 {
return pixels;
}
fill_repeat_bands(&mut pixels, width, height, grid);
let codes: Vec<u32> = text.chars().map(|ch| ch as u32).collect();
let narrow_px = ttf_pixel_size(&FONT_SILVER, grid);
let wide_px = ttf_wide_pixel_size(&FONT_SILVER, grid);
let mut fallback_cache = HashMap::new();
let mut sentence = 0usize;
let mut dim = false;
let mut cursor = 0usize;
for i in 0..codes.len() {
if cursor >= capacity {
break;
}
let code = codes[i];
match code {
DIM_ON => {
dim = true;
continue;
}
DIM_OFF => {
dim = false;
continue;
}
_ => {}
}
let ink = if dim {
INK_DIM
} else if black_ink {
INK_BLACK
} else {
(1 + sentence % INK_COLORS) as u8
};
if matches!(code, 0x2e | 0x21 | 0x3f)
&& matches!(codes.get(i + 1), Some(&(0x20 | FULL_BLOCK)))
{
sentence += 1;
}
let Some((at, units, next)) = place_cell(cursor, grid.cols, code, true) else {
continue;
};
cursor = next;
if at >= capacity {
break;
}
let row = at / grid.cols;
let col = at - row * grid.cols;
if code == FULL_BLOCK {
fill_cell(
&mut pixels,
width,
height,
grid,
col * grid.cell_w,
row,
INK_BLACK,
);
continue;
}
if let Some(glyph) = font.glyphs.get(&code) {
if glyph.rows.is_empty() {
continue;
}
let left = (col * grid.cell_w) as i32 + glyph.xoff;
for copy in 0..grid.repeat {
let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32;
let top = cell_top + font.ascent - glyph.h - glyph.yoff;
blit_glyph(&mut pixels, width, height, glyph, left, top, ink);
}
} else if let Some(ch) = char::from_u32(code) {
let px = if units == 2 { wide_px } else { narrow_px };
let Some(glyph) = cached_ttf_glyph(&mut fallback_cache, &FONT_SILVER, ch, px) else {
continue;
};
let span = units * grid.cell_w;
let left = ttf_glyph_origin(col * grid.cell_w, span, &glyph.metrics);
for copy in 0..grid.repeat {
let cell_top = (row * grid.repeat + copy) * grid.cell_h;
let top = ttf_glyph_top(cell_top, font.ascent as f32, &glyph.metrics);
blit_ttf_glyph_indexed(&mut pixels, width, height, glyph, left, top, ink);
}
}
}
pixels
}
fn render_ttf_rgb(
text: &str,
width: usize,
height: usize,
font: &TtfFont,
grid: &Grid,
black_ink: bool,
) -> Vec<u8> {
let mut pixels = vec![255u8; width * height * 3];
let capacity = grid.cols * grid.rows;
if capacity == 0 {
return pixels;
}
fill_repeat_bands_rgb(&mut pixels, width, height, grid);
let px = ttf_pixel_size(font, grid);
let ascent = ttf_ascent(font, px);
let codes: Vec<char> = text.chars().collect();
let mut cache = HashMap::new();
let mut sentence = 0usize;
let mut dim = false;
let mut cell = 0usize;
for i in 0..codes.len() {
if cell >= capacity {
break;
}
let ch = codes[i];
let code = ch as u32;
match code {
DIM_ON => {
dim = true;
continue;
}
DIM_OFF => {
dim = false;
continue;
}
_ => {}
}
let ink = if dim {
INK_DIM
} else if black_ink {
INK_BLACK
} else {
(1 + sentence % INK_COLORS) as u8
};
if matches!(code, 0x2e | 0x21 | 0x3f)
&& matches!(
codes.get(i + 1).map(|next| *next as u32),
Some(0x20 | FULL_BLOCK)
)
{
sentence += 1;
}
let row = cell / grid.cols;
let col = cell - row * grid.cols;
cell += 1;
if code == FULL_BLOCK {
fill_cell_rgb(
&mut pixels,
width,
height,
grid,
col * grid.cell_w,
row,
INK_BLACK,
);
continue;
}
let Some(glyph) = cached_ttf_glyph(&mut cache, font, ch, px) else {
continue;
};
let left = ttf_glyph_origin(col * grid.cell_w, grid.cell_w, &glyph.metrics);
for copy in 0..grid.repeat {
let cell_top = (row * grid.repeat + copy) * grid.cell_h;
let top = ttf_glyph_top(cell_top, ascent, &glyph.metrics);
blit_ttf_glyph(&mut pixels, width, height, glyph, left, top, ink);
}
}
pixels
}
const GUTTER: usize = 3;
fn render_doc_bitmap(
text: &str,
width: usize,
height: usize,
font: &Font,
grid: &Grid,
black_ink: bool,
) -> Vec<u8> {
let mut pixels = vec![0u8; width * height]; let col_w = grid.cols.saturating_sub(GUTTER) / 2;
if col_w == 0 || grid.rows == 0 {
return pixels;
}
fill_repeat_bands(&mut pixels, width, height, grid);
let codes: Vec<u32> = text.chars().map(|ch| ch as u32).collect();
let narrow_px = ttf_pixel_size(&FONT_SILVER, grid);
let wide_px = ttf_wide_pixel_size(&FONT_SILVER, grid);
let mut fallback_cache = HashMap::new();
let mut sentence = 0usize;
let mut dim = false;
let mut line = 0usize;
let mut col = 0usize;
for i in 0..codes.len() {
let code = codes[i];
match code {
DIM_ON => {
dim = true;
continue;
}
DIM_OFF => {
dim = false;
continue;
}
0x0a => {
line += 1;
col = 0;
if line >= grid.rows * 2 {
break; }
continue;
}
_ => {}
}
let ink = if dim {
INK_DIM
} else if black_ink {
INK_BLACK
} else {
(1 + sentence % INK_COLORS) as u8
};
if matches!(code, 0x2e | 0x21 | 0x3f)
&& matches!(codes.get(i + 1), Some(&(0x20 | 0x0a | FULL_BLOCK)))
{
sentence += 1;
}
let units = cell_units(code, true);
let mut cell = col;
if units == 2 && col_w >= 2 && cell == col_w - 1 {
cell += 1; }
col = cell + units;
if cell + units > col_w {
continue; }
let column = line / grid.rows;
let row = line - column * grid.rows;
let x_origin = column * (col_w + GUTTER) * grid.cell_w;
if code == FULL_BLOCK {
fill_cell(
&mut pixels,
width,
height,
grid,
x_origin + cell * grid.cell_w,
row,
INK_BLACK,
);
continue;
}
if let Some(glyph) = font.glyphs.get(&code) {
if glyph.rows.is_empty() {
continue;
}
let left = (x_origin + cell * grid.cell_w) as i32 + glyph.xoff;
for copy in 0..grid.repeat {
let cell_top = ((row * grid.repeat + copy) * grid.cell_h) as i32;
let top = cell_top + font.ascent - glyph.h - glyph.yoff;
blit_glyph(&mut pixels, width, height, glyph, left, top, ink);
}
} else if let Some(ch) = char::from_u32(code) {
let px = if units == 2 { wide_px } else { narrow_px };
let Some(glyph) = cached_ttf_glyph(&mut fallback_cache, &FONT_SILVER, ch, px) else {
continue;
};
let span = units * grid.cell_w;
let left = ttf_glyph_origin(x_origin + cell * grid.cell_w, span, &glyph.metrics);
for copy in 0..grid.repeat {
let cell_top = (row * grid.repeat + copy) * grid.cell_h;
let top = ttf_glyph_top(cell_top, font.ascent as f32, &glyph.metrics);
blit_ttf_glyph_indexed(&mut pixels, width, height, glyph, left, top, ink);
}
}
}
pixels
}
fn render_ttf_doc_rgb(
text: &str,
width: usize,
height: usize,
font: &TtfFont,
grid: &Grid,
black_ink: bool,
) -> Vec<u8> {
let mut pixels = vec![255u8; width * height * 3];
let col_w = grid.cols.saturating_sub(GUTTER) / 2;
if col_w == 0 || grid.rows == 0 {
return pixels;
}
fill_repeat_bands_rgb(&mut pixels, width, height, grid);
let px = ttf_pixel_size(font, grid);
let ascent = ttf_ascent(font, px);
let codes: Vec<char> = text.chars().collect();
let mut cache = HashMap::new();
let mut sentence = 0usize;
let mut dim = false;
let mut line = 0usize;
let mut col = 0usize;
for i in 0..codes.len() {
let ch = codes[i];
let code = ch as u32;
match code {
DIM_ON => {
dim = true;
continue;
}
DIM_OFF => {
dim = false;
continue;
}
0x0a => {
line += 1;
col = 0;
if line >= grid.rows * 2 {
break;
}
continue;
}
_ => {}
}
let ink = if dim {
INK_DIM
} else if black_ink {
INK_BLACK
} else {
(1 + sentence % INK_COLORS) as u8
};
if matches!(code, 0x2e | 0x21 | 0x3f)
&& matches!(
codes.get(i + 1).map(|next| *next as u32),
Some(0x20 | 0x0a | FULL_BLOCK)
)
{
sentence += 1;
}
let cell = col;
col += 1;
if cell >= col_w {
continue;
}
let column = line / grid.rows;
let row = line - column * grid.rows;
let x_origin = (column * (col_w + GUTTER) + cell) * grid.cell_w;
if code == FULL_BLOCK {
fill_cell_rgb(&mut pixels, width, height, grid, x_origin, row, INK_BLACK);
continue;
}
let Some(glyph) = cached_ttf_glyph(&mut cache, font, ch, px) else {
continue;
};
let left = ttf_glyph_origin(x_origin, grid.cell_w, &glyph.metrics);
for copy in 0..grid.repeat {
let cell_top = (row * grid.repeat + copy) * grid.cell_h;
let top = ttf_glyph_top(cell_top, ascent, &glyph.metrics);
blit_ttf_glyph(&mut pixels, width, height, glyph, left, top, ink);
}
}
pixels
}
fn lanczos3(x: f32) -> f32 {
let x = x.abs();
if x < 1e-6 {
return 1.0;
}
if x >= 3.0 {
return 0.0;
}
let pix = PI * x;
(pix.sin() / pix) * ((pix / 3.0).sin() / (pix / 3.0))
}
fn contributions(src_len: usize, dst_len: usize) -> Vec<(usize, Vec<f32>)> {
let scale = src_len as f32 / dst_len as f32;
let filt_scale = scale.max(1.0);
let support = 3.0 * filt_scale;
let mut out = Vec::with_capacity(dst_len);
for i in 0..dst_len {
let center = (i as f32 + 0.5) * scale;
let begin = ((center - support) as isize).max(0) as usize;
let end = ((center + support).ceil() as usize).min(src_len);
let mut weights = Vec::with_capacity(end - begin);
let mut total = 0.0f32;
for x in begin..end {
let w = lanczos3((x as f32 + 0.5 - center) / filt_scale);
weights.push(w);
total += w;
}
if total != 0.0 {
for w in &mut weights {
*w /= total;
}
}
out.push((begin, weights));
}
out
}
fn resize_rgb(src: &[f32], sw: usize, sh: usize, dw: usize, dh: usize) -> Vec<f32> {
let horiz = contributions(sw, dw);
let mut tmp = vec![0f32; dw * sh * 3];
for y in 0..sh {
let src_row = &src[y * sw * 3..(y + 1) * sw * 3];
let dst_row = &mut tmp[y * dw * 3..(y + 1) * dw * 3];
for (x, (begin, weights)) in horiz.iter().enumerate() {
let mut acc = [0f32; 3];
for (k, &w) in weights.iter().enumerate() {
let s = (begin + k) * 3;
acc[0] = src_row[s].mul_add(w, acc[0]);
acc[1] = src_row[s + 1].mul_add(w, acc[1]);
acc[2] = src_row[s + 2].mul_add(w, acc[2]);
}
dst_row[x * 3..x * 3 + 3].copy_from_slice(&acc);
}
}
let vert = contributions(sh, dh);
let mut out = vec![0f32; dw * dh * 3];
for (y, (begin, weights)) in vert.iter().enumerate() {
let dst_row = &mut out[y * dw * 3..(y + 1) * dw * 3];
for (k, &w) in weights.iter().enumerate() {
let src_row = &tmp[(begin + k) * dw * 3..(begin + k + 1) * dw * 3];
for (d, &s) in dst_row.iter_mut().zip(src_row) {
*d = s.mul_add(w, *d);
}
}
}
out
}
fn pack_bits(
pixels: &[u8],
width: usize,
height: usize,
bits: usize,
remap: &[u8; PALETTE.len()],
) -> Vec<u8> {
let per = 8 / bits;
let row_bytes = width.div_ceil(per);
let mut packed = vec![0u8; row_bytes * height];
for y in 0..height {
let src = &pixels[y * width..(y + 1) * width];
let dst = &mut packed[y * row_bytes..(y + 1) * row_bytes];
for (x, &px) in src.iter().enumerate() {
dst[x / per] |= remap[px as usize] << (bits * (per - 1 - x % per));
}
}
packed
}
fn encode_indexed_png(
pixels: &[u8],
width: usize,
height: usize,
compression: png::Compression,
) -> Result<Vec<u8>, SnapcompactError> {
let mut used = [false; PALETTE.len()];
for &px in pixels {
used[px as usize] = true;
}
let mut remap = [0u8; PALETTE.len()];
let mut palette = Vec::with_capacity(PALETTE.len() * 3);
let mut count = 0u8;
for (global, &is_used) in used.iter().enumerate() {
if is_used {
remap[global] = count;
count += 1;
palette.extend_from_slice(&PALETTE[global]);
}
}
let (depth, bits) = match count {
0..=2 => (png::BitDepth::One, 1),
3..=4 => (png::BitDepth::Two, 2),
_ => (png::BitDepth::Four, 4),
};
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
encoder.set_color(png::ColorType::Indexed);
encoder.set_depth(depth);
encoder.set_palette(Cow::Owned(palette));
encoder.set_compression(compression);
encoder.set_filter(png::FilterType::NoFilter);
let mut writer = encoder
.write_header()
.map_err(|err| SnapcompactError::Render(format!("Failed to write PNG header: {err}")))?;
writer
.write_image_data(&pack_bits(pixels, width, height, bits, &remap))
.map_err(|err| SnapcompactError::Render(format!("Failed to write PNG data: {err}")))?;
writer
.finish()
.map_err(|err| SnapcompactError::Render(format!("Failed to finish PNG stream: {err}")))?;
Ok(out)
}
fn encode_rgb_png(
pixels: &[u8],
width: usize,
height: usize,
compression: png::Compression,
) -> Result<Vec<u8>, SnapcompactError> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, width as u32, height as u32);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
encoder.set_compression(compression);
let mut writer = encoder
.write_header()
.map_err(|err| SnapcompactError::Render(format!("Failed to write PNG header: {err}")))?;
writer
.write_image_data(pixels)
.map_err(|err| SnapcompactError::Render(format!("Failed to write PNG data: {err}")))?;
writer
.finish()
.map_err(|err| SnapcompactError::Render(format!("Failed to finish PNG stream: {err}")))?;
Ok(out)
}
#[derive(Default, Clone)]
pub struct SnapcompactRenderOptions {
pub size: u32,
pub font: Option<String>,
pub cell_width: Option<u32>,
pub cell_height: Option<u32>,
pub variant: Option<String>,
pub line_repeat: Option<u32>,
pub stretch: Option<bool>,
pub columns: Option<u32>,
}
pub fn snapcompact_supported_chars(
font: String,
chars: String,
) -> Result<String, SnapcompactError> {
let font = resolve_font(&font).ok_or_else(|| {
SnapcompactError::Render(format!(
"Unknown snapcompact font {font:?}: expected \"5x8\", \"8x8\", \"6x12\", \"8x13\", or \
\"silver\""
))
})?;
let mut supported = String::new();
for ch in chars.chars() {
if matches!(ch as u32, DIM_ON | DIM_OFF | FULL_BLOCK | 0x0a) || font.supports(ch as u32) {
supported.push(ch);
}
}
Ok(supported)
}
pub fn render_snapcompact_png(
text: String,
options: SnapcompactRenderOptions,
) -> Result<Vec<u8>, SnapcompactError> {
render_snapcompact_png_sync(text, options)
}
fn render_snapcompact_png_sync(
text: String,
options: SnapcompactRenderOptions,
) -> Result<Vec<u8>, SnapcompactError> {
let size = options.size;
if size == 0 || size > MAX_FRAME_SIZE {
return Err(SnapcompactError::Render(format!(
"Invalid frame size {size}: expected 1..={MAX_FRAME_SIZE}"
)));
}
let font_name = options.font.as_deref().unwrap_or("5x8");
let font = resolve_font(font_name).ok_or_else(|| {
SnapcompactError::Render(format!(
"Unknown snapcompact font {font_name:?}: expected \"5x8\", \"8x8\", \"6x12\", \"8x13\", \
or \"silver\""
))
})?;
let black_ink = match options.variant.as_deref().unwrap_or("sent") {
"sent" => false,
"bw" => true,
other => {
return Err(SnapcompactError::Render(format!(
"Unknown snapcompact variant {other:?}: expected \"sent\" or \"bw\""
)));
}
};
let natural_w = font.cell_w();
let natural_h = font.cell_h();
let target_w = options.cell_width.unwrap_or(natural_w as u32).max(1) as usize;
let target_h = options.cell_height.unwrap_or(natural_h as u32).max(1) as usize;
let repeat = options.line_repeat.unwrap_or(1).max(1) as usize;
let columns = options.columns.unwrap_or(1);
if !matches!(columns, 1 | 2) {
return Err(SnapcompactError::Render(format!(
"Invalid snapcompact columns {columns}: expected 1 or 2"
)));
}
let doc = columns == 2;
let size = size as usize;
let grid = Grid {
cols: size / target_w,
rows: size / target_h / repeat,
repeat,
cell_w: target_w,
cell_h: target_h,
};
if grid.cols == 0 || grid.rows == 0 {
return Err(SnapcompactError::Render(format!(
"Frame size {size} cannot fit a {target_w}x{target_h} cell grid (repeat {repeat})"
)));
}
let wide_cells = matches!(font, RenderFont::Bitmap(_));
let used = used_rows(&text, &grid, doc, wide_cells);
let height = used * grid.repeat * grid.cell_h;
match font {
RenderFont::Ttf(font) => {
let pixels = if doc {
render_ttf_doc_rgb(&text, size, height, font, &grid, black_ink)
} else {
render_ttf_rgb(&text, size, height, font, &grid, black_ink)
};
Ok(encode_rgb_png(
&pixels,
size,
height,
png::Compression::Best,
)?)
}
RenderFont::Bitmap(font) => {
let stretch =
options.stretch != Some(false) && (target_w, target_h) != (natural_w, natural_h);
if !stretch {
let pixels = if doc {
render_doc_bitmap(&text, size, height, font, &grid, black_ink)
} else {
render_bitmap(&text, size, height, font, &grid, black_ink)
};
return encode_indexed_png(&pixels, size, height, png::Compression::Best);
}
let native = Grid {
cell_w: natural_w,
cell_h: natural_h,
..grid
};
let src_w = grid.cols * natural_w;
let src_h = used * grid.repeat * natural_h;
let dst_w = grid.cols * target_w;
let dst_h = used * grid.repeat * target_h;
let indexed = if doc {
render_doc_bitmap(&text, src_w, src_h, font, &native, black_ink)
} else {
render_bitmap(&text, src_w, src_h, font, &native, black_ink)
};
let mut rgb = vec![0f32; src_w * src_h * 3];
for (dst, &idx) in rgb.chunks_exact_mut(3).zip(&indexed) {
let [r, g, b] = PALETTE[idx as usize];
dst[0] = f32::from(r);
dst[1] = f32::from(g);
dst[2] = f32::from(b);
}
let resized = resize_rgb(&rgb, src_w, src_h, dst_w, dst_h);
let mut frame = vec![255u8; size * dst_h * 3];
for y in 0..dst_h {
let src_row = &resized[y * dst_w * 3..(y + 1) * dst_w * 3];
let dst_row = &mut frame[y * size * 3..];
for (d, &s) in dst_row[..dst_w.min(size) * 3].iter_mut().zip(src_row) {
*d = s.round().clamp(0.0, 255.0) as u8;
}
}
Ok(encode_rgb_png(&frame, size, dst_h, png::Compression::Best)?)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(size: u32) -> SnapcompactRenderOptions {
SnapcompactRenderOptions {
size,
..Default::default()
}
}
#[test]
fn fonts_parse_ascii_coverage() {
for (font, ascent) in [(&*FONT_5X8, 7), (&*FONT_8X8, 7)] {
assert_eq!(font.ascent, ascent);
for cp in 0x20u32..0x7f {
assert!(
font.glyphs.contains_key(&cp),
"missing glyph for U+{cp:04X}"
);
}
}
}
#[test]
fn silver_font_covers_cjk_scripts() {
assert!(
FONT_SILVER.supported.contains(&'こ'),
"Silver must cover Japanese kana"
);
assert!(
FONT_SILVER.supported.contains(&'你'),
"Silver must cover Han text"
);
assert!(
FONT_SILVER.supported.contains(&'안'),
"Silver must cover Hangul syllables"
);
}
#[test]
fn bitmap_inks_sentences_and_caps_capacity() {
let grid = Grid {
cols: 8,
rows: 5,
repeat: 1,
cell_w: 5,
cell_h: 8,
};
let pixels = render_bitmap("Hi. Ok.", 40, 40, &FONT_5X8, &grid, false);
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
assert!(inks.contains(&1), "first sentence should use ink 1");
assert!(inks.contains(&2), "second sentence should use ink 2");
assert!(!inks.contains(&3), "no third sentence ink expected");
let overflow = render_bitmap(&"x".repeat(100), 40, 40, &FONT_5X8, &grid, false);
assert_eq!(overflow.len(), 40 * 40);
}
#[test]
fn bw_variant_prints_black_only() {
let grid = Grid {
cols: 8,
rows: 8,
repeat: 1,
cell_w: 8,
cell_h: 8,
};
let pixels = render_bitmap("Hi. Ok.", 64, 64, &FONT_8X8, &grid, true);
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
assert!(!inks.is_empty());
assert!(
inks.iter().all(|&p| p == INK_BLACK),
"bw must ink only black"
);
}
#[test]
fn dim_markers_toggle_gray_without_consuming_cells() {
let grid = Grid {
cols: 8,
rows: 8,
repeat: 1,
cell_w: 8,
cell_h: 8,
};
let pixels = render_bitmap("\u{e}AB\u{f}CD", 64, 64, &FONT_8X8, &grid, true);
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
assert!(inks.contains(&INK_DIM), "dim span must ink gray");
assert!(
inks.contains(&INK_BLACK),
"post-span text must return to black"
);
let plain = render_bitmap("ABCD", 64, 64, &FONT_8X8, &grid, true);
for (i, (a, b)) in pixels.iter().zip(&plain).enumerate() {
assert_eq!(
*a != 0,
*b != 0,
"cell layout must ignore markers (pixel {i})"
);
}
}
#[test]
fn line_repeat_duplicates_rows_on_highlight_bands() {
let grid = Grid {
cols: 8,
rows: 4,
repeat: 2,
cell_w: 8,
cell_h: 8,
};
let pixels = render_bitmap("ABCDEFGH", 64, 64, &FONT_8X8, &grid, true);
assert!(
pixels[9 * 64..10 * 64].contains(&BG_REPEAT),
"duplicate band must be highlighted"
);
for y in 0..8 {
for x in 0..64 {
let a = pixels[y * 64 + x];
let b = pixels[(y + 8) * 64 + x];
assert_eq!(
a == INK_BLACK,
b == INK_BLACK,
"copy ink mismatch at ({x},{y})"
);
}
}
}
#[test]
fn full_block_fills_cell_pitch_black() {
let grid = Grid {
cols: 8,
rows: 4,
repeat: 2,
cell_w: 8,
cell_h: 8,
};
let pixels = render_bitmap("\u{e}a\u{2588}b\u{f}", 64, 64, &FONT_8X8, &grid, false);
for copy in 0..2 {
for y in copy * 8..(copy + 1) * 8 {
for x in 8..16 {
assert_eq!(
pixels[y * 64 + x],
INK_BLACK,
"block pixel ({x},{y}) must be black"
);
}
}
}
assert!(pixels.contains(&INK_DIM), "neighbours keep their dim ink");
let hued = render_bitmap("Hi.\u{2588}Ok.", 64, 64, &FONT_8X8, &grid, false);
assert!(
hued.contains(&2),
"block must advance the sentence hue like a space"
);
}
#[test]
fn doc_full_block_fills_cell() {
let grid = Grid {
cols: 13,
rows: 2,
repeat: 1,
cell_w: 8,
cell_h: 8,
};
let pixels = render_doc_bitmap("a\u{2588}b\nc", 104, 16, &FONT_8X8, &grid, true);
for y in 0..8 {
for x in 8..16 {
assert_eq!(
pixels[y * 104 + x],
INK_BLACK,
"block pixel ({x},{y}) must be black"
);
}
}
}
fn png_bytes(encoded: Vec<u8>) -> Vec<u8> {
encoded
}
#[test]
fn render_native_is_indexed_and_stretch_is_rgb() {
let native = png_bytes(
render_snapcompact_png_sync(
"Hello world. Again.".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("8x8".into()),
variant: Some("bw".into()),
line_repeat: Some(2),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(native[25], 3);
let stretched = png_bytes(
render_snapcompact_png_sync(
"Hello world. Again.".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("8x8".into()),
cell_width: Some(6),
cell_height: Some(6),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(stretched[25], 2);
let legacy = png_bytes(render_snapcompact_png_sync("Hi. Ok.".into(), opts(40)).unwrap());
assert_eq!(
legacy[25], 3,
"default shape stays the legacy 5x8 indexed path"
);
let silver = png_bytes(
render_snapcompact_png_sync(
"こんにちは 你好 안녕".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("silver".into()),
cell_width: Some(16),
cell_height: Some(16),
variant: Some("bw".into()),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(silver[25], 2, "TrueType frames render as RGB");
}
#[test]
fn indexed_png_narrows_palette_and_bit_depth() {
fn depth_and_palette(png: &[u8]) -> (u8, usize) {
let tag = png
.windows(4)
.position(|w| w == b"PLTE")
.expect("PLTE chunk");
let len = u32::from_be_bytes(png[tag - 4..tag].try_into().unwrap()) as usize;
(png[24], len / 3)
}
let bw = png_bytes(
render_snapcompact_png_sync(
"Hello world. Again.".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("8x8".into()),
variant: Some("bw".into()),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(depth_and_palette(&bw), (1, 2));
let dim = png_bytes(
render_snapcompact_png_sync(
"Read \u{e}the dim part\u{f} now.".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("8x8".into()),
variant: Some("bw".into()),
line_repeat: Some(2),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(depth_and_palette(&dim), (2, 4));
let sent = png_bytes(
render_snapcompact_png_sync(
"Hi. Ok.".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("8x8".into()),
variant: Some("sent".into()),
..Default::default()
},
)
.unwrap(),
);
let (sent_depth, sent_colors) = depth_and_palette(&sent);
assert_eq!(sent_depth, 2, "two hues + bg fit 2-bit");
assert_eq!(sent_colors, 3);
}
#[test]
fn rejects_bad_shapes() {
assert!(render_snapcompact_png_sync("x".into(), opts(0)).is_err());
assert!(
render_snapcompact_png_sync(
"x".into(),
SnapcompactRenderOptions {
size: 64,
font: Some("9x9".into()),
..Default::default()
}
)
.is_err()
);
assert!(
render_snapcompact_png_sync(
"x".into(),
SnapcompactRenderOptions {
size: 64,
variant: Some("zebra".into()),
..Default::default()
}
)
.is_err()
);
}
#[test]
fn xorg_fonts_parse_and_render() {
for (font, ascent, name) in [(&*FONT_6X12, 10, "6x12"), (&*FONT_8X13, 11, "8x13")] {
assert_eq!(font.ascent, ascent, "{name} ascent");
for cp in 0x20u32..0x7f {
assert!(
font.glyphs.contains_key(&cp),
"{name} missing glyph U+{cp:04X}"
);
}
}
for (name, size) in [("6x12", 60u32), ("8x13", 104u32)] {
let png = png_bytes(
render_snapcompact_png_sync(
"Hello world. Again!".into(),
SnapcompactRenderOptions {
size,
font: Some(name.into()),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(png[25], 3, "{name} natural cell must encode indexed");
}
let grid = Grid {
cols: 10,
rows: 5,
repeat: 1,
cell_w: 6,
cell_h: 12,
};
let pixels = render_bitmap("Hello", 60, 60, &FONT_6X12, &grid, true);
assert!(pixels.contains(&INK_BLACK), "6x12 must ink pixels");
let grid = Grid {
cols: 8,
rows: 8,
repeat: 1,
cell_w: 8,
cell_h: 13,
};
let pixels = render_bitmap("Hello", 64, 104, &FONT_8X13, &grid, true);
assert!(pixels.contains(&INK_BLACK), "8x13 must ink pixels");
}
#[test]
fn stretch_false_renders_natural_glyphs_on_padded_pitch() {
let png = png_bytes(
render_snapcompact_png_sync(
"Hello there. General Kenobi!".into(),
SnapcompactRenderOptions {
size: 128,
font: Some("8x13".into()),
cell_width: Some(8),
cell_height: Some(16),
stretch: Some(false),
variant: Some("bw".into()),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(png[25], 3, "8on16 must stay indexed");
let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
assert_eq!(
(dim(16), dim(20)),
(128, 32),
"declared geometry must match"
);
let grid = Grid {
cols: 16,
rows: 8,
repeat: 1,
cell_w: 8,
cell_h: 16,
};
let pixels = render_bitmap(
"Hgjpqy. Mixed descenders!",
128,
128,
&FONT_8X13,
&grid,
true,
);
assert!(pixels.contains(&INK_BLACK));
for (i, &p) in pixels.iter().enumerate() {
if p == INK_BLACK {
assert!(
(i / 128) % 16 < 13,
"ink leaked into pitch padding at y={}",
i / 128
);
}
}
}
#[test]
fn doc_layout_flows_lines_into_second_column() {
let grid = Grid {
cols: 8,
rows: 4,
repeat: 1,
cell_w: 8,
cell_h: 16,
};
let pixels = render_doc_bitmap("A\nB\nC\nD\nE", 64, 64, &FONT_8X13, &grid, true);
let col2 = (0..13).any(|y| (40..48).any(|x| pixels[y * 64 + x] == INK_BLACK));
assert!(
col2,
"fifth line must start at the second column's x origin"
);
let row1 = (16..29).any(|y| (0..8).any(|x| pixels[y * 64 + x] == INK_BLACK));
assert!(
row1,
"second line must start at column 0 of the next row band"
);
for y in 0..64 {
for x in 8..40 {
assert_eq!(pixels[y * 64 + x], 0, "gutter must stay blank at ({x},{y})");
}
}
}
#[test]
fn doc_sentence_hue_advances_across_newline_boundary() {
let grid = Grid {
cols: 19,
rows: 4,
repeat: 1,
cell_w: 8,
cell_h: 16,
};
let pixels = render_doc_bitmap("Hi.\nOk", 152, 64, &FONT_8X13, &grid, false);
let inks: Vec<u8> = pixels.iter().copied().filter(|&p| p != 0).collect();
assert!(inks.contains(&1), "first sentence must use ink 1");
assert!(
inks.contains(&2),
"hue must advance across the newline boundary"
);
assert!(!inks.contains(&3), "no third sentence ink expected");
let gridmode = render_bitmap("Hi.\nOk", 152, 64, &FONT_8X13, &grid, false);
let inks: Vec<u8> = gridmode.iter().copied().filter(|&p| p != 0).collect();
assert!(inks.contains(&1));
assert!(
!inks.contains(&2),
"grid mode must not advance hue across newline"
);
}
#[test]
fn frame_height_hugs_used_rows() {
let dims = |png: &[u8]| {
let dim = |off: usize| u32::from_be_bytes(png[off..off + 4].try_into().unwrap());
(dim(16), dim(20))
};
let render = |text: &str, opts: SnapcompactRenderOptions| {
png_bytes(render_snapcompact_png_sync(text.into(), opts).unwrap())
};
let opts_8x8 = || SnapcompactRenderOptions {
size: 64,
font: Some("8x8".into()),
..Default::default()
};
assert_eq!(dims(&render("0123456789", opts_8x8())), (64, 16));
assert_eq!(dims(&render("\u{e}01234567\u{f}", opts_8x8())), (64, 8));
assert_eq!(dims(&render(&"x".repeat(64), opts_8x8())), (64, 64));
let repeated = render(
"0123456789",
SnapcompactRenderOptions {
line_repeat: Some(2),
..opts_8x8()
},
);
assert_eq!(dims(&repeated), (64, 32));
let doc = render(
"Hello there.\nSecond line",
SnapcompactRenderOptions {
size: 256,
font: Some("8x13".into()),
cell_width: Some(8),
cell_height: Some(16),
stretch: Some(false),
columns: Some(2),
..Default::default()
},
);
assert_eq!(dims(&doc), (256, 32));
let stretched = render(
"0123456789ab",
SnapcompactRenderOptions {
size: 60,
font: Some("8x8".into()),
cell_width: Some(6),
cell_height: Some(6),
..Default::default()
},
);
assert_eq!(dims(&stretched), (60, 12));
}
#[test]
fn columns_validates_and_renders_doc_frames() {
assert!(
render_snapcompact_png_sync(
"x".into(),
SnapcompactRenderOptions {
size: 64,
columns: Some(3),
..Default::default()
}
)
.is_err()
);
let doc = png_bytes(
render_snapcompact_png_sync(
"Hello there.\nSecond line".into(),
SnapcompactRenderOptions {
size: 256,
font: Some("8x13".into()),
cell_width: Some(8),
cell_height: Some(16),
stretch: Some(false),
columns: Some(2),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(doc[25], 3, "8on16 doc frame must encode indexed");
let stretched = png_bytes(
render_snapcompact_png_sync(
"Hello there.\nSecond line".into(),
SnapcompactRenderOptions {
size: 256,
font: Some("8x13".into()),
cell_width: Some(6),
cell_height: Some(12),
columns: Some(2),
..Default::default()
},
)
.unwrap(),
);
assert_eq!(stretched[25], 2, "stretched doc frame must encode RGB");
}
}