Skip to main content

yggdrasil/api/markdown/mermaid/
metrics.rs

1//! Mermaid 排版用的字宽回调。
2//!
3//! merman 默认的确定性字宽和浏览器实际字体对不上,HTML 标签会溢出。这里按
4//! `font_metrics.bin`(`cd libs/shared && bun run mermaid-font-metrics <字体目录>` 生成)
5//! 里 5 款界面字体(SF Pro Text、Selawik、Roboto、Liberation Sans、Noto Sans)分别求整串宽度,
6//! 西文字体缺字形时用 Noto Sans CJK 的宽度,取 5 个结果的最大值:读者实际用哪款字体都放得下。
7
8use std::sync::LazyLock;
9
10use merman::svg::{
11    DeterministicTextMeasurer, MeasurementProfileId, SvgEnvironment, TextMeasurementPolicy,
12    TextMeasurementProfile, TextMeasurementProfileIdentity, TextStyle,
13};
14
15/// 码点连续且字宽相同的一段;`milli` 为千分之一 em(向上取整)。
16struct Run {
17    start: u16,
18    end: u16,
19    milli: u16,
20}
21
22/// 同一字重的 5 款西文字体与对应的 CJK 回退字体。
23struct FontSet {
24    latin: [Vec<Run>; 5],
25    cjk: Vec<Run>,
26}
27
28struct FontTables {
29    regular: FontSet,
30    bold: FontSet,
31}
32
33/// 表顺序:0–4 常规西文、5 常规 CJK、6–10 粗体西文、11 粗体 CJK(与生成脚本一致)。
34static TABLES: LazyLock<FontTables> = LazyLock::new(|| decode(include_bytes!("font_metrics.bin")));
35
36fn decode(bytes: &[u8]) -> FontTables {
37    const BAD: &str = "font_metrics.bin 格式不符,请用 bun run mermaid-font-metrics 重新生成";
38    let u16_at = |at: usize| -> u16 {
39        let b = bytes.get(at..at + 2).expect(BAD);
40        u16::from_le_bytes([b[0], b[1]])
41    };
42    assert!(bytes.get(..6) == Some(b"YMFM\x01\x0c"), "{BAD}");
43    let mut at = 6;
44    let mut tables: Vec<Vec<Run>> = (0..12)
45        .map(|_| {
46            let count = usize::from(u16_at(at));
47            at += 2;
48            let runs = (0..count)
49                .map(|i| {
50                    let base = at + 6 * i;
51                    Run {
52                        start: u16_at(base),
53                        end: u16_at(base + 2),
54                        milli: u16_at(base + 4),
55                    }
56                })
57                .collect();
58            at += 6 * count;
59            runs
60        })
61        .collect();
62    assert_eq!(at, bytes.len(), "{BAD}");
63    let into_set = |mut set: Vec<Vec<Run>>| -> FontSet {
64        let cjk = set.pop().expect(BAD);
65        let Ok(latin) = <[Vec<Run>; 5]>::try_from(set) else {
66            panic!("{BAD}");
67        };
68        FontSet { latin, cjk }
69    };
70    let bold = into_set(tables.split_off(6));
71    FontTables {
72        regular: into_set(tables),
73        bold,
74    }
75}
76
77fn lookup(runs: &[Run], cp: u16) -> Option<u16> {
78    let run = runs.get(runs.partition_point(|r| r.end < cp))?;
79    (run.start <= cp).then_some(run.milli)
80}
81
82/// 浏览器排版时不占宽度的字符:软连字符、零宽空格与方向符、变体选择符、组合附加符号。
83fn zero_width(ch: char) -> bool {
84    matches!(
85        ch,
86        '\u{AD}' | '\u{200B}'..='\u{200F}' | '\u{2060}' | '\u{FEFF}' | '\u{FE00}'..='\u{FE0F}'
87    ) || ('\u{0300}'..='\u{036F}').contains(&ch)
88}
89
90/// 5 款西文字体各求一遍整串宽度(em),取最大值。
91fn width_em(set: &FontSet, text: &str) -> f64 {
92    let mut sums = [0u64; 5];
93    for ch in text.chars().filter(|c| !zero_width(*c)) {
94        let Ok(cp) = u16::try_from(u32::from(ch)) else {
95            // 基本多文种平面以外(emoji 等)按 1 em 计。
96            sums.iter_mut().for_each(|sum| *sum += 1000);
97            continue;
98        };
99        let cjk = lookup(&set.cjk, cp);
100        for (sum, runs) in sums.iter_mut().zip(&set.latin) {
101            *sum += u64::from(lookup(runs, cp).or(cjk).unwrap_or(1000));
102        }
103    }
104    sums.into_iter().max().unwrap_or(0) as f64 / 1000.0
105}
106
107fn is_bold(style: &TextStyle) -> bool {
108    match style.font_weight.as_deref().map(str::trim) {
109        Some("bold" | "bolder") => true,
110        Some(w) => w.parse::<u32>().is_ok_and(|w| w >= 600),
111        None => false,
112    }
113}
114
115/// merman 的字宽回调:返回 `text` 按 `style` 排版后的像素宽度。
116pub(super) fn text_width_px(text: &str, style: &TextStyle) -> f64 {
117    let set = if is_bold(style) {
118        &TABLES.bold
119    } else {
120        &TABLES.regular
121    };
122    width_em(set, text) * style.font_size
123}
124
125/// 使用站点字宽表的渲染环境。每次新建:回调只引用 `'static` 的字宽表,不依赖 merman 类型是否 `Sync`。
126pub(super) fn environment() -> SvgEnvironment {
127    let measurer = DeterministicTextMeasurer::default().with_width_callback(text_width_px);
128    let id = MeasurementProfileId::new("ygg-ui-fonts").expect("测量配置 id 合法");
129    let identity = TextMeasurementProfileIdentity::new(id, "1").expect("测量配置版本合法");
130    let profile = TextMeasurementProfile::new(identity, measurer);
131    SvgEnvironment::deterministic()
132        .with_text_measurement_policy(TextMeasurementPolicy::uniform(profile))
133}