use std::io::Cursor;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, Context, Result};
use fast_image_resize as fr;
use image::{AnimationDecoder, DynamicImage, ImageDecoder, ImageFormat, ImageReader};
#[derive(Clone, Debug)]
pub struct Raster {
pub w: u32,
pub h: u32,
pub ch: u8,
pub data: Vec<u8>,
}
impl Raster {
pub fn from_dynamic(img: DynamicImage) -> Raster {
let (w, h) = (img.width(), img.height());
if img.color().has_alpha() {
let mut rgba = img.into_rgba8();
if rgba.pixels().all(|p| p.0[3] == 255) {
let rgb = DynamicImage::ImageRgba8(std::mem::take(&mut rgba)).into_rgb8();
return Raster {
w,
h,
ch: 3,
data: rgb.into_raw(),
};
}
Raster {
w,
h,
ch: 4,
data: rgba.into_raw(),
}
} else {
Raster {
w,
h,
ch: 3,
data: img.into_rgb8().into_raw(),
}
}
}
pub fn to_rgba(&self) -> Raster {
if self.ch == 4 {
return self.clone();
}
let mut data = Vec::with_capacity((self.w * self.h * 4) as usize);
for p in self.data.as_chunks::<3>().0 {
data.extend_from_slice(&[p[0], p[1], p[2], 255]);
}
Raster {
w: self.w,
h: self.h,
ch: 4,
data,
}
}
pub fn flatten(&self, bg: [u8; 3]) -> Raster {
if self.ch != 4 {
return self.clone();
}
let mut data = Vec::with_capacity((self.w * self.h * 3) as usize);
for p in self.data.as_chunks::<4>().0 {
let a = p[3] as u32;
for c in 0..3 {
let v = (p[c] as u32 * a + bg[c] as u32 * (255 - a) + 127) / 255;
data.push(v as u8);
}
}
Raster {
w: self.w,
h: self.h,
ch: 3,
data,
}
}
pub fn rotate90(&self) -> Raster {
let (w, h, ch) = (self.w as usize, self.h as usize, self.ch as usize);
let mut data = vec![0u8; self.data.len()];
for y in 0..h {
for x in 0..w {
let src = (y * w + x) * ch;
let dst = (x * h + (h - 1 - y)) * ch;
data[dst..dst + ch].copy_from_slice(&self.data[src..src + ch]);
}
}
Raster {
w: self.h,
h: self.w,
ch: self.ch,
data,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Filter {
Nearest,
Bilinear,
Lanczos,
}
pub fn resize_region(
src: &Raster,
crop: (f64, f64, f64, f64),
dw: u32,
dh: u32,
filter: Filter,
) -> Raster {
let dw = dw.max(1);
let dh = dh.max(1);
let pt = if src.ch == 4 {
fr::PixelType::U8x4
} else {
fr::PixelType::U8x3
};
let Ok(src_ref) = fr::images::ImageRef::new(src.w, src.h, &src.data, pt) else {
return Raster {
w: dw,
h: dh,
ch: src.ch,
data: vec![0; (dw * dh * src.ch as u32) as usize],
};
};
let mut dst = fr::images::Image::new(dw, dh, pt);
let alg = match filter {
Filter::Nearest => fr::ResizeAlg::Nearest,
Filter::Bilinear => fr::ResizeAlg::Convolution(fr::FilterType::Bilinear),
Filter::Lanczos => fr::ResizeAlg::Convolution(fr::FilterType::Lanczos3),
};
let (cx, cy, cw, chh) = crop;
let opts = fr::ResizeOptions::new().resize_alg(alg).crop(
cx.max(0.0),
cy.max(0.0),
cw.max(1e-3),
chh.max(1e-3),
);
let mut resizer = fr::Resizer::new();
if resizer.resize(&src_ref, &mut dst, &opts).is_err() {
return Raster {
w: dw,
h: dh,
ch: src.ch,
data: vec![0; (dw * dh * src.ch as u32) as usize],
};
}
Raster {
w: dw,
h: dh,
ch: src.ch,
data: dst.into_vec(),
}
}
pub fn resize(src: &Raster, dw: u32, dh: u32) -> Raster {
if src.w == dw && src.h == dh {
return src.clone();
}
resize_region(
src,
(0.0, 0.0, src.w as f64, src.h as f64),
dw,
dh,
Filter::Lanczos,
)
}
pub struct Frame {
pub raster: Raster,
pub delay: Duration,
}
pub struct Picture {
pub frames: Vec<Frame>,
pub svg: Option<Arc<resvg::usvg::Tree>>,
pub width: u32,
pub height: u32,
pub format: String,
}
impl Picture {
pub fn is_animated(&self) -> bool {
self.frames.len() > 1
}
pub fn render(&self, w: u32, h: u32) -> Raster {
if let Some(tree) = &self.svg {
return render_svg(
tree,
(0.0, 0.0, self.width as f64, self.height as f64),
w,
h,
);
}
resize(&self.frames[0].raster, w, h)
}
}
fn fontdb() -> Arc<resvg::usvg::fontdb::Database> {
use std::sync::OnceLock;
static DB: OnceLock<Arc<resvg::usvg::fontdb::Database>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = resvg::usvg::fontdb::Database::new();
db.load_system_fonts();
crate::app::trace(format!("system fonts loaded ({} faces)", db.len()));
Arc::new(db)
})
.clone()
}
const UI_FONT_FILES: &[&str] = &[
"/System/Library/Fonts/HelveticaNeue.ttc",
"/System/Library/Fonts/Apple Color Emoji.ttc",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-SemiBold.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/noto/NotoSans-SemiBold.ttf",
"/usr/share/fonts/noto/NotoSans-Bold.ttf",
"/usr/share/fonts/google-noto/NotoSans-Regular.ttf",
"/usr/share/fonts/google-noto/NotoSans-SemiBold.ttf",
"/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
"/usr/share/fonts/noto/NotoColorEmoji.ttf",
"/usr/share/fonts/google-noto-emoji/NotoColorEmoji.ttf",
"/usr/share/fonts/google-noto-color-emoji-fonts/NotoColorEmoji.ttf",
];
fn ui_fontdb() -> Option<Arc<resvg::usvg::fontdb::Database>> {
use std::sync::OnceLock;
static DB: OnceLock<Option<Arc<resvg::usvg::fontdb::Database>>> = OnceLock::new();
DB.get_or_init(|| {
let mut db = resvg::usvg::fontdb::Database::new();
for f in UI_FONT_FILES {
if Path::new(f).exists() {
let _ = db.load_font_file(f);
}
}
let family = db.faces().next()?.families.first()?.0.clone();
db.set_sans_serif_family(&family);
db.set_serif_family(&family);
db.set_monospace_family(&family);
crate::app::trace(format!("ui fonts loaded ({} faces)", db.len()));
Some(Arc::new(db))
})
.clone()
}
fn ui_fonts_cover(text: &str) -> bool {
text.chars().all(|c| {
c <= '\u{024F}'
|| ('\u{2000}'..='\u{2BFF}').contains(&c)
|| c >= '\u{1F000}'
|| matches!(c, '\u{FE0F}' | '\u{200D}' | '\u{20E3}')
})
}
fn ui_db_for(text: &str) -> Arc<resvg::usvg::fontdb::Database> {
match ui_fontdb() {
Some(db) if ui_fonts_cover(text) => db,
_ => fontdb(),
}
}
fn svg_has_text(data: &[u8]) -> bool {
let has = |needle: &[u8]| data.windows(needle.len()).any(|w| w == needle);
data.starts_with(&[0x1f, 0x8b]) || has(b"<text") || has(b"<image")
}
pub fn parse_svg(data: &[u8], resources_dir: Option<&Path>) -> Result<resvg::usvg::Tree> {
let fonts = if svg_has_text(data) {
fontdb()
} else {
Arc::default()
};
parse_svg_with(data, resources_dir, fonts)
}
fn parse_svg_with(
data: &[u8],
resources_dir: Option<&Path>,
fontdb: Arc<resvg::usvg::fontdb::Database>,
) -> Result<resvg::usvg::Tree> {
let opt = resvg::usvg::Options {
resources_dir: resources_dir.map(|p| p.to_path_buf()),
fontdb,
..Default::default()
};
resvg::usvg::Tree::from_data(data, &opt).map_err(|e| anyhow!("invalid SVG: {e}"))
}
pub fn render_svg(
tree: &resvg::usvg::Tree,
crop: (f64, f64, f64, f64),
dw: u32,
dh: u32,
) -> Raster {
let dw = dw.max(1);
let dh = dh.max(1);
let Some(mut pixmap) = resvg::tiny_skia::Pixmap::new(dw, dh) else {
return Raster {
w: dw,
h: dh,
ch: 4,
data: vec![0; (dw * dh * 4) as usize],
};
};
let sx = dw as f64 / crop.2.max(1e-6);
let sy = dh as f64 / crop.3.max(1e-6);
let ts = resvg::tiny_skia::Transform::from_row(
sx as f32,
0.0,
0.0,
sy as f32,
(-crop.0 * sx) as f32,
(-crop.1 * sy) as f32,
);
resvg::render(tree, ts, &mut pixmap.as_mut());
let mut data = pixmap.take();
for p in data.as_chunks_mut::<4>().0 {
let a = p[3] as u32;
if a != 0 && a != 255 {
p[0] = ((p[0] as u32 * 255 + a / 2) / a).min(255) as u8;
p[1] = ((p[1] as u32 * 255 + a / 2) / a).min(255) as u8;
p[2] = ((p[2] as u32 * 255 + a / 2) / a).min(255) as u8;
}
}
Raster {
w: dw,
h: dh,
ch: 4,
data,
}
}
fn looks_like_svg(data: &[u8]) -> bool {
let head = &data[..data.len().min(1024)];
let s = String::from_utf8_lossy(head).to_ascii_lowercase();
s.contains("<svg") || (s.trim_start().starts_with("<?xml") && s.contains("svg"))
}
fn svg_picture(tree: resvg::usvg::Tree) -> Picture {
let size = tree.size();
Picture {
frames: Vec::new(),
width: size.width().ceil().max(1.0) as u32,
height: size.height().ceil().max(1.0) as u32,
svg: Some(Arc::new(tree)),
format: "SVG".into(),
}
}
pub fn decode_generated_svg(svg: &str) -> Result<Picture> {
Ok(svg_picture(parse_svg_with(
svg.as_bytes(),
None,
ui_db_for(svg),
)?))
}
pub fn decode(data: &[u8], hint: &str, resources_dir: Option<&Path>) -> Result<Picture> {
let lower = hint.to_ascii_lowercase();
let is_svg_ext = lower.ends_with(".svg") || lower.ends_with(".svgz");
if is_svg_ext || looks_like_svg(data) {
return Ok(svg_picture(parse_svg(data, resources_dir)?));
}
let guessed = image::guess_format(data)
.ok()
.or_else(|| ImageFormat::from_path(hint).ok());
match guessed {
Some(fmt) => {
decode_raster(data, fmt).or_else(|e| decode_with_ffmpeg_bytes(hint).map_err(|_| e))
}
None => decode_with_ffmpeg_bytes(hint),
}
}
fn decode_raster(data: &[u8], fmt: ImageFormat) -> Result<Picture> {
let format = format!("{fmt:?}").to_uppercase();
let frames: Option<Vec<image::Frame>> = match fmt {
ImageFormat::Gif => image::codecs::gif::GifDecoder::new(Cursor::new(data))
.ok()
.and_then(|d| d.into_frames().collect_frames().ok()),
ImageFormat::WebP => image::codecs::webp::WebPDecoder::new(Cursor::new(data))
.ok()
.filter(|d| d.has_animation())
.and_then(|d| d.into_frames().collect_frames().ok()),
ImageFormat::Png => image::codecs::png::PngDecoder::new(Cursor::new(data))
.ok()
.filter(|d| d.is_apng().unwrap_or(false))
.and_then(|d| d.apng().ok())
.and_then(|d| d.into_frames().collect_frames().ok()),
_ => None,
};
if let Some(frames) = frames.filter(|f| f.len() > 1) {
let (w, h) = frames[0].buffer().dimensions();
let frames = frames
.into_iter()
.map(|f| {
let (n, d) = f.delay().numer_denom_ms();
let ms = n.checked_div(d).map(|v| v.max(20)).unwrap_or(100);
Frame {
delay: Duration::from_millis(ms as u64),
raster: Raster::from_dynamic(DynamicImage::ImageRgba8(f.into_buffer())),
}
})
.collect();
return Ok(Picture {
frames,
svg: None,
width: w,
height: h,
format,
});
}
let mut reader = ImageReader::with_format(Cursor::new(data), fmt);
reader.no_limits();
let mut decoder = reader.into_decoder().context("unsupported image")?;
let orientation = decoder.orientation().ok();
let mut img = DynamicImage::from_decoder(decoder).context("failed to decode image")?;
if let Some(o) = orientation {
img.apply_orientation(o);
}
let raster = Raster::from_dynamic(img);
Ok(Picture {
width: raster.w,
height: raster.h,
frames: vec![Frame {
raster,
delay: Duration::ZERO,
}],
svg: None,
format,
})
}
fn decode_with_ffmpeg_bytes(path: &str) -> Result<Picture> {
if path.is_empty() || path == "-" {
return Err(anyhow!("unsupported image format"));
}
let ffmpeg = crate::fetch::tool("ffmpeg").ok_or_else(|| {
anyhow!("unsupported image format (install ffmpeg for HEIC/AVIF/JXL support)")
})?;
let out = std::process::Command::new(ffmpeg)
.args([
"-v",
"error",
"-nostdin",
"-i",
path,
"-frames:v",
"1",
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
])
.stdin(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| {
anyhow!("unsupported image format (install ffmpeg for HEIC/AVIF/JXL support)")
})?;
if !out.status.success() || out.stdout.is_empty() {
return Err(anyhow!("unsupported image format"));
}
let ext = Path::new(path)
.extension()
.map(|e| e.to_string_lossy().to_uppercase())
.unwrap_or_else(|| "IMAGE".into());
let mut pic = decode_raster(&out.stdout, ImageFormat::Png)?;
pic.format = ext;
Ok(pic)
}
pub fn compose_row(items: &[Arc<Raster>], gap: u32) -> Raster {
let h = items.iter().map(|r| r.h).max().unwrap_or(1).max(1);
let w = items.iter().map(|r| r.w).sum::<u32>() + gap * items.len().saturating_sub(1) as u32;
let w = w.max(1);
let mut data = vec![0u8; (w * h * 4) as usize];
let mut x0 = 0u32;
for it in items {
let it = it.to_rgba();
let y0 = (h - it.h) / 2;
for y in 0..it.h {
let src = &it.data[(y * it.w * 4) as usize..((y + 1) * it.w * 4) as usize];
let off = (((y0 + y) * w + x0) * 4) as usize;
data[off..off + src.len()].copy_from_slice(src);
}
x0 += it.w + gap;
}
Raster { w, h, ch: 4, data }
}
const HEADING_FONTS: &str = "'Helvetica Neue', Helvetica, 'Segoe UI', 'Noto Sans', 'Liberation Sans', 'DejaVu Sans', Arial, sans-serif";
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
fn text_line(
text: &str,
size: f64,
h: u32,
color: (u8, u8, u8),
) -> Option<(resvg::usvg::Tree, f64)> {
let baseline = h as f64 / 2.0 + size * 0.36;
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="20000" height="{h}"><text x="0" y="{baseline:.2}" font-family="{HEADING_FONTS}" font-weight="600" font-size="{size:.2}" fill="#{:02x}{:02x}{:02x}">{}</text></svg>"##,
color.0,
color.1,
color.2,
xml_escape(text)
);
let tree = parse_svg_with(svg.as_bytes(), None, ui_db_for(text)).ok()?;
let bb = tree.root().bounding_box()?;
(bb.width() > 0.0).then(|| (tree, bb.right() as f64))
}
pub fn typeset(
text: &str,
size: f64,
h: u32,
max_w: f64,
color: (u8, u8, u8),
) -> Option<Vec<(Raster, String)>> {
let draw = |(tree, w): (resvg::usvg::Tree, f64)| {
let w = w.ceil() + 2.0;
render_svg(&tree, (0.0, 0.0, w, h as f64), w as u32, h)
};
let whole = text_line(text, size, h, color)?;
if whole.1 <= max_w {
return Some(vec![(draw(whole), text.to_string())]);
}
let width = |t: &str| text_line(t, size, h, color).map(|l| l.1);
let space = (width("x x")? - 2.0 * width("x")?).max(0.0);
let mut lines: Vec<String> = Vec::new();
let (mut cur, mut cur_w) = (String::new(), 0.0);
for word in text.split(' ') {
let w = width(word)?;
if !cur.is_empty() && cur_w + space + w > max_w {
lines.push(std::mem::take(&mut cur));
}
if cur.is_empty() {
cur_w = w;
} else {
cur.push(' ');
cur_w += space + w;
}
cur.push_str(word);
}
lines.push(cur);
lines
.into_iter()
.map(|l| Some((draw(text_line(&l, size, h, color)?), l)))
.collect()
}
pub fn checkbox(
checked: bool,
w: u32,
h: u32,
accent: (u8, u8, u8),
border: (u8, u8, u8),
) -> Raster {
let body = h as f64 / 1.22;
let size = (body * 0.82).round().max(6.0);
let x = (w as f64 * 0.08).round();
let y = ((h as f64 - size) / 2.0).round();
let r = (size * 0.2).max(1.5);
let stroke = (size / 11.0).max(1.0);
let hex = |c: (u8, u8, u8)| format!("#{:02x}{:02x}{:02x}", c.0, c.1, c.2);
let shape = if checked {
let (x0, y0, s) = (x, y, size);
format!(
r#"<rect x="{x0}" y="{y0}" width="{s}" height="{s}" rx="{r}" fill="{a}"/><path d="M {} {} L {} {} L {} {}" fill="none" stroke="white" stroke-width="{}" stroke-linecap="round" stroke-linejoin="round"/>"#,
x0 + s * 0.24,
y0 + s * 0.52,
x0 + s * 0.43,
y0 + s * 0.70,
x0 + s * 0.77,
y0 + s * 0.32,
s * 0.13,
a = hex(accent)
)
} else {
let half = stroke / 2.0;
format!(
r#"<rect x="{}" y="{}" width="{}" height="{}" rx="{r}" fill="none" stroke="{}" stroke-width="{stroke}"/>"#,
x + half,
y + half,
size - stroke,
size - stroke,
hex(border)
)
};
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}">{shape}</svg>"#
);
match parse_svg(svg.as_bytes(), None) {
Ok(tree) => render_svg(&tree, (0.0, 0.0, w as f64, h as f64), w, h),
Err(_) => Raster {
w,
h,
ch: 4,
data: vec![0; (w * h * 4) as usize],
},
}
}
pub fn warm_fonts() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
std::thread::spawn(fontdb);
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flatten_composites_over_background() {
let r = Raster {
w: 3,
h: 1,
ch: 4,
data: vec![255, 0, 0, 255, 0, 0, 255, 0, 0, 0, 0, 128],
};
let white = r.flatten([255, 255, 255]);
assert_eq!(white.ch, 3);
assert_eq!(white.data, vec![255, 0, 0, 255, 255, 255, 127, 127, 127]);
let black = r.flatten([0, 0, 0]);
assert_eq!(black.data, vec![255, 0, 0, 0, 0, 0, 0, 0, 0]);
}
#[test]
fn heading_font_coverage() {
assert!(ui_fonts_cover("Roadmap — v1.0 🚀 ✓ Ünïcödé"));
assert!(!ui_fonts_cover("路线图"));
assert!(svg_has_text(b"<svg><text>a</text></svg>"));
assert!(!svg_has_text(b"<svg><rect/></svg>"));
}
#[test]
fn resize_and_rotate() {
let r = Raster {
w: 4,
h: 2,
ch: 3,
data: (0..24).map(|v| v as u8).collect(),
};
let rot = r.rotate90();
assert_eq!((rot.w, rot.h), (2, 4));
let s = resize(&r, 8, 4);
assert_eq!((s.w, s.h, s.data.len()), (8, 4, 96));
}
#[test]
fn decodes_svg() {
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="red"/></svg>"#;
let pic = decode(svg, "x.svg", None).unwrap();
assert_eq!((pic.width, pic.height), (20, 10));
let r = pic.render(40, 20);
assert_eq!(&r.data[0..4], &[255, 0, 0, 255]);
}
}