#![warn(clippy::all, clippy::pedantic)]
use crate::layout::{Layout, LayoutElement, LayoutRect};
use crate::numeric::{f32_to_i32, f32_to_u32, i32_to_u32, u32_to_i32};
use anyhow::{Context, Result};
use fontdue::{Font, FontSettings};
use image::{GenericImageView, Rgb, RgbImage};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
pub const DEFAULT_TOP_PADDING: u32 = 40;
pub const DEFAULT_LEFT_PADDING: u32 = 40;
pub const DEFAULT_FONT_SIZE: f32 = 40.0;
#[derive(Clone, Copy)]
pub(crate) struct FontPair<'a> {
pub(crate) main: &'a Font,
pub(crate) emoji: &'a Font,
}
impl<'a> FontPair<'a> {
pub(crate) fn get_font_for_char(&self, c: char) -> &'a Font {
let is_emoji = match c as u32 {
0x1F600..=0x1F64F |
0x1F680..=0x1F6FF |
0x1F300..=0x1F5FF |
0x1F900..=0x1F9FF |
0x1FA70..=0x1FAFF |
0x1F1E6..=0x1F1FF => true,
_ => false,
};
if is_emoji { self.emoji } else { self.main }
}
}
fn draw_text(
canvas: &mut RgbImage,
text: &str,
pos_x: i32,
pos_y: i32,
scale: f32,
fonts: FontPair,
color: Rgb<u8>,
) {
let px_scale = scale;
#[allow(clippy::cast_precision_loss)]
let mut cursor_x = pos_x as f32;
#[allow(clippy::cast_precision_loss)]
let cursor_y = pos_y as f32;
let mut glyphs = Vec::new();
for c in text.chars() {
let font = fonts.get_font_for_char(c);
let (metrics, bitmap) = font.rasterize(c, px_scale);
if c.is_whitespace() {
cursor_x += metrics.advance_width;
continue;
}
glyphs.push((c, font, metrics, bitmap, cursor_x, cursor_y));
cursor_x += metrics.advance_width;
}
for (_c, _font, metrics, bitmap, gx, gy) in glyphs {
#[allow(clippy::cast_possible_truncation)]
let x_pos = gx as i32 + metrics.xmin;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let y_pos = gy as i32 - metrics.ymin - metrics.height as i32;
for bitmap_y in 0..metrics.height {
for bitmap_x in 0..metrics.width {
let glyph_pixel = bitmap[bitmap_y * metrics.width + bitmap_x];
if glyph_pixel > 0 {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let canvas_x = x_pos + bitmap_x as i32;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let canvas_y = y_pos + bitmap_y as i32;
if canvas_x >= 0
&& canvas_y >= 0
&& {
#[allow(clippy::cast_possible_wrap)]
let comp = canvas_x < canvas.width() as i32;
comp
}
&& {
#[allow(clippy::cast_possible_wrap)]
let comp = canvas_y < canvas.height() as i32;
comp
}
{
let alpha = f32::from(glyph_pixel) / 255.0;
#[allow(clippy::cast_sign_loss)]
let pixel_x = canvas_x as u32;
#[allow(clippy::cast_sign_loss)]
let pixel_y = canvas_y as u32;
let existing_pixel = canvas.get_pixel(pixel_x, pixel_y);
let blended = {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let red = ((1.0 - alpha) * f32::from(existing_pixel[0])
+ alpha * f32::from(color[0]))
as u8;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let green = ((1.0 - alpha) * f32::from(existing_pixel[1])
+ alpha * f32::from(color[1]))
as u8;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let blue = ((1.0 - alpha) * f32::from(existing_pixel[2])
+ alpha * f32::from(color[2]))
as u8;
Rgb([red, green, blue])
};
canvas.put_pixel(pixel_x, pixel_y, blended);
}
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LabelAlignment {
Start,
Center,
End,
}
impl Default for LabelAlignment {
fn default() -> Self {
Self::Center
}
}
#[derive(Debug)]
pub struct PlotConfig {
pub images: Vec<PathBuf>,
pub output: PathBuf,
pub rows: u32,
pub row_labels: Vec<String>,
pub column_labels: Vec<String>,
pub column_label_alignment: LabelAlignment,
pub row_label_alignment: LabelAlignment,
pub debug_mode: bool,
pub top_padding: u32,
pub left_padding: u32,
pub font_size: Option<f32>,
}
impl Default for PlotConfig {
fn default() -> Self {
Self {
images: Vec::new(),
output: PathBuf::from("output.jpg"),
rows: 1,
row_labels: Vec::new(),
column_labels: Vec::new(),
column_label_alignment: LabelAlignment::default(),
row_label_alignment: LabelAlignment::default(),
debug_mode: false,
top_padding: DEFAULT_TOP_PADDING,
left_padding: DEFAULT_LEFT_PADDING,
font_size: None,
}
}
}
fn validate_plot_config(config: &PlotConfig) -> Result<u32> {
let PlotConfig {
images,
rows,
row_labels,
column_labels,
..
} = config;
if !row_labels.is_empty() && row_labels.len() != *rows as usize {
anyhow::bail!(
"Number of row labels ({}) should match the number of rows ({})",
row_labels.len(),
rows
);
}
let cols = u32::try_from(images.len())
.map_err(|_| anyhow::anyhow!("Too many images"))?
.div_ceil(*rows);
if !column_labels.is_empty() && column_labels.len() != cols as usize {
anyhow::bail!(
"Number of column labels ({}) should match the number of columns ({})",
column_labels.len(),
cols
);
}
Ok(cols)
}
pub(crate) fn load_fonts() -> FontPair<'static> {
static MAIN_FONT_DATA: &[u8] = include_bytes!("../assets/DejaVuSans.ttf");
static EMOJI_FONT_DATA: &[u8] = include_bytes!("../assets/NotoColorEmoji.ttf");
static MAIN_FONT: OnceLock<Font> = OnceLock::new();
static EMOJI_FONT: OnceLock<Font> = OnceLock::new();
let main_font = MAIN_FONT.get_or_init(|| {
Font::from_bytes(MAIN_FONT_DATA, FontSettings::default()).expect("Failed to load main font")
});
let emoji_font = EMOJI_FONT.get_or_init(|| {
Font::from_bytes(EMOJI_FONT_DATA, FontSettings::default())
.expect("Failed to load emoji font")
});
FontPair {
main: main_font,
emoji: emoji_font,
}
}
fn find_max_dimensions(images: &[PathBuf]) -> Result<(u32, u32)> {
let mut max_width = 0;
let mut max_height = 0;
for path in images {
if !path.exists() {
continue;
}
let img = image::open(path)
.with_context(|| format!("Failed to open image at {}", path.display()))?;
let dims = img.dimensions();
max_width = max_width.max(dims.0);
max_height = max_height.max(dims.1);
}
Ok((max_width, max_height))
}
pub(crate) fn calculate_label_width(label: &str, fonts: FontPair, scale: f32) -> f32 {
let mut width = 0.0;
for c in label.chars() {
let font = fonts.get_font_for_char(c);
let (metrics, _) = font.rasterize(c, scale);
width += metrics.advance_width;
}
width
}
fn calculate_label_dimensions(label: &str, fonts: FontPair, scale: f32) -> (f32, u32) {
let lines: Vec<&str> = label.split('\n').collect();
let mut max_width: f32 = 0.0;
let line_height = scale * 1.2;
#[allow(clippy::cast_precision_loss)]
let total_height = f32_to_u32(line_height * lines.len() as f32);
for line in lines {
let width = calculate_label_width(line, fonts, scale);
max_width = max_width.max(width);
}
(max_width, total_height)
}
fn draw_multiline_text(
canvas: &mut RgbImage,
text: &str,
pos_x: i32,
pos_y: i32,
scale: f32,
fonts: FontPair,
color: Rgb<u8>,
) {
let lines: Vec<&str> = text.split('\n').collect();
let line_height = scale * 1.2;
for (i, line) in lines.iter().enumerate() {
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
let y_offset = pos_y + (i as f32 * line_height) as i32;
draw_text(canvas, line, pos_x, y_offset, scale, fonts, color);
}
}
#[allow(clippy::too_many_lines)]
fn calculate_layout(config: &PlotConfig, max_width: u32, max_height: u32, cols: u32) -> Layout {
let has_labels = !config.row_labels.is_empty() || !config.column_labels.is_empty();
let fonts = load_fonts();
let font_size = config.font_size.unwrap_or(DEFAULT_FONT_SIZE);
let (max_row_label_width, _max_row_label_height) = if config.row_labels.is_empty() {
(0.0f32, 0)
} else {
config
.row_labels
.iter()
.map(|label| calculate_label_dimensions(label, fonts, font_size))
.fold((0.0f32, 0), |(w, h), (lw, lh)| (w.max(lw), h.max(lh)))
};
let (_max_col_label_width, max_col_label_height) = if config.column_labels.is_empty() {
(0.0f32, 0)
} else {
config
.column_labels
.iter()
.map(|label| calculate_label_dimensions(label, fonts, font_size))
.fold((0.0f32, 0), |(w, h), (lw, lh)| (w.max(lw), h.max(lh)))
};
let left_padding = if config.row_labels.is_empty() {
0
} else {
config
.left_padding
.max(i32_to_u32(f32_to_i32(max_row_label_width)) + 20)
};
let top_padding = if config.column_labels.is_empty() {
0
} else {
config.top_padding.max(max_col_label_height + 20)
};
let row_height = max_height + if has_labels { top_padding } else { 0 };
let canvas_height = row_height * config.rows + if has_labels { top_padding } else { 0 };
let canvas_width = max_width * cols + left_padding;
let mut layout = Layout::new(canvas_width, canvas_height);
if left_padding > 0 {
layout.add_element(LayoutElement::Padding {
rect: LayoutRect {
x: 0,
y: 0,
width: left_padding,
height: canvas_height,
},
description: "Left padding for row labels".to_string(),
});
}
if has_labels {
layout.add_element(LayoutElement::Padding {
rect: LayoutRect {
x: u32_to_i32(left_padding),
y: 0,
width: canvas_width - left_padding,
height: top_padding,
},
description: "Top padding for column labels".to_string(),
});
}
if !config.column_labels.is_empty() {
#[allow(clippy::cast_possible_truncation)]
for col in 0..cols.min(config.column_labels.len() as u32) {
let label = &config.column_labels[col as usize];
let cell_start = col * max_width + left_padding;
let (label_width, label_height) = calculate_label_dimensions(label, fonts, font_size);
let label_width_i32 = f32_to_i32(label_width);
let label_x = match config.column_label_alignment {
LabelAlignment::Start => u32_to_i32(cell_start),
LabelAlignment::Center => {
u32_to_i32(cell_start + max_width / 2) - label_width_i32 / 2
}
LabelAlignment::End => u32_to_i32(cell_start + max_width) - label_width_i32,
};
let label_y = u32_to_i32(top_padding / 2) - u32_to_i32(label_height / 2);
layout.add_element(LayoutElement::ColumnLabel {
rect: LayoutRect {
x: label_x,
y: label_y,
width: i32_to_u32(label_width_i32),
height: label_height,
},
text: label.clone(),
});
}
}
for (i, img_path) in config.images.iter().enumerate() {
let i = u32::try_from(i).unwrap_or(0);
let row = i / cols;
let col = i % cols;
let x_start = col * max_width + left_padding;
let y_start = row * row_height + top_padding;
if let Some(row_label) = config.row_labels.get(row as usize) {
let (label_width, label_height) =
calculate_label_dimensions(row_label, fonts, font_size);
let available_width = left_padding - 20;
let available_width_i32 = u32_to_i32(available_width);
let label_width_i32 = f32_to_i32(label_width);
let label_x = match config.row_label_alignment {
LabelAlignment::Start => 10,
LabelAlignment::Center => 10 + (available_width_i32 - label_width_i32) / 2,
LabelAlignment::End => 10 + available_width_i32 - label_width_i32,
};
layout.add_element(LayoutElement::RowLabel {
rect: LayoutRect {
x: label_x,
y: u32_to_i32(y_start + max_height / 2) - u32_to_i32(label_height / 2),
width: i32_to_u32(label_width_i32),
height: label_height,
},
text: row_label.clone(),
});
}
let img = image::open(img_path).unwrap().to_rgb8();
let (img_width, img_height) = img.dimensions();
let x_offset = (max_width - img_width) / 2;
let y_offset = (max_height - img_height) / 2;
layout.add_element(LayoutElement::Image {
rect: LayoutRect {
x: u32_to_i32(x_start + x_offset),
y: u32_to_i32(y_start + y_offset),
width: img_width,
height: img_height,
},
path: img_path.to_string_lossy().into_owned(),
});
}
layout
}
pub fn create_plot(config: &PlotConfig) -> Result<()> {
let cols = validate_plot_config(config)?;
let (max_width, max_height) = find_max_dimensions(&config.images)?;
let layout = calculate_layout(config, max_width, max_height, cols);
if config.debug_mode {
let debug_output = config.output.with_file_name(format!(
"{}_debug{}",
config.output.file_stem().unwrap().to_string_lossy(),
config
.output
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default()
));
layout
.render_debug()
.save(&debug_output)
.with_context(|| format!("Failed to save debug layout: {}", debug_output.display()))?;
}
let mut canvas = RgbImage::new(layout.total_width, layout.total_height);
for pixel in canvas.pixels_mut() {
*pixel = Rgb([255, 255, 255]);
}
let fonts = load_fonts();
let font_size = config.font_size.unwrap_or(DEFAULT_FONT_SIZE);
for element in layout.elements {
match element {
LayoutElement::Image { rect, path } => {
let img = image::open(Path::new(&path))?.to_rgb8();
for (x, y, pixel) in img.enumerate_pixels() {
let canvas_x = i32_to_u32(rect.x + u32_to_i32(x));
let canvas_y = i32_to_u32(rect.y + u32_to_i32(y));
if canvas_x < canvas.width() && canvas_y < canvas.height() {
canvas.put_pixel(canvas_x, canvas_y, *pixel);
}
}
}
LayoutElement::RowLabel { rect, text } | LayoutElement::ColumnLabel { rect, text } => {
draw_multiline_text(
&mut canvas,
&text,
rect.x,
rect.y,
font_size,
fonts,
Rgb([0, 0, 0]),
);
}
LayoutElement::Padding { .. } => {}
}
}
canvas
.save(&config.output)
.with_context(|| format!("Failed to save output image: {}", config.output.display()))?;
Ok(())
}