atproto blogging
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}