1#![cfg(feature = "server")]
11
12use std::fmt;
13use std::sync::{Arc, LazyLock};
14
15use resvg::{tiny_skia, usvg};
16use skrifa::prelude::{LocationRef, Size};
17use skrifa::{FontRef, MetadataProvider};
18
19use crate::models::share::PostShareCard;
20use crate::utils::html::escape_html;
21
22pub const WIDTH: u32 = 1200;
23pub const HEIGHT: u32 = 630;
24
25static SERIF: &[u8] = include_bytes!("fonts/NotoSerifSC-Medium.otf");
26static SANS: &[u8] = include_bytes!("fonts/NotoSansSC-Regular.otf");
27static SANS_BOLD: &[u8] = include_bytes!("fonts/NotoSansSC-Bold.otf");
28
29static FONT_DB: LazyLock<Arc<usvg::fontdb::Database>> = LazyLock::new(|| {
30 let mut db = usvg::fontdb::Database::new();
31 for data in [SERIF, SANS, SANS_BOLD] {
32 db.load_font_source(usvg::fontdb::Source::Binary(Arc::new(data)));
33 }
34 db.set_serif_family("Noto Serif SC");
35 db.set_sans_serif_family("Noto Sans SC");
36 Arc::new(db)
37});
38
39const CLAY: &str = "#d97757";
40
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct SiteShareCard {
44 pub title: String,
46 pub subtitle: String,
48 pub footer: String,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53pub enum OgError {
54 MissingGlyph(char),
56 Svg(String),
57 Encode,
58}
59
60impl fmt::Display for OgError {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 OgError::MissingGlyph(c) => write!(f, "分享图字体缺字:{c:?}"),
64 OgError::Svg(e) => write!(f, "分享图 SVG 解析失败:{e}"),
65 OgError::Encode => write!(f, "分享图 PNG 编码失败"),
66 }
67 }
68}
69
70impl std::error::Error for OgError {}
71
72#[derive(Clone, Copy)]
74struct Style {
75 font: &'static [u8],
76 family: &'static str,
77 weight: u16,
78 size: f32,
79 line_height: f32,
80 letter_spacing: f32,
81}
82
83const POST_META: Style = Style {
84 font: SANS,
85 family: "Noto Sans SC",
86 weight: 400,
87 size: 26.0,
88 line_height: 42.0,
89 letter_spacing: 0.0,
90};
91const POST_TITLE: Style = Style {
92 font: SERIF,
93 family: "Noto Serif SC",
94 weight: 500,
95 size: 60.0,
96 line_height: 79.0,
97 letter_spacing: -0.6,
98};
99const POST_SUMMARY: Style = Style {
100 font: SANS,
101 family: "Noto Sans SC",
102 weight: 400,
103 size: 28.0,
104 line_height: 48.0,
105 letter_spacing: 0.0,
106};
107const WORDMARK: Style = Style {
108 font: SANS_BOLD,
109 family: "Noto Sans SC",
110 weight: 700,
111 size: 30.0,
112 line_height: 48.0,
113 letter_spacing: -0.3,
114};
115const SITE_WORDMARK: Style = Style {
116 size: 36.0,
117 line_height: 58.0,
118 letter_spacing: -0.36,
119 ..WORDMARK
120};
121const SITE_TAGLINE: Style = Style {
122 size: 68.0,
123 line_height: 88.0,
124 letter_spacing: -0.68,
125 ..POST_TITLE
126};
127const SITE_FOOTER: Style = POST_META;
128
129pub fn render_post(card: &PostShareCard) -> Result<Vec<u8>, OgError> {
131 const PAD_X: f32 = 80.0;
132 const PAD_Y: f32 = 72.0;
133 let max_width = WIDTH as f32 - PAD_X * 2.0;
134
135 let meta = wrap(&POST_META, &card.meta, max_width, 1)?;
136 let title = wrap(&POST_TITLE, &card.title, max_width, 3)?;
137 let summary = wrap(&POST_SUMMARY, &card.summary, max_width, 2)?;
138
139 let mut svg = svg_open("#f5f5f3");
140 let mut y = PAD_Y;
141 y = text_block(&mut svg, &POST_META, &meta, PAD_X, y, |_| "#6b6b69")?;
142 y = text_block(&mut svg, &POST_TITLE, &title, PAD_X, y + 28.0, |_| {
143 "#151515"
144 })?;
145 if !summary.is_empty() {
146 text_block(&mut svg, &POST_SUMMARY, &summary, PAD_X, y + 24.0, |_| {
147 "#525252"
148 })?;
149 }
150 let mark_top = HEIGHT as f32 - PAD_Y - WORDMARK.line_height;
151 wordmark(&mut svg, &WORDMARK, PAD_X, mark_top, "#151515")?;
152 svg.push_str(&format!(
153 r#"<rect x="{}" y="0" width="12" height="{HEIGHT}" fill="{CLAY}"/>"#,
154 WIDTH - 12
155 ));
156 svg.push_str("</svg>");
157 rasterize(&svg)
158}
159
160pub fn render_site(card: &SiteShareCard) -> Result<Vec<u8>, OgError> {
162 const PAD: f32 = 80.0;
163 let max_width = WIDTH as f32 - PAD * 2.0;
164
165 let mut lines: Vec<(String, &str)> = wrap(&SITE_TAGLINE, &card.title, max_width, 3)?
166 .into_iter()
167 .map(|line| (line, "#f2f1ee"))
168 .collect();
169 let rest = 3usize.saturating_sub(lines.len());
170 if rest > 0 {
171 lines.extend(
172 wrap(&SITE_TAGLINE, &card.subtitle, max_width, rest)?
173 .into_iter()
174 .map(|line| (line, "#8b8984")),
175 );
176 }
177 let footer = wrap(&SITE_FOOTER, &card.footer, max_width, 1)?;
178
179 let mut svg = svg_open("#0f0f0e");
180 wordmark(&mut svg, &SITE_WORDMARK, PAD, PAD, "#f2f1ee")?;
181
182 let footer_top = HEIGHT as f32 - PAD - SITE_FOOTER.line_height;
184 let tagline_height = lines.len() as f32 * SITE_TAGLINE.line_height;
185 let free = footer_top - (PAD + SITE_WORDMARK.line_height) - tagline_height;
186 let tagline_top = PAD + SITE_WORDMARK.line_height + free / 2.0;
187 let texts: Vec<String> = lines.iter().map(|(line, _)| line.clone()).collect();
188 text_block(&mut svg, &SITE_TAGLINE, &texts, PAD, tagline_top, |i| {
189 lines[i].1
190 })?;
191 text_block(&mut svg, &SITE_FOOTER, &footer, PAD, footer_top, |_| {
192 "#8b8984"
193 })?;
194 svg.push_str("</svg>");
195 rasterize(&svg)
196}
197
198fn svg_open(background: &str) -> String {
199 format!(
200 r#"<svg xmlns="http://www.w3.org/2000/svg" width="{WIDTH}" height="{HEIGHT}" viewBox="0 0 {WIDTH} {HEIGHT}"><rect width="{WIDTH}" height="{HEIGHT}" fill="{background}"/>"#
201 )
202}
203
204fn wordmark(svg: &mut String, style: &Style, x: f32, top: f32, color: &str) -> Result<(), OgError> {
207 let dot = (style.size * 0.53).round();
208 let dot_y = top + (style.line_height - dot) / 2.0;
209 svg.push_str(&format!(
210 r#"<rect x="{x}" y="{dot_y}" width="{dot}" height="{dot}" rx="{}" fill="{CLAY}"/>"#,
211 dot / 4.0
212 ));
213 text_block(
214 svg,
215 style,
216 &["Yggdrasil".to_string()],
217 x + dot * 2.0,
218 top,
219 |_| color,
220 )?;
221 Ok(())
222}
223
224fn text_block<'a>(
226 svg: &mut String,
227 style: &Style,
228 lines: &[String],
229 x: f32,
230 top: f32,
231 color: impl Fn(usize) -> &'a str,
232) -> Result<f32, OgError> {
233 let baseline = baseline_offset(style)?;
234 for (i, line) in lines.iter().enumerate() {
235 let y = top + style.line_height * i as f32 + baseline;
236 svg.push_str(&format!(
237 r#"<text x="{x}" y="{y:.1}" font-family="{}" font-weight="{}" font-size="{}" letter-spacing="{}" fill="{}">{}</text>"#,
238 style.family,
239 style.weight,
240 style.size,
241 style.letter_spacing,
242 color(i),
243 escape_html(line),
244 ));
245 }
246 Ok(top + style.line_height * lines.len() as f32)
247}
248
249fn baseline_offset(style: &Style) -> Result<f32, OgError> {
251 let font = font_ref(style.font);
252 let metrics = font.metrics(Size::new(style.size), LocationRef::default());
253 let content = metrics.ascent - metrics.descent;
254 Ok((style.line_height - content) / 2.0 + metrics.ascent)
255}
256
257fn font_ref(data: &'static [u8]) -> FontRef<'static> {
258 FontRef::new(data).expect("内嵌分享图字体可以解析")
259}
260
261fn measure(style: &Style, text: &str) -> Result<f32, OgError> {
263 let font = font_ref(style.font);
264 let charmap = font.charmap();
265 let metrics = font.glyph_metrics(Size::new(style.size), LocationRef::default());
266 let mut width = 0.0;
267 for c in text.chars() {
268 let glyph = charmap.map(c).ok_or(OgError::MissingGlyph(c))?;
269 width += metrics.advance_width(glyph).unwrap_or(0.0) + style.letter_spacing;
270 }
271 Ok(width)
272}
273
274fn wrap(
276 style: &Style,
277 text: &str,
278 max_width: f32,
279 max_lines: usize,
280) -> Result<Vec<String>, OgError> {
281 let mut lines: Vec<String> = Vec::new();
282 let mut line = String::new();
283 let mut width = 0.0;
284 for token in tokens(text) {
285 if token == " " && line.is_empty() {
286 continue;
287 }
288 let token_width = measure(style, &token)?;
289 if width + token_width <= max_width {
290 line.push_str(&token);
291 width += token_width;
292 continue;
293 }
294 if !line.is_empty() {
295 lines.push(line.trim_end().to_string());
296 line.clear();
297 width = 0.0;
298 if token == " " {
299 continue;
300 }
301 }
302 if token_width <= max_width {
303 line.push_str(&token);
304 width = token_width;
305 continue;
306 }
307 for c in token.chars() {
309 let char_width = measure(style, c.encode_utf8(&mut [0; 4]))?;
310 if width + char_width > max_width && !line.is_empty() {
311 lines.push(std::mem::take(&mut line));
312 width = 0.0;
313 }
314 line.push(c);
315 width += char_width;
316 }
317 }
318 if !line.trim().is_empty() {
319 lines.push(line.trim_end().to_string());
320 }
321 if lines.len() > max_lines {
322 lines.truncate(max_lines);
323 if let Some(last) = lines.last_mut() {
324 *last = ellipsize(style, last, max_width)?;
325 }
326 }
327 Ok(lines)
328}
329
330fn ellipsize(style: &Style, line: &str, max_width: f32) -> Result<String, OgError> {
332 let mut chars: Vec<char> = line.chars().collect();
333 loop {
334 while chars
335 .last()
336 .is_some_and(|c| c.is_whitespace() || is_no_break_before(*c))
337 {
338 chars.pop();
339 }
340 let candidate: String = chars.iter().chain(['…'].iter()).collect();
341 if chars.is_empty() || measure(style, &candidate)? <= max_width {
342 return Ok(candidate);
343 }
344 chars.pop();
345 }
346}
347
348fn tokens(text: &str) -> Vec<String> {
351 let mut out: Vec<String> = Vec::new();
352 let mut word = String::new();
353 let mut opening = String::new();
354 for c in text.chars() {
355 if c.is_whitespace() {
356 flush(&mut word, &mut out);
357 flush(&mut opening, &mut out);
358 if out.last().is_some_and(|t| t != " ") {
359 out.push(" ".to_string());
360 }
361 } else if is_no_break_before(c) && opening.is_empty() {
362 if !word.is_empty() {
363 word.push(c);
364 } else if let Some(last) = out.last_mut().filter(|t| *t != " ") {
365 last.push(c);
366 } else {
367 out.push(c.to_string());
368 }
369 } else if is_no_break_after(c) {
370 flush(&mut word, &mut out);
371 opening.push(c);
372 } else if is_cjk(c) {
373 flush(&mut word, &mut out);
374 let mut token = std::mem::take(&mut opening);
375 token.push(c);
376 out.push(token);
377 } else {
378 if word.is_empty() {
379 word = std::mem::take(&mut opening);
380 }
381 word.push(c);
382 }
383 }
384 flush(&mut word, &mut out);
385 flush(&mut opening, &mut out);
386 while out.last().is_some_and(|t| t == " ") {
387 out.pop();
388 }
389 out
390}
391
392fn flush(buf: &mut String, out: &mut Vec<String>) {
393 if !buf.is_empty() {
394 out.push(std::mem::take(buf));
395 }
396}
397
398fn is_no_break_before(c: char) -> bool {
399 ",。、;:!?)》」』】〕〉”’…—~·%‰,.;:!?)]}".contains(c)
400}
401
402fn is_no_break_after(c: char) -> bool {
403 "(《「『【〔〈“‘".contains(c)
404}
405
406fn is_cjk(c: char) -> bool {
407 matches!(c as u32,
408 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF)
409}
410
411fn rasterize(svg: &str) -> Result<Vec<u8>, OgError> {
412 let options = usvg::Options {
413 fontdb: FONT_DB.clone(),
414 ..usvg::Options::default()
415 };
416 let tree = usvg::Tree::from_str(svg, &options).map_err(|e| OgError::Svg(e.to_string()))?;
417 let mut pixmap = tiny_skia::Pixmap::new(WIDTH, HEIGHT).ok_or(OgError::Encode)?;
418 resvg::render(&tree, tiny_skia::Transform::default(), &mut pixmap.as_mut());
419 pixmap.encode_png().map_err(|_| OgError::Encode)
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425 use crate::models::settings::{DEFAULT_HOME_SUBTITLE, DEFAULT_HOME_TITLE};
426
427 fn is_png(bytes: &[u8]) -> bool {
428 bytes.starts_with(b"\x89PNG\r\n\x1a\n")
429 }
430
431 #[test]
432 fn tokens_keep_punctuation_off_line_edges() {
433 assert_eq!(
434 tokens("让时间成为电路,「Hesper」的 Apple I"),
435 [
436 "让",
437 "时",
438 "间",
439 "成",
440 "为",
441 "电",
442 "路,",
443 "「Hesper」",
444 "的",
445 " ",
446 "Apple",
447 " ",
448 "I"
449 ]
450 );
451 assert_eq!(tokens(" a b "), ["a", " ", "b"]);
452 }
453
454 #[test]
455 fn wrap_breaks_by_width_and_ellipsizes_overflow() {
456 let text = "让时间成为电路的一部分:Hesper 的 Apple I 实现,从一个字符如何抵达屏幕出发,\
457 沿着主时钟、PIA 握手、循环显示存储器与数字视频链一路读下去";
458 let lines = wrap(&POST_TITLE, text, 1040.0, 3).unwrap();
459 assert_eq!(lines.len(), 3);
460 for line in &lines {
461 assert!(measure(&POST_TITLE, line).unwrap() <= 1040.0, "{line}");
462 assert!(!line.starts_with([',', ':', ' ']), "{line}");
463 }
464 assert!(lines[2].ends_with('…'));
465
466 let short = wrap(&POST_TITLE, "短标题", 1040.0, 3).unwrap();
467 assert_eq!(short, ["短标题"]);
468 }
469
470 #[test]
471 fn wrap_splits_tokens_wider_than_a_line() {
472 let url = "https://example.com/".to_string() + &"a".repeat(200);
473 let lines = wrap(&POST_SUMMARY, &url, 1040.0, 2).unwrap();
474 assert_eq!(lines.len(), 2);
475 assert!(lines[1].ends_with('…'));
476 }
477
478 #[test]
479 fn missing_glyph_is_reported() {
480 assert_eq!(
481 measure(&POST_TITLE, "𠀀").unwrap_err(),
482 OgError::MissingGlyph('𠀀')
483 );
484 }
485
486 #[test]
487 fn renders_post_card() {
488 let card = PostShareCard {
489 title: "让时间成为电路的一部分:Hesper 的 Apple I 实现".into(),
490 summary: "从一个字符如何抵达屏幕出发,沿着 Hesper 的主时钟、PIA 握手、\
491 循环显示存储器与数字视频链,理解 Apple I 的实现取舍。"
492 .into(),
493 meta: "2026.09.16·19 分钟·源码剖析".into(),
494 };
495 let png = render_post(&card).unwrap();
496 assert!(is_png(&png));
497 }
498
499 #[test]
500 fn renders_default_site_card() {
501 let card = SiteShareCard {
502 title: DEFAULT_HOME_TITLE.into(),
503 subtitle: DEFAULT_HOME_SUBTITLE.into(),
504 footer: "rua.plus·41 篇文章".into(),
505 };
506 assert!(is_png(&render_site(&card).unwrap()));
507 }
508}