use anyhow::Result;
use base64::{engine::general_purpose, Engine};
use serde::{Deserialize, Serialize};
use std::path::Path;
use skia_safe::{
Canvas, Color, Data, EncodedImageFormat, Font,
FontMgr, FontStyle, Image, Paint, Path as SkPath, Point, Rect,
TextBlob,
textlayout::{FontCollection, ParagraphBuilder, ParagraphStyle, TextAlign, TextDirection, TextStyle}
};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PosterError {
#[error("Failed to load image: {0}")]
ImageLoadError(String),
#[error("Failed to render element: {0}")]
RenderError(String),
#[error("Failed to generate output: {0}")]
OutputError(String),
}
#[derive(Debug, Deserialize, Serialize)]
pub struct PosterConfig {
pub width: u32,
pub height: u32,
pub background_color: String,
pub elements: Vec<Element>,
}
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "type")]
pub enum Element {
#[serde(rename = "background")]
Background(BackgroundElement),
#[serde(rename = "image")]
Image(ImageElement),
#[serde(rename = "text")]
Text(TextElement),
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct BackgroundElement {
pub image: Option<String>,
pub color: String,
pub radius: Option<Radius>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ImageElement {
pub src: String,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub radius: Option<Radius>,
pub z_index: Option<i32>,
#[serde(default = "default_object_fit")]
pub object_fit: ObjectFit,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TextElement {
pub text: String,
pub x: f32,
pub y: f32,
pub font_size: f32,
pub color: String,
#[serde(default = "default_text_align")]
pub align: TextAlignType,
pub font_family: Option<String>,
pub font_file: Option<String>,
pub max_width: Option<f32>,
#[serde(default = "default_line_height")]
pub line_height: f32,
pub max_lines: Option<u32>,
pub z_index: Option<i32>,
#[serde(default = "default_bold")]
pub bold: bool,
pub prefix: Option<String>,
pub background_color: Option<String>,
#[serde(default = "default_padding")]
pub padding: f32,
pub border_radius: Option<Radius>,
pub width: Option<f32>,
pub height: Option<f32>,
#[serde(default = "default_text_direction")]
pub direction: TextDirectionType,
}
impl Default for TextElement {
fn default() -> Self {
Self {
text: String::new(),
x: 0.0,
y: 0.0,
font_size: 16.0,
color: "#000000".to_string(),
align: TextAlignType::Left,
font_family: None,
font_file: None,
max_width: None,
line_height: 1.5,
max_lines: None,
z_index: None,
bold: false,
prefix: None,
background_color: None,
padding: 0.0,
border_radius: None,
width: None,
height: None,
direction: TextDirectionType::Ltr,
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(untagged)]
pub enum Radius {
Single(f32),
Multiple([f32; 4]),
}
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ObjectFit {
Cover,
Contain,
Stretch,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TextAlignType {
Left,
Center,
Right,
}
#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum TextDirectionType {
Ltr,
Rtl,
}
fn is_rtl_text(text: &str) -> bool {
text.chars().any(|c| {
let code = c as u32;
(code >= 0x0600 && code <= 0x06FF) ||
(code >= 0x0750 && code <= 0x077F) ||
(code >= 0x08A0 && code <= 0x08FF) ||
(code >= 0xFB50 && code <= 0xFDFF) ||
(code >= 0xFE70 && code <= 0xFEFF) ||
(code >= 0x0590 && code <= 0x05FF) })
}
fn load_font_from_file(font_path: &str, font_size: f32) -> Option<Font> {
use std::path::Path as StdPath;
let paths_to_try = vec![
font_path.to_string(), format!("./{}", font_path), format!("../{}", font_path), ];
for try_path in &paths_to_try {
if !StdPath::new(try_path).exists() {
continue;
}
if let Ok(font_bytes) = std::fs::read(try_path) {
let font_data = Data::new_copy(&font_bytes);
let font_mgr = FontMgr::new();
if let Some(typeface) = font_mgr.new_from_data(&font_data, None) {
return Some(Font::from_typeface(typeface, font_size));
}
}
}
None
}
fn get_font_for_text_with_family(_text: &str, font_size: f32, bold: bool, font_family: Option<&str>, font_file: Option<&str>) -> Font {
let font_mgr = FontMgr::default();
let weight = if bold {
skia_safe::font_style::Weight::BOLD
} else {
skia_safe::font_style::Weight::NORMAL
};
let font_style = FontStyle::new(weight, skia_safe::font_style::Width::NORMAL, skia_safe::font_style::Slant::Upright);
if let Some(file_path) = font_file {
if let Some(font) = load_font_from_file(file_path, font_size) {
return font;
}
}
if let Some(family) = font_family {
if let Some(typeface) = font_mgr.match_family_style(family, font_style) {
return Font::new(typeface, font_size);
}
}
let default_fonts = vec![
"Arial Unicode MS", "Arial",
"Helvetica",
"Times New Roman",
];
for family in default_fonts {
if let Some(typeface) = font_mgr.match_family_style(family, font_style) {
return Font::new(typeface, font_size);
}
}
let font_mgr = FontMgr::default();
if let Some(typeface) = font_mgr.legacy_make_typeface(None, FontStyle::normal()) {
Font::new(typeface, font_size)
} else {
let system_mgr = FontMgr::new();
if let Some(default_typeface) = system_mgr.legacy_make_typeface(None, FontStyle::normal()) {
Font::new(default_typeface, font_size)
} else {
Font::default()
}
}
}
fn default_object_fit() -> ObjectFit {
ObjectFit::Cover
}
fn default_text_align() -> TextAlignType {
TextAlignType::Left
}
fn default_line_height() -> f32 {
1.5
}
fn default_bold() -> bool {
false
}
fn default_padding() -> f32 {
0.0
}
fn default_text_direction() -> TextDirectionType {
TextDirectionType::Ltr
}
pub struct PosterGenerator {
width: u32,
height: u32,
background_color: String,
elements: Vec<Box<dyn PosterElement>>,
}
trait PosterElement {
fn z_index(&self) -> i32;
fn render(&self, canvas: &Canvas) -> Result<()>;
}
impl PosterElement for BackgroundElement {
fn z_index(&self) -> i32 {
-1000 }
fn render(&self, canvas: &Canvas) -> Result<()> {
let color = parse_color(&self.color);
let mut paint = Paint::default();
paint.set_color(color);
paint.set_anti_alias(true);
let width = canvas.base_layer_size().width;
let height = canvas.base_layer_size().height;
if let Some(radius) = &self.radius {
let path = create_rounded_rect_path(0.0, 0.0, width as f32, height as f32, radius);
canvas.draw_path(&path, &paint);
} else {
canvas.clear(color);
}
if let Some(img_path) = &self.image {
if let Ok(img) = load_image(img_path) {
let scaled_img = scale_image(img, width as f32, height as f32, &ObjectFit::Cover)?;
if let Some(radius) = &self.radius {
canvas.save();
let path = create_rounded_rect_path(0.0, 0.0, width as f32, height as f32, radius);
canvas.clip_path(&path, None, Some(true));
canvas.draw_image(scaled_img, Point::new(0.0, 0.0), None);
canvas.restore();
} else {
canvas.draw_image(scaled_img, Point::new(0.0, 0.0), None);
}
}
}
Ok(())
}
}
impl PosterElement for ImageElement {
fn z_index(&self) -> i32 {
self.z_index.unwrap_or(0)
}
fn render(&self, canvas: &Canvas) -> Result<()> {
let img = load_image(&self.src)?;
let scaled_img = scale_image(
img,
self.width,
self.height,
&self.object_fit,
)?;
if let Some(radius) = &self.radius {
canvas.save();
let path = create_rounded_rect_path(
self.x,
self.y,
self.width,
self.height,
radius,
);
canvas.clip_path(&path, None, Some(true));
canvas.draw_image(scaled_img, Point::new(self.x, self.y), None);
canvas.restore();
} else {
canvas.draw_image(scaled_img, Point::new(self.x, self.y), None);
}
Ok(())
}
}
impl PosterElement for TextElement {
fn z_index(&self) -> i32 {
self.z_index.unwrap_or(0)
}
fn render(&self, canvas: &Canvas) -> Result<()> {
let color = parse_color(&self.color);
let full_text = match &self.prefix {
Some(prefix) => format!("{}{}", prefix, self.text),
None => self.text.clone(),
};
let text_direction = match self.direction {
TextDirectionType::Rtl => TextDirectionType::Rtl,
TextDirectionType::Ltr => {
if is_rtl_text(&full_text) {
TextDirectionType::Rtl
} else {
TextDirectionType::Ltr
}
}
};
let font = get_font_for_text_with_family(&full_text, self.font_size, self.bold, self.font_family.as_deref(), self.font_file.as_deref());
self.render_with_text_layout(canvas, &full_text, &text_direction, &font, color)?;
Ok(())
}
}
impl TextElement {
fn render_with_text_layout(&self, canvas: &Canvas, full_text: &str, text_direction: &TextDirectionType, font: &Font, color: Color) -> Result<()> {
let mut paint = Paint::default();
paint.set_color(color);
paint.set_anti_alias(true);
let processed_text = if matches!(text_direction, TextDirectionType::Rtl) {
self.process_rtl_text(full_text)
} else {
full_text.to_string()
};
let has_manual_newlines = processed_text.contains('\n');
let lines: Vec<String> = if has_manual_newlines && self.max_width.is_some() {
let max_width = self.max_width.unwrap();
let mut all_lines = Vec::new();
for manual_line in processed_text.split('\n') {
let wrapped_lines = break_text_rtl(manual_line, max_width, font, None);
all_lines.extend(wrapped_lines);
}
if let Some(max) = self.max_lines {
all_lines.truncate(max as usize);
}
all_lines
} else if has_manual_newlines {
let mut lines: Vec<String> = processed_text.split('\n').map(|s| s.to_string()).collect();
if let Some(max) = self.max_lines {
lines.truncate(max as usize);
}
lines
} else if let Some(max_width) = self.max_width {
break_text_rtl(&processed_text, max_width, font, self.max_lines)
} else {
vec![processed_text.clone()]
};
if let Some(bg_color_str) = &self.background_color {
let bg_color = parse_color(bg_color_str);
let mut bg_paint = Paint::default();
bg_paint.set_color(bg_color);
let (_line_spacing, metrics) = font.metrics();
let ascent = -metrics.ascent; let descent = metrics.descent; let single_line_height = ascent + descent;
let max_line_width = lines.iter()
.map(|line| measure_text_with_font(line, font).0)
.max_by(|a, b| a.partial_cmp(b).unwrap())
.unwrap_or(0.0);
let total_text_height = if lines.len() > 1 {
single_line_height + (lines.len() - 1) as f32 * self.font_size * self.line_height
} else {
single_line_height
};
let bg_width = self.width.unwrap_or_else(|| max_line_width + self.padding * 2.0);
let bg_height = self.height.unwrap_or_else(|| total_text_height + self.padding * 2.0);
let bg_x = match (self.align, text_direction) {
(TextAlignType::Left, TextDirectionType::Ltr) => self.x - self.padding,
(TextAlignType::Right, TextDirectionType::Ltr) => self.x - bg_width + self.padding,
(TextAlignType::Center, _) => self.x - bg_width / 2.0,
(TextAlignType::Left, TextDirectionType::Rtl) => self.x - bg_width + self.padding,
(TextAlignType::Right, TextDirectionType::Rtl) => self.x - self.padding,
};
let bg_y = self.y - ascent - self.padding;
if let Some(radius) = &self.border_radius {
let path = create_rounded_rect_path(bg_x, bg_y, bg_width, bg_height, radius);
canvas.draw_path(&path, &bg_paint);
} else {
let rect = Rect::new(bg_x, bg_y, bg_x + bg_width, bg_y + bg_height);
canvas.draw_rect(rect, &bg_paint);
}
}
for (i, line) in lines.iter().enumerate() {
let y_pos = self.y + (i as f32 * self.font_size * self.line_height);
draw_text_line_improved(canvas, line, self.x, y_pos, font, &paint, text_direction, &self.align);
}
Ok(())
}
fn process_rtl_text(&self, text: &str) -> String {
text.to_string()
}
}
impl PosterGenerator {
pub fn new(width: u32, height: u32, background_color: String) -> Self {
Self {
width,
height,
background_color,
elements: Vec::new(),
}
}
pub fn add_background(&mut self, background: BackgroundElement) -> &mut Self {
self.elements.push(Box::new(background));
self
}
pub fn add_image(&mut self, image: ImageElement) -> &mut Self {
self.elements.push(Box::new(image));
self
}
pub fn add_text(&mut self, text: TextElement) -> &mut Self {
self.elements.push(Box::new(text));
self
}
pub fn clear(&mut self) -> &mut Self {
self.elements.clear();
self
}
pub fn set_elements(&mut self, elements: Vec<Element>) -> &mut Self {
self.clear();
for element in elements {
match element {
Element::Background(bg) => self.add_background(bg),
Element::Image(img) => self.add_image(img),
Element::Text(txt) => self.add_text(txt),
};
}
self
}
pub fn generate(&self) -> Result<Vec<u8>> {
let mut surface = skia_safe::surfaces::raster_n32_premul((self.width as i32, self.height as i32)).ok_or_else(|| {
PosterError::RenderError("Failed to create surface".to_string())
})?;
{
let canvas = surface.canvas();
let bg_color = parse_color(&self.background_color);
canvas.clear(bg_color);
let mut sorted_elements = self.elements.iter().collect::<Vec<_>>();
sorted_elements.sort_by_key(|e| e.z_index());
for element in sorted_elements {
element.render(canvas)?;
}
}
let image = surface.image_snapshot();
let data = image.encode_to_data(EncodedImageFormat::PNG).ok_or_else(|| {
PosterError::OutputError("Failed to encode image as PNG".to_string())
})?;
Ok(data.as_bytes().to_vec())
}
pub fn generate_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let png_data = self.generate()?;
std::fs::write(path, png_data)?;
Ok(())
}
pub fn generate_base64(&self) -> Result<String> {
let png_data = self.generate()?;
let base64 = general_purpose::STANDARD.encode(&png_data);
Ok(format!("data:image/png;base64,{}", base64))
}
}
fn parse_color(color_str: &str) -> Color {
if color_str.starts_with('#') {
let hex = &color_str[1..];
if hex.len() == 6 {
if let (Ok(r), Ok(g), Ok(b)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
u8::from_str_radix(&hex[4..6], 16),
) {
return Color::from_rgb(r, g, b);
}
} else if hex.len() == 8 {
if let (Ok(r), Ok(g), Ok(b), Ok(a)) = (
u8::from_str_radix(&hex[0..2], 16),
u8::from_str_radix(&hex[2..4], 16),
u8::from_str_radix(&hex[4..6], 16),
u8::from_str_radix(&hex[6..8], 16),
) {
return Color::from_argb(a, r, g, b);
}
}
}
Color::BLACK
}
fn load_image(path: &str) -> Result<Image> {
if path.starts_with("data:image/") {
let base64_data = path.split(',').nth(1).ok_or_else(|| {
PosterError::ImageLoadError("Invalid base64 image format".to_string())
})?;
let bytes = general_purpose::STANDARD.decode(base64_data)?;
let data = Data::new_copy(&bytes);
let image = Image::from_encoded(data).ok_or_else(|| {
PosterError::ImageLoadError("Failed to decode base64 image".to_string())
})?;
return Ok(image);
}
let bytes = std::fs::read(path)?;
let data = Data::new_copy(&bytes);
let image = Image::from_encoded(data).ok_or_else(|| {
PosterError::ImageLoadError(format!("Failed to load image from: {}", path))
})?;
Ok(image)
}
fn scale_image(img: Image, width: f32, height: f32, object_fit: &ObjectFit) -> Result<Image> {
let src_width = img.width() as f32;
let src_height = img.height() as f32;
let mut surface = match object_fit {
ObjectFit::Cover => {
let scale_x = width / src_width;
let scale_y = height / src_height;
let scale = scale_x.max(scale_y);
let scaled_width = (src_width * scale).ceil() as i32;
let scaled_height = (src_height * scale).ceil() as i32;
let mut surface = skia_safe::surfaces::raster_n32_premul((width as i32, height as i32)).ok_or_else(|| {
PosterError::RenderError("Failed to create surface for scaled image".to_string())
})?;
let canvas = surface.canvas();
let x = (width - scaled_width as f32) / 2.0;
let y = (height - scaled_height as f32) / 2.0;
let mut paint = Paint::default();
paint.set_anti_alias(true);
canvas.scale((scale, scale));
canvas.draw_image(img, Point::new(x / scale, y / scale), Some(&paint));
surface
},
ObjectFit::Contain => {
let scale_x = width / src_width;
let scale_y = height / src_height;
let scale = scale_x.min(scale_y);
let scaled_width = (src_width * scale) as i32;
let scaled_height = (src_height * scale) as i32;
let mut surface = skia_safe::surfaces::raster_n32_premul((width as i32, height as i32)).ok_or_else(|| {
PosterError::RenderError("Failed to create surface for scaled image".to_string())
})?;
let canvas = surface.canvas();
let x = (width - scaled_width as f32) / 2.0;
let y = (height - scaled_height as f32) / 2.0;
let mut paint = Paint::default();
paint.set_anti_alias(true);
let src_rect = Rect::new(0.0, 0.0, src_width, src_height);
let dest_rect = Rect::new(x, y, x + scaled_width as f32, y + scaled_height as f32);
canvas.draw_image_rect(img, Some((&src_rect, skia_safe::canvas::SrcRectConstraint::Fast)), dest_rect, &paint);
surface
},
ObjectFit::Stretch => {
let mut surface = skia_safe::surfaces::raster_n32_premul((width as i32, height as i32)).ok_or_else(|| {
PosterError::RenderError("Failed to create surface for stretched image".to_string())
})?;
let canvas = surface.canvas();
let src_rect = Rect::new(0.0, 0.0, src_width, src_height);
let dest_rect = Rect::new(0.0, 0.0, width, height);
let mut paint = Paint::default();
paint.set_anti_alias(true);
canvas.draw_image_rect(img, Some((&src_rect, skia_safe::canvas::SrcRectConstraint::Fast)), dest_rect, &paint);
surface
}
};
Ok(surface.image_snapshot())
}
fn create_rounded_rect_path(x: f32, y: f32, width: f32, height: f32, radius: &Radius) -> SkPath {
let mut path = SkPath::new();
match radius {
Radius::Single(r) => {
let r = r.min(width / 2.0).min(height / 2.0);
path.add_round_rect(
Rect::new(x, y, x + width, y + height),
(r, r),
None
);
},
Radius::Multiple(corners) => {
let tl = corners[0].min(width / 2.0).min(height / 2.0);
let tr = corners[1].min(width / 2.0).min(height / 2.0);
let br = corners[2].min(width / 2.0).min(height / 2.0);
let bl = corners[3].min(width / 2.0).min(height / 2.0);
path.move_to((x + tl, y));
path.line_to((x + width - tr, y));
if tr > 0.0 {
path.quad_to((x + width, y), (x + width, y + tr));
}
path.line_to((x + width, y + height - br));
if br > 0.0 {
path.quad_to((x + width, y + height), (x + width - br, y + height));
}
path.line_to((x + bl, y + height));
if bl > 0.0 {
path.quad_to((x, y + height), (x, y + height - bl));
}
path.line_to((x, y + tl));
if tl > 0.0 {
path.quad_to((x, y), (x + tl, y));
}
path.close();
}
}
path
}
fn measure_text_with_font(text: &str, font: &Font) -> (f32, f32) {
let blob = TextBlob::new(text, font).unwrap_or_else(|| {
TextBlob::new(" ", font).unwrap() });
let bounds = blob.bounds();
(bounds.width(), bounds.height())
}
fn break_text_rtl(text: &str, max_width: f32, font: &Font, max_lines: Option<u32>) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
let words: Vec<&str> = text.split_whitespace().collect();
for word in words {
let test_line = if current_line.is_empty() {
word.to_string()
} else {
format!("{} {}", current_line, word)
};
let (test_width, _) = measure_text_with_font(&test_line, font);
if test_width <= max_width || current_line.is_empty() {
current_line = test_line;
} else {
lines.push(current_line);
current_line = word.to_string();
if let Some(max) = max_lines {
if lines.len() >= max as usize - 1 {
break;
}
}
}
}
if !current_line.is_empty() {
if let Some(max) = max_lines {
if lines.len() >= max as usize {
let last_line = lines.last_mut().unwrap();
*last_line = truncate_with_ellipsis_rtl(last_line, max_width, font);
} else {
lines.push(current_line);
}
} else {
lines.push(current_line);
}
}
lines
}
fn truncate_with_ellipsis_rtl(text: &str, max_width: f32, font: &Font) -> String {
let ellipsis = if is_rtl_text(text) { "..." } else { "..." }; let (ellipsis_width, _) = measure_text_with_font(ellipsis, font);
let (text_width, _) = measure_text_with_font(text, font);
if text_width <= max_width {
return text.to_string();
}
let available_width = max_width - ellipsis_width;
let mut result = String::new();
for ch in text.chars() {
let test_text = format!("{}{}", result, ch);
let (test_width, _) = measure_text_with_font(&test_text, font);
if test_width <= available_width {
result.push(ch);
} else {
break;
}
}
format!("{}{}", result, ellipsis)
}
fn draw_text_line_improved(
canvas: &Canvas,
text: &str,
x: f32,
y: f32,
font: &Font,
paint: &Paint,
direction: &TextDirectionType,
align: &TextAlignType
) {
if matches!(direction, TextDirectionType::Rtl) && is_rtl_text(text) {
let mut paragraph_style = ParagraphStyle::new();
paragraph_style.set_text_direction(TextDirection::RTL);
let text_align = match align {
TextAlignType::Left => TextAlign::Left,
TextAlignType::Right => TextAlign::Right,
TextAlignType::Center => TextAlign::Center,
};
paragraph_style.set_text_align(text_align);
let font_mgr = FontMgr::default();
let mut font_collection = FontCollection::new();
font_collection.set_default_font_manager(font_mgr, None);
let mut paragraph_builder = ParagraphBuilder::new(¶graph_style, font_collection);
let mut text_style = TextStyle::new();
text_style.set_font_size(font.size());
text_style.set_color(paint.color());
let family_name = font.typeface().family_name();
text_style.set_font_families(&[family_name.as_str()]);
paragraph_builder.push_style(&text_style);
paragraph_builder.add_text(text);
let mut paragraph = paragraph_builder.build();
paragraph.layout(1000.0);
let draw_y = y - font.size();
let draw_x = if matches!(align, TextAlignType::Center) {
x - paragraph.max_width() / 2.0
} else {
x
};
paragraph.paint(canvas, Point::new(draw_x, draw_y));
} else {
if let Some(blob) = TextBlob::new(text, font) {
let (text_width, _) = measure_text_with_font(text, font);
let draw_x = match align {
TextAlignType::Left => x,
TextAlignType::Right => x - text_width,
TextAlignType::Center => x - text_width / 2.0,
};
canvas.draw_text_blob(blob, Point::new(draw_x, y), paint);
}
}
}