Skip to main content

yggdrasil/og/
mod.rs

1//! 分享图渲染:文章图与站点图,均为 1200×630 PNG(见 docs/v1.0-重设计方案.md「分享图」)。
2//!
3//! 版式按效果稿「网站图标与分享图」画板放大一倍:文章图浅底,元信息、衬线标题(最多
4//! 三行)、摘要(最多两行)与底部文字标,右侧一条陶土橙细边;站点图深底,文字标、
5//! 两行标语与「地址·篇数」。
6//!
7//! 字体随二进制嵌入(`scripts/subset-og-fonts.ts` 裁剪的 Noto Serif / Sans SC),不读
8//! 系统字体,渲染结果与部署环境无关。SVG 文字不会自动折行,这里按字体字宽量出每行;
9//! 字体缺字时返回 [`OgError::MissingGlyph`],由调用方改用站点图。
10#![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/// 站点分享图上的文字。
42#[derive(Debug, Clone, PartialEq, Eq, Hash)]
43pub struct SiteShareCard {
44    /// 标语第一行。
45    pub title: String,
46    /// 标语第二行(次要色),可为空。
47    pub subtitle: String,
48    /// 底部「站点地址·N 篇文章」,可为空。
49    pub footer: String,
50}
51
52#[derive(Debug, Clone, PartialEq)]
53pub enum OgError {
54    /// 嵌入字体没有这个字。
55    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/// 一种文字样式。尺寸为 1200×630 画布上的像素。
73#[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
129/// 渲染文章分享图。
130pub 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
160/// 渲染站点分享图。标语两行各自折行,合计最多三行。
161pub 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    // 与效果稿的 space-between 一致:标语在文字标与页脚之间居中。
183    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
204/// 文字标:陶土橙方点加「Yggdrasil」。方点边长约为字号的 0.53,圆角为边长的 0.25,
205/// 与字之间留约一个方点宽。
206fn 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
224/// 自 `top` 起逐行写出文字,返回最后一行行框的底边。
225fn 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
249/// 行框顶到基线的距离:与 CSS 一样把行距差平分到字面上下。
250fn 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
261/// 文字宽度(含字距)。缺字时报错。
262fn 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
274/// 按宽度折行,最多 `max_lines` 行;超出时末行以「…」收尾。
275fn 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        // 比整行还宽的单个记号(长网址、长标识符)按字拆开。
308        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
330/// 末行加省略号,放不下时从尾部逐字退回;同时去掉省略号前的空格与句读。
331fn 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
348/// 切成折行的最小单位:一个汉字、一个西文词或一个空格。句读与闭括号并入前一个单位,
349/// 不出现在行首;开括号与开引号并入后一个单位,不留在行尾。
350fn 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}