use crate::config::OgImageConfig;
use crate::template::{OgImageData, OgImageTemplate};
use thiserror::Error;
pub type OgImageResult<T> = Result<T, OgImageError>;
#[derive(Debug, Error)]
pub enum OgImageError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to load font: {0}")]
FontLoad(String),
#[error("Failed to encode image: {0}")]
Encode(String),
#[error("Invalid configuration: {0}")]
InvalidConfig(String),
}
pub struct OgImageGenerator {
config: OgImageConfig,
template: OgImageTemplate,
}
impl OgImageGenerator {
#[must_use]
pub fn new(config: OgImageConfig) -> Self {
Self { config, template: OgImageTemplate::default() }
}
#[must_use]
pub fn with_template(config: OgImageConfig, template: OgImageTemplate) -> Self {
Self { config, template }
}
#[must_use]
pub fn config(&self) -> &OgImageConfig {
&self.config
}
#[must_use]
pub fn template(&self) -> &OgImageTemplate {
&self.template
}
pub fn generate(&self, data: &OgImageData) -> OgImageResult<Vec<u8>> {
let _ = data;
Err(OgImageError::Encode("Image generation not yet implemented".to_string()))
}
pub fn generate_to_file(
&self,
data: &OgImageData,
output_path: &std::path::Path,
) -> OgImageResult<()> {
let bytes = self.generate(data)?;
std::fs::write(output_path, bytes)?;
Ok(())
}
#[must_use]
pub fn generate_svg(&self, data: &OgImageData) -> String {
let width = self.config.width;
let height = self.config.height;
let bg = &self.config.background_color;
let text_color = &self.config.text_color;
let font_family =
self.config.font_family.as_deref().unwrap_or("IBM Plex Sans, system-ui, sans-serif");
let brand = data
.site_name
.as_deref()
.filter(|name| !name.trim().is_empty())
.unwrap_or("Ox Content");
let is_brand_card = normalize_for_compare(&data.title) == normalize_for_compare(brand);
let hero_title =
if is_brand_card { "cargo doc for JavaScript".to_string() } else { data.title.clone() };
let hero_description = if is_brand_card {
"Rust-powered docs and high-performance Markdown tooling.".to_string()
} else {
data.description
.as_deref()
.filter(|description| !description.trim().is_empty())
.unwrap_or("Rust-powered docs and Markdown tooling.")
.to_string()
};
let title_lines = wrap_text_limited(&hero_title, 28, 2);
let title_line_height = u64::from(self.config.title_font_size) + 10;
let title_svg = title_lines.iter().enumerate().fold(String::new(), |mut acc, (i, line)| {
use std::fmt::Write;
let line_index = u64::try_from(i).unwrap_or(u64::MAX);
let y = 300_u64.saturating_add(line_index.saturating_mul(title_line_height));
let _ = write!(
acc,
r#"<text x="64" y="{y}" fill="{text_color}" font-size="{}" font-weight="700" letter-spacing="-3.8px" font-family="{font_family}">{}</text>"#,
self.config.title_font_size,
escape_xml(line)
);
acc
});
let description_lines = wrap_text_limited(&hero_description, 56, 2);
let title_line_offset =
u64::try_from(title_lines.len().saturating_sub(1)).unwrap_or(u64::MAX);
let description_start_y = 300_u64
.saturating_add(title_line_offset.saturating_mul(title_line_height))
.saturating_add(58);
let description_line_height = u64::from(self.config.description_font_size) + 14;
let description_svg =
description_lines
.iter()
.enumerate()
.fold(String::new(), |mut acc, (i, line)| {
use std::fmt::Write;
let line_index = u64::try_from(i).unwrap_or(u64::MAX);
let y = description_start_y
.saturating_add(line_index.saturating_mul(description_line_height));
let _ = write!(
acc,
r##"<text x="64" y="{y}" fill="#93a4c3" font-size="{}" font-family="{font_family}">{}</text>"##,
self.config.description_font_size,
escape_xml(line)
);
acc
});
format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect width="100%" height="100%" fill="{bg}"/>
<rect x="0.5" y="0.5" width="{border_width}" height="{border_height}" fill="none" stroke="#223252"/>
<rect width="100%" height="4" fill="#4f6fae"/>
<defs>
<linearGradient id="brand_mark_gradient" x1="138" y1="118" x2="360" y2="392" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#355cff"/>
<stop offset="100%" stop-color="#74c7ff"/>
</linearGradient>
</defs>
<g transform="translate(64 56) scale(1.5926)">
<text x="2" y="43" fill="#eff6ff" font-family="IBM Plex Sans, IBM Plex Mono, Avenir Next, Segoe UI, sans-serif" font-size="34" font-weight="700" letter-spacing="-1.4px">OXCONTENT</text>
<text x="213" y="43.5" fill="#eff6ff" font-family="IBM Plex Sans, IBM Plex Mono, Avenir Next, Segoe UI, sans-serif" font-size="40" font-weight="400">(</text>
<g transform="translate(216 9) scale(0.089) rotate(-7 256 256)">
<path d="M161 96H286C298 96 309 101 318 110L352 144C361 153 366 164 366 176V386C366 399 355 410 342 410H161C148 410 138 399 138 386V120C138 107 148 96 161 96Z" fill="url(#brand_mark_gradient)"/>
</g>
<text x="252" y="43.5" fill="#eff6ff" font-family="IBM Plex Sans, IBM Plex Mono, Avenir Next, Segoe UI, sans-serif" font-size="40" font-weight="400">)</text>
</g>
{title_svg}
{description_svg}
</svg>"##,
border_width = width.saturating_sub(1),
border_height = height.saturating_sub(1)
)
}
}
fn normalize_for_compare(value: &str) -> String {
value.chars().filter(|ch| !ch.is_whitespace()).flat_map(char::to_lowercase).collect()
}
fn wrap_text_limited(text: &str, max_chars: usize, max_lines: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current_line = String::new();
for word in text.split_whitespace() {
if current_line.is_empty() {
current_line = word.to_string();
} else if current_line.len() + 1 + word.len() <= max_chars {
current_line.push(' ');
current_line.push_str(word);
} else {
lines.push(current_line);
current_line = word.to_string();
}
}
if !current_line.is_empty() {
lines.push(current_line);
}
if lines.len() > max_lines {
lines.truncate(max_lines);
if let Some(last) = lines.last_mut() {
if last.len() > 3 {
last.truncate(last.len() - 3);
last.push_str("...");
}
}
}
lines
}
impl Default for OgImageGenerator {
fn default() -> Self {
Self::new(OgImageConfig::default())
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_svg() {
let generator = OgImageGenerator::default();
let data = OgImageData {
title: "Test Title".to_string(),
description: Some("Test description".to_string()),
site_name: None,
author: None,
date: None,
tags: vec![],
};
let svg = generator.generate_svg(&data);
assert!(svg.contains("Test Title"));
assert!(svg.contains("Test description"));
}
}