at main 367 lines 10 kB view raw
1//! OpenGraph image generation module 2//! 3//! Generates social card images for entry pages using SVG templates rendered to PNG. 4pub mod server; 5 6use crate::cache_impl::{Cache, new_cache}; 7use askama::Template; 8use jacquard::smol_str::{SmolStr, ToSmolStr, format_smolstr}; 9use std::sync::OnceLock; 10use std::time::Duration; 11 12/// Cache for generated OG images 13/// Key: "{ident}/{book}/{entry}/{cid}" - includes CID for invalidation 14static OG_CACHE: OnceLock<Cache<SmolStr, Vec<u8>>> = OnceLock::new(); 15 16fn get_cache() -> &'static Cache<SmolStr, Vec<u8>> { 17 OG_CACHE.get_or_init(|| { 18 // Cache up to 1000 images for 1 hour 19 new_cache(1000, Duration::from_secs(3600)) 20 }) 21} 22 23/// Generate cache key from entry identifiers 24pub fn cache_key(ident: &str, book: &str, entry: &str, cid: &str) -> SmolStr { 25 format_smolstr!("{}/{}/{}/{}", ident, book, entry, cid) 26} 27 28/// Try to get a cached OG image 29pub fn get_cached(key: &SmolStr) -> Option<Vec<u8>> { 30 get_cache().get(key) 31} 32 33/// Store an OG image in the cache 34pub fn cache_image(key: SmolStr, image: Vec<u8>) { 35 get_cache().insert(key, image); 36} 37 38/// Error type for OG image generation 39#[derive(Debug)] 40pub enum OgError { 41 NotFound, 42 FetchError(SmolStr), 43 RenderError(SmolStr), 44 TemplateError(SmolStr), 45} 46 47impl std::fmt::Display for OgError { 48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 match self { 50 OgError::NotFound => write!(f, "Entry not found"), 51 OgError::FetchError(e) => write!(f, "Fetch error: {}", e), 52 OgError::RenderError(e) => write!(f, "Render error: {}", e), 53 OgError::TemplateError(e) => write!(f, "Template error: {}", e), 54 } 55 } 56} 57 58impl std::error::Error for OgError {} 59 60/// Standard OG image dimensions 61pub const OG_WIDTH: u32 = 1200; 62pub const OG_HEIGHT: u32 = 630; 63 64/// Rose Pine theme colors 65mod colors { 66 pub const BASE: &str = "#191724"; 67 pub const TEXT: &str = "#e0def4"; 68 pub const SUBTLE: &str = "#908caa"; 69 pub const MUTED: &str = "#6e6a86"; 70 pub const OVERLAY: &str = "#524f67"; 71} 72 73/// Text-only template (no hero image) 74#[derive(Template)] 75#[template(path = "og_text_only.svg", escape = "none")] 76pub struct TextOnlyTemplate { 77 pub title_lines: Vec<String>, 78 pub content_lines: Vec<String>, 79 pub notebook_title: SmolStr, 80 pub author_handle: SmolStr, 81} 82 83/// Hero image template (full-bleed image with overlay) 84#[derive(Template)] 85#[template(path = "og_hero_image.svg", escape = "none")] 86pub struct HeroImageTemplate { 87 pub hero_image_data: String, 88 pub title_lines: Vec<String>, 89 pub notebook_title: SmolStr, 90 pub author_handle: SmolStr, 91} 92 93/// Notebook index template 94#[derive(Template)] 95#[template(path = "og_notebook.svg", escape = "none")] 96pub struct NotebookTemplate { 97 pub title_lines: Vec<String>, 98 pub author_handle: SmolStr, 99 pub entry_count: usize, 100 pub entry_titles: Vec<String>, 101} 102 103/// Profile template (text-only, no banner) 104#[derive(Template)] 105#[template(path = "og_profile.svg", escape = "none")] 106pub struct ProfileTemplate { 107 pub avatar_data: Option<String>, 108 pub display_name_lines: Vec<String>, 109 pub handle: SmolStr, 110 pub bio_lines: Vec<String>, 111 pub notebook_count: usize, 112} 113 114/// Profile template with banner image 115#[derive(Template)] 116#[template(path = "og_profile_banner.svg", escape = "none")] 117pub struct ProfileBannerTemplate { 118 pub banner_image_data: String, 119 pub avatar_data: Option<String>, 120 pub display_name_lines: Vec<String>, 121 pub handle: SmolStr, 122 pub bio_lines: Vec<String>, 123 pub notebook_count: usize, 124} 125 126/// Site homepage template 127#[derive(Template)] 128#[template(path = "og_site.svg", escape = "none")] 129pub struct SiteTemplate {} 130 131/// Global font database, initialized once 132static FONTDB: OnceLock<fontdb::Database> = OnceLock::new(); 133 134fn get_fontdb() -> &'static fontdb::Database { 135 FONTDB.get_or_init(|| { 136 let mut db = fontdb::Database::new(); 137 // Load CMU Sans Serif for headings/UI 138 db.load_font_data( 139 include_bytes!("../../assets/fonts/cmu-sans-serif/CMUSansSerif-Medium.ttf").to_vec(), 140 ); 141 db.load_font_data( 142 include_bytes!("../../assets/fonts/cmu-sans-serif/CMUSansSerif-Bold.ttf").to_vec(), 143 ); 144 // Load Adobe Caslon Pro for body text 145 db.load_font_data( 146 include_bytes!("../../assets/fonts/adobe-caslon/AdobeCaslonPro-Regular.ttf").to_vec(), 147 ); 148 db.load_font_data( 149 include_bytes!("../../assets/fonts/adobe-caslon/AdobeCaslonPro-Bold.ttf").to_vec(), 150 ); 151 // Load Ioskeley Mono for branding/handles 152 db.load_font_data( 153 include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.ttf").to_vec(), 154 ); 155 db 156 }) 157} 158 159/// Wrap title text into lines that fit the SVG width 160pub fn wrap_title(title: &str, max_chars: usize, max_lines: usize) -> Vec<String> { 161 textwrap::wrap(title, max_chars) 162 .into_iter() 163 .take(max_lines) 164 .map(|s| s.to_string()) 165 .collect() 166} 167 168/// Render an SVG string to PNG bytes 169pub fn render_svg_to_png(svg: &str) -> Result<Vec<u8>, OgError> { 170 let fontdb = get_fontdb(); 171 172 let options = usvg::Options { 173 fontdb: std::sync::Arc::new(fontdb.clone()), 174 ..Default::default() 175 }; 176 177 let tree = usvg::Tree::from_str(svg, &options) 178 .map_err(|e| OgError::RenderError(format_smolstr!("Failed to parse SVG: {}", e)))?; 179 180 let mut pixmap = tiny_skia::Pixmap::new(OG_WIDTH, OG_HEIGHT) 181 .ok_or_else(|| OgError::RenderError("Failed to create pixmap".to_smolstr()))?; 182 183 resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut()); 184 185 pixmap 186 .encode_png() 187 .map_err(|e| OgError::RenderError(format_smolstr!("Failed to encode PNG: {}", e))) 188} 189 190/// Generate a text-only OG image 191pub fn generate_text_only( 192 title: &str, 193 content: &str, 194 notebook_title: &str, 195 author_handle: &str, 196) -> Result<Vec<u8>, OgError> { 197 let title_lines = wrap_title(title, 50, 2); 198 let content_lines = wrap_title(content, 70, 5); 199 200 let template = TextOnlyTemplate { 201 title_lines, 202 content_lines, 203 notebook_title: notebook_title.to_smolstr(), 204 author_handle: author_handle.to_smolstr(), 205 }; 206 207 let svg = template 208 .render() 209 .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 210 211 render_svg_to_png(&svg) 212} 213 214/// Generate a hero image OG image 215pub fn generate_hero_image( 216 hero_image_data: &str, 217 title: &str, 218 notebook_title: &str, 219 author_handle: &str, 220) -> Result<Vec<u8>, OgError> { 221 let title_lines = wrap_title(title, 50, 2); 222 223 let template = HeroImageTemplate { 224 hero_image_data: hero_image_data.to_string(), 225 title_lines, 226 notebook_title: notebook_title.to_smolstr(), 227 author_handle: author_handle.to_smolstr(), 228 }; 229 230 let svg = template 231 .render() 232 .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 233 234 render_svg_to_png(&svg) 235} 236 237/// Generate cache key for notebook OG images 238pub fn notebook_cache_key(ident: &str, book: &str, cid: &str) -> SmolStr { 239 format_smolstr!("notebook/{}/{}/{}", ident, book, cid) 240} 241 242/// Generate cache key for profile OG images 243pub fn profile_cache_key(ident: &str, cid: &str) -> SmolStr { 244 format_smolstr!("profile/{}/{}", ident, cid) 245} 246 247/// Generate a notebook index OG image 248pub fn generate_notebook_og( 249 title: &str, 250 author_handle: &str, 251 entry_count: usize, 252 entry_titles: Vec<String>, 253) -> Result<Vec<u8>, OgError> { 254 let title_lines = wrap_title(title, 40, 2); 255 // Limit to first 4 entries, truncate long titles 256 let entry_titles: Vec<String> = entry_titles 257 .into_iter() 258 .take(4) 259 .map(|t| { 260 if t.len() > 60 { 261 format!("{}...", &t[..57]) 262 } else { 263 t 264 } 265 }) 266 .collect(); 267 268 let template = NotebookTemplate { 269 title_lines, 270 author_handle: author_handle.to_smolstr(), 271 entry_count, 272 entry_titles, 273 }; 274 275 let svg = template 276 .render() 277 .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 278 279 render_svg_to_png(&svg) 280} 281 282/// Generate a profile OG image (text-only version) 283pub fn generate_profile_og( 284 display_name: &str, 285 handle: &str, 286 bio: &str, 287 avatar_data: Option<String>, 288 notebook_count: usize, 289) -> Result<Vec<u8>, OgError> { 290 let display_name_lines = wrap_title(display_name, 30, 2); 291 let bio_lines = wrap_title(bio, 60, 4); 292 293 let template = ProfileTemplate { 294 avatar_data, 295 display_name_lines, 296 handle: handle.to_smolstr(), 297 bio_lines, 298 notebook_count, 299 }; 300 301 let svg = template 302 .render() 303 .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 304 305 render_svg_to_png(&svg) 306} 307 308/// Generate a profile OG image with banner 309pub fn generate_profile_banner_og( 310 display_name: &str, 311 handle: &str, 312 bio: &str, 313 banner_image_data: String, 314 avatar_data: Option<String>, 315 notebook_count: usize, 316) -> Result<Vec<u8>, OgError> { 317 let display_name_lines = wrap_title(display_name, 25, 1); 318 let bio_lines = wrap_title(bio, 70, 1); 319 320 let template = ProfileBannerTemplate { 321 banner_image_data, 322 avatar_data, 323 display_name_lines, 324 handle: handle.to_smolstr(), 325 bio_lines, 326 notebook_count, 327 }; 328 329 let svg = template 330 .render() 331 .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 332 333 render_svg_to_png(&svg) 334} 335 336/// Generate site homepage OG image 337pub fn generate_site_og() -> Result<Vec<u8>, OgError> { 338 let template = SiteTemplate {}; 339 340 let svg = template 341 .render() 342 .map_err(|e| OgError::TemplateError(e.to_smolstr()))?; 343 344 render_svg_to_png(&svg) 345} 346 347#[cfg(test)] 348mod tests { 349 use super::*; 350 351 #[test] 352 fn test_wrap_title_short() { 353 let lines = wrap_title("Hello World", 28, 3); 354 assert_eq!(lines, vec!["Hello World"]); 355 } 356 357 #[test] 358 fn test_wrap_title_long() { 359 let lines = wrap_title( 360 "This is a very long title that should wrap onto multiple lines", 361 28, 362 3, 363 ); 364 assert!(lines.len() > 1); 365 assert!(lines.len() <= 3); 366 } 367}