yggdrasil/api/markdown/
post_process.rs1pub(super) fn wrap_images_with_blur(html: &str) -> String {
11 wrap_images_with_blur_with(html, crate::api::image::get_image_dimensions)
12}
13
14fn 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 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: ®ex::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 let rel_path = src
39 .strip_prefix("/uploads/")
40 .unwrap_or(src)
41 .split('?')
42 .next()
43 .unwrap_or("");
44
45 let ar_style = dims_fn(rel_path)
48 .map(|(w, h)| format!(" style=\"--ar:{} / {};\"", w, h))
49 .unwrap_or_default();
50
51 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
68pub(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: ®ex::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 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 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 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 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 assert!(result.contains("blur-img"));
148 }
149
150 #[test]
151 fn wrap_images_with_blur_strips_query_from_rel_path() {
152 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 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 #[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 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 assert!(result.contains("\n<p>间隔</p>\n"));
237 }
238
239 #[test]
240 fn wrap_tables_handles_multiline_table() {
241 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 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}