From f50424e50b9737f0c1059176f329429f7243a2b7 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Sat, 14 Nov 2026 12:28:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(markdown):=20=E5=86=85=E5=B5=8C=20SVG=20?= =?UTF-8?q?=E6=8C=89=E7=99=BD=E5=90=8D=E5=8D=95=E4=BF=9D=E7=95=99=E7=BB=98?= =?UTF-8?q?=E5=9B=BE=E5=85=83=E7=B4=A0=EF=BC=8C=E8=84=9A=E6=9C=AC=E3=80=81?= =?UTF-8?q?=E5=A4=96=E9=83=A8=E5=BC=95=E7=94=A8=E5=92=8C=20style=20?= =?UTF-8?q?=E4=BB=8D=E8=A2=AB=E5=89=A5=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit markdown.render 的 Ammonia 过滤器放行 SVG 的图形、文字、use、image、渐变、图案、裁剪、蒙版、标记和不加载外部资源的滤镜,以及它们的几何与展示属性;script、foreignObject、style 元素、动画元素、feImage 和 style 属性仍被剥离,use/textPath 只能引用本文档的 #id。共享 fixture markdown-render-v1.json 增加内嵌 SVG 用例,Core 新增测试跑完整份 fixture。 --- rust/contracts/rust-core-api.md | 7 +- rust/crates/project/src/markdown.rs | 225 +++++++++++++++++++ rust/fixtures/editor/markdown-render-v1.json | 28 +++ 3 files changed, 259 insertions(+), 1 deletion(-) diff --git a/rust/contracts/rust-core-api.md b/rust/contracts/rust-core-api.md index b70ae6d4..a4a0b190 100644 --- a/rust/contracts/rust-core-api.md +++ b/rust/contracts/rust-core-api.md @@ -5452,7 +5452,12 @@ these attributes and the original decoded code text. Raw formatting HTML can survive, but scripts, executable attributes, embeds, arbitrary inline styles and unsafe URL schemes are removed in Core before -embedding. Windows asynchronously requests this existing command via its +embedding. Inline `` keeps its drawing elements (shapes, text, `use`, +`image`, gradients, patterns, clips, masks, markers and the non-loading filter +primitives) with their geometry and presentation attributes; `script`, +`foreignObject`, `style`, the animation elements, `feImage` and the `style` +attribute are removed, and `use`/`textPath` may only reference `#id` in the +same document. Windows asynchronously requests this existing command via its central dispatcher, discards obsolete responses and falls back to the sanitized fragment if syntax coloring fails. Render failures are shown as localized text; there is no frontend Markdown parser fallback. This does not promise Windows diff --git a/rust/crates/project/src/markdown.rs b/rust/crates/project/src/markdown.rs index 2621a3a8..7a102947 100644 --- a/rust/crates/project/src/markdown.rs +++ b/rust/crates/project/src/markdown.rs @@ -3,8 +3,182 @@ use ammonia::Builder; use comrak::{markdown_to_html, Options}; use serde::{Deserialize, Serialize}; +use std::borrow::Cow; use std::collections::HashSet; +/// SVG elements that only draw. Inline `` in a README is a picture: shapes, +/// text, gradients, clips, masks, markers and filters survive, while elements that +/// run or embed something else do not (`script`, `foreignObject`, `style`, the +/// animation elements, `feImage`). Names are the case html5ever gives SVG elements. +const SVG_TAGS: &[&str] = &[ + "svg", + "g", + "defs", + "symbol", + "use", + "title", + "desc", + "path", + "rect", + "circle", + "ellipse", + "line", + "polyline", + "polygon", + "text", + "tspan", + "textPath", + "image", + "linearGradient", + "radialGradient", + "stop", + "pattern", + "clipPath", + "mask", + "marker", + "filter", + "feBlend", + "feColorMatrix", + "feComponentTransfer", + "feComposite", + "feDropShadow", + "feFlood", + "feFuncA", + "feFuncB", + "feFuncG", + "feFuncR", + "feGaussianBlur", + "feMerge", + "feMergeNode", + "feMorphology", + "feOffset", +]; + +/// Geometry and presentation attributes of the SVG elements above. `style` stays +/// out, as it does for HTML; presentation attributes carry the same paint. +const SVG_ATTRIBUTES: &[&str] = &[ + "viewBox", + "preserveAspectRatio", + "width", + "height", + "x", + "y", + "x1", + "y1", + "x2", + "y2", + "cx", + "cy", + "r", + "rx", + "ry", + "fx", + "fy", + "d", + "points", + "pathLength", + "transform", + "fill", + "fill-opacity", + "fill-rule", + "stroke", + "stroke-width", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-dasharray", + "stroke-dashoffset", + "stroke-opacity", + "opacity", + "color", + "display", + "visibility", + "vector-effect", + "shape-rendering", + "text-rendering", + "paint-order", + "clip-path", + "clip-rule", + "clipPathUnits", + "mask", + "maskUnits", + "maskContentUnits", + "filter", + "filterUnits", + "primitiveUnits", + "marker-start", + "marker-mid", + "marker-end", + "markerWidth", + "markerHeight", + "markerUnits", + "refX", + "refY", + "orient", + "offset", + "stop-color", + "stop-opacity", + "gradientUnits", + "gradientTransform", + "spreadMethod", + "patternUnits", + "patternContentUnits", + "patternTransform", + "dx", + "dy", + "rotate", + "textLength", + "lengthAdjust", + "startOffset", + "text-anchor", + "dominant-baseline", + "alignment-baseline", + "baseline-shift", + "font-family", + "font-size", + "font-weight", + "font-style", + "letter-spacing", + "word-spacing", + "text-decoration", + "in", + "in2", + "result", + "mode", + "type", + "values", + "operator", + "k1", + "k2", + "k3", + "k4", + "stdDeviation", + "flood-color", + "flood-opacity", + "radius", + "tableValues", + "slope", + "intercept", + "amplitude", + "exponent", +]; + +/// Keeps a `use` or `textPath` reference inside the document (`#id`): an +/// external reference would load another file's drawing into this one. +fn svg_reference_filter<'u>( + element: &str, + attribute: &str, + value: &'u str, +) -> Option> { + if attribute == "href" + && matches!(element, "use" | "textPath") + && !value.trim_start().starts_with('#') + { + return None; + } + Some(Cow::Borrowed(value)) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] /// Markdown source to render with the shared dialect. @@ -90,6 +264,18 @@ pub fn render(request: MarkdownRenderRequest) -> MarkdownRenderResponse { sanitizer.add_tag_attributes(tag, &["align"]); } + // Inline SVG draws; Ammonia checks that an element only switches into the + // SVG namespace through and that every href passes the URL schemes. + sanitizer.add_tags(SVG_TAGS); + for tag in SVG_TAGS { + sanitizer.add_tag_attributes(tag, SVG_ATTRIBUTES); + } + sanitizer + .add_tag_attributes("use", &["href"]) + .add_tag_attributes("textPath", &["href"]) + .add_tag_attributes("image", &["href"]) + .attribute_filter(svg_reference_filter); + MarkdownRenderResponse { html: sanitizer.clean(&rendered).to_string(), } @@ -106,6 +292,45 @@ mod tests { .html } + #[test] + fn satisfies_shared_render_fixture() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../fixtures/editor/markdown-render-v1.json" + )) + .expect("Markdown render fixture should decode"); + for case in fixture["cases"].as_array().expect("fixture cases") { + let name = case["name"].as_str().expect("case name"); + let output = html(case["source"].as_str().expect("case source")); + for want in case["contains"].as_array().expect("contains") { + let want = want.as_str().expect("contains entry"); + assert!(output.contains(want), "{name}: lacks {want:?} in {output}"); + } + for bad in case["absent"].as_array().expect("absent") { + let bad = bad.as_str().expect("absent entry"); + assert!(!output.contains(bad), "{name}: has {bad:?} in {output}"); + } + } + } + + #[test] + fn inline_svg_keeps_drawing_and_drops_what_runs_or_embeds() { + let output = html( + "tu\n", + ); + + assert!(output.contains("\n\nLogo\n\n\n\nLithe\n\n\n\n
embedded
\n\n\n
\n

\n", + "contains": [ + "Lithe", + "Logo", + "