Skip to main content

yggdrasil/api/markdown/
post_process.rs

1//! 渲染后处理:/uploads/ 图片 blur-up 包裹与表格横向滚动容器。
2
3/// 把 HTML 里的 /uploads/ 图片转成 blur-up 双层结构。
4///
5/// 仅处理 src 以 /uploads/ 开头的 img;外链图保持原样。
6/// 对每个匹配的 img:
7/// 1. 提取 src,解析出 rel_path(去 /uploads/ 前缀和 query)
8/// 2. 查 get_image_dimensions 拿真实宽高,算 --ar(如 "16:9")
9/// 3. 生成 `<span class="blur-img" style="--ar:..">` 包裹两层 img
10pub(super) fn wrap_images_with_blur(html: &str) -> String {
11    wrap_images_with_blur_with(html, crate::api::image::get_image_dimensions)
12}
13
14/// `wrap_images_with_blur` 的纯函数核心,接受 dimensions 查询闭包以便单测。
15///
16/// `dims_fn(rel_path) -> Option<(u32, u32)>` 注入真实或测试用的 dimensions 来源,
17/// 使本函数不依赖文件系统——测试可注入已知宽高,生产注入 get_image_dimensions。
18fn wrap_images_with_blur_with<F>(html: &str, dims_fn: F) -> String
19where
20    F: Fn(&str) -> Option<(u32, u32)>,
21{
22    use regex::Regex;
23    use std::sync::LazyLock;
24
25    // 匹配 pulldown-cmark 产出的 <img src="..." alt="..." /> 或 <img src="..." alt="...">
26    // pulldown-cmark 格式可控:src 在前,alt 在后,属性用双引号
27    static IMG_RE: LazyLock<Regex> = LazyLock::new(|| {
28        Regex::new(r#"<img\s+src="(/uploads/[^"]+)"(?:\s+alt="([^"]*)")?\s*/?>"#)
29            .expect("IMG_RE 正则模式应在编译期通过校验")
30    });
31
32    IMG_RE
33        .replace_all(html, |caps: &regex::Captures| {
34            let src = caps.get(1).map(|m| m.as_str()).unwrap_or("");
35            let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
36
37            // 从 src 解析 rel_path:去 /uploads/ 前缀 + 去 query
38            let rel_path = src
39                .strip_prefix("/uploads/")
40                .unwrap_or(src)
41                .split('?')
42                .next()
43                .unwrap_or("");
44
45            // 查 dimensions,算 aspect-ratio
46            // 注意:CSS aspect-ratio 用斜杠分隔(width / height),不是冒号
47            let ar_style = dims_fn(rel_path)
48                .map(|(w, h)| format!(" style=\"--ar:{} / {};\"", w, h))
49                .unwrap_or_default();
50
51            // alt 转义(src/alt 来自 markdown,pulldown-cmark 已转义过,这里直接用)
52            let alt_attr = if alt.is_empty() {
53                String::new()
54            } else {
55                format!(" alt=\"{}\"", alt)
56            };
57
58            format!(
59                "<span class=\"blur-img\"{ar}><img class=\"blur-img-placeholder\" src=\"{src}?w=20\"{alt_attr}><img class=\"blur-img-full\" data-src=\"{src}?w=800\"{alt_attr}></span>",
60                ar = ar_style,
61                src = src,
62                alt_attr = alt_attr,
63            )
64        })
65        .into_owned()
66}
67
68/// 把每个 `<table>...</table>` 包进可横向滚动的 `<div class="table-wrap">`。
69///
70/// 移动端窄屏下宽表格无法横向滚动:pulldown-cmark 产出裸 table,外层 `<main>` 又是
71/// `overflow-hidden` 会把超宽内容直接裁掉。这里给 table 套一层 `table-wrap`(CSS 配
72/// `overflow-x: auto`),让表格在容器内滚动而非撑破页面。
73///
74/// 正则匹配 pulldown-cmark 输出的 `<table ...>...</table>`(非贪婪、跨行),包裹整个
75/// table 标签。对已包裹的 HTML 幂等(不会二次嵌套)——`table-wrap` div 内的 table
76/// 仍会被正则命中并再次包裹,故调用方仅在渲染管线调用一次。
77pub(super) fn wrap_tables(html: &str) -> String {
78    use regex::Regex;
79    use std::sync::LazyLock;
80
81    static TABLE_RE: LazyLock<Regex> = LazyLock::new(|| {
82        Regex::new(r"(?s)<table(\s[^>]*)?>.*?</table>")
83            .expect("TABLE_RE 正则模式应在编译期通过校验")
84    });
85
86    TABLE_RE
87        .replace_all(html, |caps: &regex::Captures| {
88            format!("<div class=\"table-wrap\">{}</div>", &caps[0])
89        })
90        .into_owned()
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::api::markdown::clean_html;
97
98    #[test]
99    fn wrap_images_with_blur_wraps_uploads_image() {
100        // 注入返回 None 的 dims_fn,验证 --ar 缺省时的结构正确性。
101        // 不依赖 uploads/ 文件系统。
102        let html = r#"<p><img src="/uploads/nonexistent/test.webp" alt="test"></p>"#;
103        let result = wrap_images_with_blur_with(html, |_| None);
104        assert!(
105            result.contains("blur-img-placeholder"),
106            "should have placeholder"
107        );
108        assert!(result.contains("blur-img-full"), "should have full layer");
109        assert!(result.contains("?w=20"), "placeholder should use ?w=20");
110        assert!(result.contains("?w=800"), "full should use ?w=800");
111        assert!(result.contains("data-src"), "full should use data-src");
112    }
113
114    #[test]
115    fn wrap_images_with_blur_skips_external_image() {
116        // 外链图不进入 dims_fn,保持原样。
117        let html = r#"<img src="https://example.com/img.png" alt="ext">"#;
118        let result = wrap_images_with_blur_with(html, |_| None);
119        assert!(
120            !result.contains("blur-img"),
121            "external image should not be wrapped"
122        );
123    }
124
125    #[test]
126    fn wrap_images_with_blur_uses_slash_in_aspect_ratio() {
127        // 注入已知 dimensions,验证 --ar 用斜杠分隔(如 "--ar:800 / 600;")。
128        // 此前依赖 uploads/ 真实文件,现已解耦。
129        let html = r#"<img src="/uploads/2026/06/18/abc.webp" alt="t">"#;
130        let result = wrap_images_with_blur_with(html, |_| Some((800, 600)));
131        assert!(result.contains("--ar:"), "should have --ar");
132        assert!(
133            result.contains(" / "),
134            "aspect-ratio must use slash separator, got: {}",
135            result
136        );
137        assert!(result.contains("--ar:800 / 600;"), "应含精确宽高");
138    }
139
140    #[test]
141    fn wrap_images_with_blur_omits_ar_when_no_dimensions() {
142        // dims_fn 返回 None 时不输出 --ar,避免空 style 属性。
143        let html = r#"<img src="/uploads/x.webp" alt="t">"#;
144        let result = wrap_images_with_blur_with(html, |_| None);
145        assert!(!result.contains("--ar"), "无 dimensions 不应有 --ar");
146        // 但包裹结构仍应生成
147        assert!(result.contains("blur-img"));
148    }
149
150    #[test]
151    fn wrap_images_with_blur_strips_query_from_rel_path() {
152        // rel_path 提取应去 query 后缀,dims_fn 收到的是去 query 的路径。
153        // dims_fn 是 Fn(replace_all 要求),用 RefCell 捕获传入值。
154        use std::cell::RefCell;
155        let html = r#"<img src="/uploads/2026/x.webp?w=100" alt="t">"#;
156        let received = RefCell::new(String::new());
157        let _ = wrap_images_with_blur_with(html, |p| {
158            *received.borrow_mut() = p.to_string();
159            Some((100, 100))
160        });
161        assert_eq!(
162            received.borrow().as_str(),
163            "2026/x.webp",
164            "rel_path 应去 query"
165        );
166    }
167
168    #[test]
169    fn wrap_images_with_blur_preserves_alt() {
170        let html = r#"<img src="/uploads/x.webp" alt="描述">"#;
171        let result = wrap_images_with_blur_with(html, |_| Some((10, 10)));
172        assert!(result.contains(r#"alt="描述""#), "placeholder 应保留 alt");
173        assert!(
174            result.matches(r#"alt="描述""#).count() == 2,
175            "两层 img 都应带 alt"
176        );
177    }
178
179    #[test]
180    fn wrap_images_with_blur_omits_alt_attr_when_empty() {
181        let html = r#"<img src="/uploads/x.webp">"#;
182        let result = wrap_images_with_blur_with(html, |_| None);
183        assert!(!result.contains("alt=\""), "无 alt 时不应生成空 alt 属性");
184    }
185
186    #[test]
187    fn full_pipeline_wrap_then_clean_preserves_slash() {
188        // 模拟完整渲染管线:wrap → clean_html,验证 sanitizer 不破坏斜杠。
189        // 注入确定 dimensions,脱离文件系统依赖。
190        let html = r#"<img src="/uploads/2026/06/18/abc.webp" alt="t">"#;
191        let wrapped = wrap_images_with_blur_with(html, |_| Some((800, 600)));
192        let cleaned = clean_html(&wrapped);
193        assert!(
194            cleaned.contains(" / "),
195            "clean_html must preserve slash in --ar, got: {}",
196            cleaned
197        );
198    }
199
200    // ---- wrap_tables 单元测试 ----
201
202    #[test]
203    fn wrap_tables_wraps_bare_table() {
204        let html =
205            "<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>";
206        let result = wrap_tables(html);
207        assert!(
208            result.starts_with("<div class=\"table-wrap\"><table>")
209                && result.ends_with("</table></div>"),
210            "应整体包裹一层 table-wrap, got: {}",
211            result
212        );
213    }
214
215    #[test]
216    fn wrap_tables_wraps_table_with_attributes() {
217        // pulldown-cmark 不会给 table 加属性,但正则需兼容带属性/自闭合起始的 table。
218        let html = r#"<table class="x"><tr><td>1</td></tr></table>"#;
219        let result = wrap_tables(html);
220        assert!(
221            result.contains("<div class=\"table-wrap\"><table"),
222            "应从 table 起始标签整体包裹, got: {}",
223            result
224        );
225        assert!(result.ends_with("</table></div>"));
226    }
227
228    #[test]
229    fn wrap_tables_wraps_multiple_tables() {
230        let html =
231            "<table><tr><td>1</td></tr></table>\n<p>间隔</p>\n<table><tr><td>2</td></tr></table>";
232        let result = wrap_tables(html);
233        let wrap_count = result.matches("<div class=\"table-wrap\">").count();
234        assert_eq!(wrap_count, 2, "两个 table 应各自包裹, got: {}", result);
235        // 中间段落不被误包
236        assert!(result.contains("\n<p>间隔</p>\n"));
237    }
238
239    #[test]
240    fn wrap_tables_handles_multiline_table() {
241        // pulldown-cmark 产出的 table HTML 是单行无换行的,但正则用 (?s) 跨行兼容手写 HTML。
242        let html = "<table>\n  <tr>\n    <td>1</td>\n  </tr>\n</table>";
243        let result = wrap_tables(html);
244        assert!(
245            result.starts_with("<div class=\"table-wrap\"><table>")
246                && result.ends_with("</table></div>"),
247            "跨行 table 应整体包裹, got: {}",
248            result
249        );
250    }
251
252    #[test]
253    fn wrap_tables_does_not_touch_non_table_html() {
254        let html = "<p>段落</p><ul><li>项</li></ul>";
255        let result = wrap_tables(html);
256        assert_eq!(result, html, "无 table 时应原样返回");
257    }
258
259    #[test]
260    fn wrap_tables_then_clean_preserves_div_and_table() {
261        // 端到端:wrap → clean_html,确认 sanitizer 放行 div.table-wrap 与内部 table 结构。
262        let html =
263            "<table><thead><tr><th>H</th></tr></thead><tbody><tr><td>v</td></tr></tbody></table>";
264        let wrapped = wrap_tables(html);
265        let cleaned = clean_html(&wrapped);
266        assert!(
267            cleaned.contains(r#"<div class="table-wrap">"#),
268            "clean_html 应保留 table-wrap div, got: {}",
269            cleaned
270        );
271        assert!(
272            cleaned.contains("<table>") && cleaned.contains("</table>"),
273            "clean_html 应保留 table 标签, got: {}",
274            cleaned
275        );
276    }
277}