chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "jcode-terminal-image"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "Terminal image display (Kitty/iTerm2/Sixel) and image metadata formatting helpers for jcode"
|
||||
|
||||
[lib]
|
||||
name = "jcode_terminal_image"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
crossterm = "0.29"
|
||||
@@ -0,0 +1,444 @@
|
||||
//! Terminal image display support
|
||||
//!
|
||||
//! Supports Kitty graphics protocol (Kitty, Ghostty), iTerm2 inline images,
|
||||
//! and Sixel graphics (xterm, foot, mlterm, WezTerm).
|
||||
//! Falls back to a simple placeholder if no image protocol is available.
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use std::io::{self, Write};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Cache whether ImageMagick is available for Sixel conversion
|
||||
static HAS_IMAGEMAGICK: LazyLock<bool> = LazyLock::new(|| {
|
||||
Command::new("convert")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
/// Terminal image protocol support
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum ImageProtocol {
|
||||
/// Kitty graphics protocol (most feature-rich)
|
||||
Kitty,
|
||||
/// iTerm2 inline images
|
||||
ITerm2,
|
||||
/// Sixel graphics (xterm, foot, mlterm, WezTerm)
|
||||
Sixel,
|
||||
/// No image support
|
||||
None,
|
||||
}
|
||||
|
||||
impl ImageProtocol {
|
||||
/// Detect the best available image protocol for the current terminal
|
||||
pub fn detect() -> Self {
|
||||
// Check for Kitty first (most capable)
|
||||
if std::env::var("KITTY_WINDOW_ID").is_ok() {
|
||||
return Self::Kitty;
|
||||
}
|
||||
|
||||
// Check TERM for kitty or ghostty
|
||||
if let Ok(term) = std::env::var("TERM")
|
||||
&& (term.contains("kitty") || term.contains("ghostty"))
|
||||
{
|
||||
return Self::Kitty;
|
||||
}
|
||||
|
||||
// Check TERM_PROGRAM for Ghostty
|
||||
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
|
||||
if term_program == "ghostty" {
|
||||
return Self::Kitty;
|
||||
}
|
||||
if term_program == "iTerm.app" {
|
||||
return Self::ITerm2;
|
||||
}
|
||||
// WezTerm supports Sixel
|
||||
if term_program == "WezTerm" {
|
||||
return Self::Sixel;
|
||||
}
|
||||
}
|
||||
|
||||
// Check LC_TERMINAL for iTerm2
|
||||
if let Ok(lc_terminal) = std::env::var("LC_TERMINAL")
|
||||
&& lc_terminal == "iTerm2"
|
||||
{
|
||||
return Self::ITerm2;
|
||||
}
|
||||
|
||||
// Check for Sixel-capable terminals
|
||||
if Self::detect_sixel() {
|
||||
return Self::Sixel;
|
||||
}
|
||||
|
||||
Self::None
|
||||
}
|
||||
|
||||
/// Detect if terminal supports Sixel graphics
|
||||
fn detect_sixel() -> bool {
|
||||
// Only enable Sixel if we have ImageMagick to do the conversion
|
||||
if !*HAS_IMAGEMAGICK {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(term) = std::env::var("TERM") {
|
||||
let term_lower = term.to_lowercase();
|
||||
// Known Sixel-capable terminals
|
||||
if term_lower.contains("xterm")
|
||||
|| term_lower.contains("foot")
|
||||
|| term_lower.contains("mlterm")
|
||||
|| term_lower.contains("yaft")
|
||||
|| term_lower.contains("mintty")
|
||||
|| term_lower.contains("contour")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check TERM_PROGRAM for other Sixel terminals
|
||||
if let Ok(prog) = std::env::var("TERM_PROGRAM")
|
||||
&& (prog == "mintty" || prog == "contour")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if image display is supported
|
||||
pub fn is_supported(&self) -> bool {
|
||||
*self != Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// Display parameters for terminal images
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ImageDisplayParams {
|
||||
/// Maximum width in terminal columns
|
||||
pub max_cols: u16,
|
||||
/// Maximum height in terminal rows
|
||||
pub max_rows: u16,
|
||||
}
|
||||
|
||||
impl Default for ImageDisplayParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_cols: 80,
|
||||
max_rows: 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ImageDisplayParams {
|
||||
/// Create display params based on terminal size
|
||||
pub fn from_terminal() -> Self {
|
||||
let (cols, rows) = crossterm::terminal::size().unwrap_or((120, 40));
|
||||
|
||||
// Use about 2/3 of terminal width, capped at 100 columns
|
||||
// Use about 1/2 of terminal height, capped at 30 rows
|
||||
Self {
|
||||
max_cols: (cols * 2 / 3).clamp(40, 100),
|
||||
max_rows: (rows / 2).clamp(10, 30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Display an image in the terminal
|
||||
///
|
||||
/// Returns Ok(true) if the image was displayed, Ok(false) if not supported,
|
||||
/// or an error if something went wrong.
|
||||
pub fn display_image(path: &Path, params: &ImageDisplayParams) -> io::Result<bool> {
|
||||
let protocol = ImageProtocol::detect();
|
||||
|
||||
if !protocol.is_supported() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Read the image file
|
||||
let data = std::fs::read(path)?;
|
||||
|
||||
// Get image dimensions to calculate aspect ratio
|
||||
let (img_width, img_height) = get_image_dimensions(&data).unwrap_or((0, 0));
|
||||
|
||||
match protocol {
|
||||
ImageProtocol::Kitty => display_kitty(&data, params, img_width, img_height),
|
||||
ImageProtocol::ITerm2 => display_iterm2(&data, path, params, img_width, img_height),
|
||||
ImageProtocol::Sixel => display_sixel(path, params, img_width, img_height),
|
||||
ImageProtocol::None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get image dimensions from raw data
|
||||
fn get_image_dimensions(data: &[u8]) -> Option<(u32, u32)> {
|
||||
// PNG: check signature and parse IHDR chunk
|
||||
if data.len() > 24 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
|
||||
let width = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
|
||||
let height = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
|
||||
return Some((width, height));
|
||||
}
|
||||
|
||||
// JPEG: look for SOF0/SOF2 markers
|
||||
if data.len() > 2 && data[0] == 0xFF && data[1] == 0xD8 {
|
||||
let mut i = 2;
|
||||
while i + 9 < data.len() {
|
||||
if data[i] != 0xFF {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let marker = data[i + 1];
|
||||
// SOF0 (baseline) or SOF2 (progressive)
|
||||
if marker == 0xC0 || marker == 0xC2 {
|
||||
let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
|
||||
let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
|
||||
return Some((width, height));
|
||||
}
|
||||
// Skip to next marker
|
||||
if i + 3 < data.len() {
|
||||
let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
|
||||
i += 2 + len;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GIF: parse header
|
||||
if data.len() > 10 && (&data[0..6] == b"GIF87a" || &data[0..6] == b"GIF89a") {
|
||||
let width = u16::from_le_bytes([data[6], data[7]]) as u32;
|
||||
let height = u16::from_le_bytes([data[8], data[9]]) as u32;
|
||||
return Some((width, height));
|
||||
}
|
||||
|
||||
// WebP: parse RIFF header
|
||||
if data.len() > 30 && &data[0..4] == b"RIFF" && &data[8..12] == b"WEBP" {
|
||||
// VP8 chunk
|
||||
if &data[12..16] == b"VP8 " && data.len() > 30 {
|
||||
// VP8 bitstream starts at offset 23, dimensions at offset 26
|
||||
if data[23] == 0x9D && data[24] == 0x01 && data[25] == 0x2A {
|
||||
let width = (u16::from_le_bytes([data[26], data[27]]) & 0x3FFF) as u32;
|
||||
let height = (u16::from_le_bytes([data[28], data[29]]) & 0x3FFF) as u32;
|
||||
return Some((width, height));
|
||||
}
|
||||
}
|
||||
// VP8L (lossless)
|
||||
if &data[12..16] == b"VP8L" && data.len() > 25 {
|
||||
let bits = u32::from_le_bytes([data[21], data[22], data[23], data[24]]);
|
||||
let width = (bits & 0x3FFF) + 1;
|
||||
let height = ((bits >> 14) & 0x3FFF) + 1;
|
||||
return Some((width, height));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Calculate display size maintaining aspect ratio
|
||||
fn calculate_display_size(
|
||||
img_width: u32,
|
||||
img_height: u32,
|
||||
max_cols: u16,
|
||||
max_rows: u16,
|
||||
) -> (u16, u16) {
|
||||
if img_width == 0 || img_height == 0 {
|
||||
return (max_cols.min(40), max_rows.min(20));
|
||||
}
|
||||
|
||||
// Terminal cells are typically ~2:1 aspect ratio (taller than wide)
|
||||
// So we need to account for that when calculating display size
|
||||
let cell_aspect = 2.0; // height/width ratio of a terminal cell
|
||||
|
||||
let img_aspect = img_width as f64 / img_height as f64;
|
||||
let max_width = max_cols as f64;
|
||||
let max_height = max_rows as f64 * cell_aspect; // Convert rows to "width units"
|
||||
|
||||
let (display_width, display_height) = if img_aspect > max_width / max_height {
|
||||
// Image is wider than available space
|
||||
(max_width, max_width / img_aspect)
|
||||
} else {
|
||||
// Image is taller than available space
|
||||
(max_height * img_aspect, max_height)
|
||||
};
|
||||
|
||||
(
|
||||
(display_width as u16).max(10),
|
||||
(display_height / cell_aspect) as u16, // Convert back to rows
|
||||
)
|
||||
}
|
||||
|
||||
/// Display image using Kitty graphics protocol
|
||||
fn display_kitty(
|
||||
data: &[u8],
|
||||
params: &ImageDisplayParams,
|
||||
img_width: u32,
|
||||
img_height: u32,
|
||||
) -> io::Result<bool> {
|
||||
let (cols, rows) =
|
||||
calculate_display_size(img_width, img_height, params.max_cols, params.max_rows);
|
||||
|
||||
// Encode image data as base64
|
||||
let encoded = BASE64.encode(data);
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
|
||||
// Kitty graphics protocol:
|
||||
// \x1b_G<key>=<value>,...;<payload>\x1b\\
|
||||
//
|
||||
// Keys:
|
||||
// a=T - action: transmit and display
|
||||
// f=100 - format: auto-detect
|
||||
// c=<cols> - display width in cells
|
||||
// r=<rows> - display height in cells
|
||||
// m=1 - more data follows (chunked)
|
||||
// m=0 - final chunk
|
||||
|
||||
// Send in chunks (max 4096 bytes per chunk for safety)
|
||||
const CHUNK_SIZE: usize = 4096;
|
||||
let chunks: Vec<&str> = encoded
|
||||
.as_bytes()
|
||||
.chunks(CHUNK_SIZE)
|
||||
.map(|c| std::str::from_utf8(c).unwrap_or(""))
|
||||
.collect();
|
||||
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
let is_first = i == 0;
|
||||
let is_last = i == chunks.len() - 1;
|
||||
let more = if is_last { 0 } else { 1 };
|
||||
|
||||
if is_first {
|
||||
// First chunk includes all parameters
|
||||
write!(
|
||||
stdout,
|
||||
"\x1b_Ga=T,f=100,c={},r={},m={};{}\x1b\\",
|
||||
cols, rows, more, chunk
|
||||
)?;
|
||||
} else {
|
||||
// Subsequent chunks only have m flag
|
||||
write!(stdout, "\x1b_Gm={};{}\x1b\\", more, chunk)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Newline after image
|
||||
writeln!(stdout)?;
|
||||
stdout.flush()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Display image using iTerm2 inline image protocol
|
||||
fn display_iterm2(
|
||||
data: &[u8],
|
||||
path: &Path,
|
||||
params: &ImageDisplayParams,
|
||||
img_width: u32,
|
||||
img_height: u32,
|
||||
) -> io::Result<bool> {
|
||||
let (cols, _rows) =
|
||||
calculate_display_size(img_width, img_height, params.max_cols, params.max_rows);
|
||||
|
||||
// Encode image data as base64
|
||||
let encoded = BASE64.encode(data);
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "image".to_string());
|
||||
let filename_b64 = BASE64.encode(filename.as_bytes());
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
|
||||
// iTerm2 inline image protocol:
|
||||
// \x1b]1337;File=name=<base64name>;size=<size>;inline=1;width=<cols>:<base64data>\x07
|
||||
write!(
|
||||
stdout,
|
||||
"\x1b]1337;File=name={};size={};inline=1;width={}:{}\x07",
|
||||
filename_b64,
|
||||
data.len(),
|
||||
cols,
|
||||
encoded
|
||||
)?;
|
||||
|
||||
// Newline after image
|
||||
writeln!(stdout)?;
|
||||
stdout.flush()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Display image using Sixel graphics protocol
|
||||
///
|
||||
/// Uses ImageMagick's `convert` command to generate Sixel output.
|
||||
/// This is the same approach used by image.nvim and other terminal image tools.
|
||||
fn display_sixel(
|
||||
path: &Path,
|
||||
params: &ImageDisplayParams,
|
||||
img_width: u32,
|
||||
img_height: u32,
|
||||
) -> io::Result<bool> {
|
||||
if !*HAS_IMAGEMAGICK {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let (cols, rows) =
|
||||
calculate_display_size(img_width, img_height, params.max_cols, params.max_rows);
|
||||
|
||||
// Calculate pixel dimensions based on typical terminal cell size
|
||||
// Assuming ~8px wide x 16px tall cells (common default)
|
||||
let pixel_width = (cols as u32) * 8;
|
||||
let pixel_height = (rows as u32) * 16;
|
||||
|
||||
// Use ImageMagick to convert to Sixel
|
||||
// -geometry: resize to fit
|
||||
// -colors 256: limit palette for Sixel
|
||||
// sixel:-: output Sixel to stdout
|
||||
let output = Command::new("convert")
|
||||
.arg(path)
|
||||
.arg("-geometry")
|
||||
.arg(format!("{}x{}>", pixel_width, pixel_height))
|
||||
.arg("-colors")
|
||||
.arg("256")
|
||||
.arg("sixel:-")
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.write_all(&output.stdout)?;
|
||||
writeln!(stdout)?;
|
||||
stdout.flush()?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_protocol_detection() {
|
||||
// This test just verifies the detection doesn't panic
|
||||
let protocol = ImageProtocol::detect();
|
||||
println!("Detected protocol: {:?}", protocol);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_display_size() {
|
||||
// Wide image
|
||||
let (w, h) = calculate_display_size(1920, 1080, 80, 24);
|
||||
assert!(w <= 80);
|
||||
assert!(h <= 24);
|
||||
|
||||
// Tall image
|
||||
let (w, h) = calculate_display_size(1080, 1920, 80, 24);
|
||||
assert!(w <= 80);
|
||||
assert!(h <= 24);
|
||||
|
||||
// Square image
|
||||
let (w, h) = calculate_display_size(500, 500, 80, 24);
|
||||
assert!(w <= 80);
|
||||
assert!(h <= 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Low-level terminal image primitives for jcode.
|
||||
//!
|
||||
//! This crate has no dependency on the rest of jcode; it provides:
|
||||
//! - [`display`]: terminal image rendering (Kitty / iTerm2 / Sixel) with a
|
||||
//! graceful no-protocol fallback.
|
||||
//! - [`metadata`]: small pure helpers for formatting image dimensions, byte
|
||||
//! counts, formats, and estimating base64-decoded sizes.
|
||||
//!
|
||||
//! It is shared by the TUI and the `read` tool so neither has to depend on
|
||||
//! the other just to display or describe an image.
|
||||
|
||||
pub mod display;
|
||||
pub mod metadata;
|
||||
|
||||
pub use display::{ImageDisplayParams, ImageProtocol, display_image};
|
||||
@@ -0,0 +1,138 @@
|
||||
pub fn compact_path_label(path: &str) -> String {
|
||||
let trimmed = path.trim();
|
||||
std::path::Path::new(trimmed)
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
pub fn compact_image_format(value: &str) -> String {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return "image".to_string();
|
||||
}
|
||||
|
||||
let lower = trimmed
|
||||
.strip_prefix("image/")
|
||||
.unwrap_or(trimmed)
|
||||
.to_ascii_lowercase();
|
||||
match lower.as_str() {
|
||||
"jpg" | "jpeg" => "JPEG".to_string(),
|
||||
"png" => "PNG".to_string(),
|
||||
"webp" => "WebP".to_string(),
|
||||
"gif" => "GIF".to_string(),
|
||||
"bmp" => "BMP".to_string(),
|
||||
"ico" | "x-icon" => "ICO".to_string(),
|
||||
"svg" | "svg+xml" => "SVG".to_string(),
|
||||
_ => trimmed.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_dimensions(width: u32, height: u32) -> String {
|
||||
format!("{width}×{height}")
|
||||
}
|
||||
|
||||
pub fn aspect_ratio(width: u32, height: u32) -> Option<String> {
|
||||
if width == 0 || height == 0 {
|
||||
return None;
|
||||
}
|
||||
let divisor = gcd(width, height).max(1);
|
||||
Some(format!("{}:{}", width / divisor, height / divisor))
|
||||
}
|
||||
|
||||
pub fn format_byte_count(bytes: u64) -> String {
|
||||
const KB: f64 = 1024.0;
|
||||
const MB: f64 = KB * 1024.0;
|
||||
const GB: f64 = MB * 1024.0;
|
||||
|
||||
if bytes == 1 {
|
||||
return "1 B".to_string();
|
||||
}
|
||||
if bytes < 1024 {
|
||||
return format!("{bytes} B");
|
||||
}
|
||||
|
||||
let (value, unit) = if (bytes as f64) >= GB {
|
||||
(bytes as f64 / GB, "GB")
|
||||
} else if (bytes as f64) >= MB {
|
||||
(bytes as f64 / MB, "MB")
|
||||
} else {
|
||||
(bytes as f64 / KB, "KB")
|
||||
};
|
||||
let mut formatted = if value >= 100.0 {
|
||||
format!("{value:.0}")
|
||||
} else {
|
||||
format!("{value:.1}")
|
||||
};
|
||||
if formatted.ends_with(".0") {
|
||||
formatted.truncate(formatted.len().saturating_sub(2));
|
||||
}
|
||||
format!("{formatted} {unit}")
|
||||
}
|
||||
|
||||
pub fn estimate_base64_decoded_len(data: &str) -> Option<u64> {
|
||||
let trimmed = data.trim();
|
||||
let payload = if trimmed.starts_with("data:") {
|
||||
trimmed.split_once(',').map(|(_, payload)| payload)?
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
|
||||
let mut len = 0u64;
|
||||
let mut padding = 0u64;
|
||||
for ch in payload.chars() {
|
||||
if ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
len += 1;
|
||||
if ch == '=' {
|
||||
padding += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if len == 0 || len % 4 == 1 || padding > 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(((len * 3) / 4).saturating_sub(padding))
|
||||
}
|
||||
|
||||
fn gcd(mut a: u32, mut b: u32) -> u32 {
|
||||
while b != 0 {
|
||||
let next = a % b;
|
||||
a = b;
|
||||
b = next;
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_byte_count_uses_compact_units() {
|
||||
assert_eq!(format_byte_count(1), "1 B");
|
||||
assert_eq!(format_byte_count(1536), "1.5 KB");
|
||||
assert_eq!(format_byte_count(2 * 1024 * 1024), "2 MB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_base64_decoded_len_handles_common_payloads() {
|
||||
assert_eq!(estimate_base64_decoded_len("TQ=="), Some(1));
|
||||
assert_eq!(estimate_base64_decoded_len("TWE="), Some(2));
|
||||
assert_eq!(estimate_base64_decoded_len("TWFu"), Some(3));
|
||||
assert_eq!(
|
||||
estimate_base64_decoded_len("data:image/png;base64,TWFu"),
|
||||
Some(3)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aspect_ratio_reduces_dimensions() {
|
||||
assert_eq!(aspect_ratio(1024, 1024).as_deref(), Some("1:1"));
|
||||
assert_eq!(aspect_ratio(1792, 1024).as_deref(), Some("7:4"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user