MD 阅读 · SVG 图形渲染
Markdown 文档里的 SVG 矢量图,鸿蒙端与网页端现在都能直接渲染成图形:支持围栏与裸内联两种写法、点击放大、手势缩放,并做完整套安全清洗(脚本、外链、事件属性全部拦截)。
版本:2026-09-18 范围:鸿蒙端(ArkTS)+ 网页端(原生 JS) 目标:让 Markdown 文档中的 SVG 矢量图在两端都能直接渲染成图形,形成完整闭环。
一、需求与目标
原先两端只支持 `mermaid 围栏(Mermaid 图),文档里直接粘贴的 <svg> 只能当纯文本显示。
本次要让 SVG 与 Mermaid 处在同一体验等级:
| 目标 | 说明 |
|---|---|
| 语法入口 | 围栏与裸内联两种写法都要支持(设计工具导出的 .svg 可直接粘贴) |
| 渲染 | 忠实还原矢量图形,等比缩放,不模糊 |
| 交互 | 点击放大查看,缩放手势与 Mermaid 完全一致 |
| 安全 | SVG 是可执行载体,必须清洗后再渲染(两端一致) |
| 降级 | 渲染失败时给明确提示,并保留源码可读 |
| 一致性 | 两端解析规则、尺寸策略、交互行为对齐,避免"同一文档两种效果" |
二、语法入口
2.1 围栏写法(推荐)
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 80">
<rect x="4" y="4" width="192" height="72" rx="8" fill="#eef4ff" stroke="#2a6fdb"/>
<text x="100" y="46" font-size="18" fill="#2a6fdb" text-anchor="middle">SVG 直出</text>
</svg>
解析器对语言标记大小写不敏感(svg / SVG / Svg 均可)。
2.2 直接粘贴(裸内联)
把 .svg 文件内容原样粘贴即可,无需围栏:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120">
<circle cx="60" cy="60" r="50" fill="none" stroke="#2a6fdb" stroke-width="12"/>
</svg>
识别规则(两端一致):
- 行首(容忍 ≤3 空格缩进)为
<svg且后接空白或>→ 图形起点; - 起点的前一行若为
<?xml … ?>声明,则一并跳过(设计工具导出文件常见); - 向后收集到行尾为
</svg>的那一行为止; - 收集过程中若遇到空行、且空行之后的行不再以
<开头 → 判定为未闭合残片,立即停止(避免把后续正文吞进图形块); - 收集到的尾部空行会被裁掉。
第 4 条是实测踩坑:早期实现只认
</svg>,遇到截断的 SVG 会把整篇文档剩余内容全部当作图形源码。
三、两端架构
┌─────────────── 解析层(两端同源规则) ───────────────┐
Markdown 文档 ──► │ MarkdownParser.ts / parser.js │
│ ```svg 围栏 → { type:'svg', code } │
│ <svg>…</svg> → { type:'svg', code } │
└──────────────────────┬──────────────────────────────┘
│
┌────────────────────────────┴────────────────────────────┐
▼ ▼
── 鸿蒙端(ArkTS)── ── 网页端(原生 JS)──
MarkdownView.BlockItem('svg') app.js renderBlock('svg')
│ │
SvgView.ets(Web 组件) graph.js renderSvgBlock()
├─ SvgSanitizer.ts(字符串级清洗) ├─ sanitizeSvgCode()(预清洗 + DOM 白名单)
└─ rawfile/graph.html └─ 直接插入 DOM(浏览器原生解析)
├─ DOM 级白名单清洗
├─ renderSvg()
└─ 缩放手势 / 高度回读
│ │
└──────────────► 全屏查看(各自的查看器)◄───────────────┘
鸿蒙:Index.ets FullscreenViewer(Web 复用 graph.html)
网页:graph.js openGraphViewer(overlay + transform)
3.1 鸿蒙端要点
graph.html一页两用:Mermaid 与 SVG 共用同一个 rawfile 页面,只是入口函数不同(renderGraph/renderSvg,另有统一入口renderAny(kind, code, fitWidth))。
复用一个页面而不是两个 HTML 的原因:文档页内不会同时挂载两套 Web 组件;缩放、平移、双击这些手势逻辑只维护一份;全屏层也不必按类型切换 src。
- 高度自适应:内联模式下图形的宽度由 CSS 决定、高度按比例,ArkTS 侧拿不到,需要在渲染后回读
container.scrollHeight并写回@State viewHeight。
量两次(300ms / 900ms):字体与内嵌图片可能异步就绪,只量一次会偏矮导致图形被裁。
- 全屏态传递:点击内联图 →
AppStorage写入两个键: graphFullscreenKind:'mermaid'|'svg'graphFullscreenCode:源码原文
Index.ets 监听 graphFullscreenCode,非空即叠加全屏层,并按 graphFullscreenKind 选择调用 renderSvg 还是 renderGraph。
返回键优先退出全屏(onBackPress 返回 true),而不是退出应用。
- 组件总览:
| 文件 | 职责 |
|---|---|
utils/MarkdownParser.ts | 解析 svg 块(围栏 + 裸内联) |
utils/SvgSanitizer.ts | 字符串级清洗 + looksLikeSvg() 粗判 |
components/SvgView.ets | 内嵌 SVG:Web 渲染、高度回读、点击进全屏 |
components/MermaidView.ets | Mermaid:同一页面 renderGraph |
components/MarkdownView.ets | 块级分发(新增 svg 分支) |
pages/Index.ets | 图形全屏层(按 kind 选择入口函数) |
resources/rawfile/graph.html | 渲染页:DOM 白名单清洗、渲染、缩放手势 |
3.2 网页端要点
js/graph.js(新增模块):清洗 + 内联渲染 + 通用放大查看器。app.js:renderBlock新增'svg'分支;Mermaid 渲染完成后调用makeZoomable(),两类图形共用同一个查看器。- 查看器交互:滚轮缩放(指针锚点)、拖拽平移、双击在"适应窗口 ↔ 2.5×"间切换、
+/-/0/ESC快捷键、触摸双指捏合、点遮罩空白处关闭。
实现上用 transform: translate() scale()(transform-origin: 0 0)配合 requestAnimationFrame 节流,避免逐帧改布局。
- 资源版本:
index.html里的?20260918b会经app.js的import.meta.url传递给动态 import 的parser.js/graph.js。改动任一 js 或 css 后必须同步 bump 这个参数,否则浏览器会继续用缓存。
四、安全模型(两层清洗)
SVG 是完整的 XML,能携带脚本与外部资源,必须当作不可信输入。网页端尤其危险:分享链接打开的可能是他人文档。
第一层:字符串级预清洗(两端各有一份实现)
| 处理对象 | 处置 |
|---|---|
<!DOCTYPE …>(含内部子集) | 整体删除(防 XXE / 实体炸弹) |
<?xml … ?> | 删除(插入 HTML 文档时无意义且干扰解析) |
<script>(配对与自闭合) | 连内容删除 |
<foreignObject> | 连内容删除(可嵌 HTML + 脚本) |
<iframe/embed/object/audio/video/canvas> | 连内容删除 |
on* 事件属性 | 双引号 / 单引号 / 无引号三种写法全覆盖 |
javascript: / vbscript: / data:text/html | 删除 |
CSS expression( | 删除 |
第二层:DOM 白名单裁剪(graph.html 内 / graph.js 内)
在 DOM 树上递归遍历:
- 命中丢弃标签集合 → 整棵子树移除(
script/foreignObject/iframe/embed/object/audio/video/canvas/link/meta/base/ 表单控件等); - 属性级:删除所有
on*;href/xlink:href仅允许同文档锚点(#id)与data:image/*;删除任何src;删除含expression(/url(http…)/url(//)/url(javascript…)的style; <style>元素内的 CSS 文本:去掉@import与外部url();- 判断前先剔除值中的空白与不可见字符,防止
java\nscript:这类拆分绕过; - XML 解析失败时退回 HTML 解析器(对手写 SVG 宽容度更高),此时字符串级预清洗已生效,降级路径不会成为安全缺口。
被移除的节点会计数,渲染时在图形上方给出提示:已按安全策略移除 N 处脚本 / 外链内容。
验证矩阵(网页端实测,10/10 通过)
| 用例 | 结果 |
|---|---|
内联 <script>alert(1)</script> | 已移除 |
onload / onclick 事件属性 | 已移除 |
xlink:href="javascript:…" | 已移除 |
<image href="http://evil…"> | 已移除 |
foreignObject 内嵌 <img onerror> | 已移除 |
<!DOCTYPE + ENTITY(XXE) | 已移除 |
<style>@import url(http://evil…)</style> | 已移除 |
href="java\nscript:…" 拆分绕过 | 已移除 |
正常图形(渐变 / use #id / 文本) | 完整保留 |
| 手写有误的 SVG(走 HTML 兜底) | 正常渲染 |
五、尺寸与交互策略
| 场景 | 策略 |
|---|---|
| 内联展示 | 保留图形自身宽高(小图不被拉伸、图标不变形),超宽图形由 max-width:100% 收缩;缺宽高时用 viewBox 补出 |
| 全屏查看 | 保持自然尺寸,由手势 / 查看器缩放 |
| 缩放上限(鸿蒙) | Mermaid:按图中正文字号反推,使放大后正文 ≈ 30px;SVG:无可靠正文字号,取 max(4, fitScale × 4),上限 12× |
| 双击 | 在"整图可见"与"细节放大"之间切换 |
| 平移边界 | 画面小于视口时居中,大于视口时限制在 [视口 - 内容, 0] 之间,防止拖出可视区 |
早期实现把内联图形一律压到容器宽度,结果 24×24 的图标被拉成整屏宽。改为保留自然尺寸后,图形大小与作者预期一致。
六、验证结果
网页端(本地 http.server + CDP 实测)
| 项 | 结果 |
|---|---|
| 围栏 SVG 渲染 | 2/2 块渲染成功,触发尺寸 320×120、120×120(自然尺寸生效) |
| 裸内联 SVG 渲染 | 成功 |
| 图形放大查看器 | 打开正常,fit 后 386%,标题"SVG 图形",控制按钮齐全 |
| Mermaid 回归 | 渲染 1/1,点击放大绑定成功,标题"Mermaid 图形",206% |
| 页面 JS 异常 | 无 |
| 安全清洗 | 10/10 通过 |
鸿蒙端
- ArkTS 全量编译(
assembleHap,debug)通过,无语法错误。 - 真机交互(点击全屏、双指缩放)需在设备上人工确认。
七、已知限制与后续可选项
| 限制 | 说明 / 可选改进 |
|---|---|
foreignObject 内容被清除 | 少数设计工具(浏览器"复制为 SVG")用 <foreignObject> 承载文本,会丢失该部分;出于安全取舍保留现状。若实际遇到,可在提示中给出明确说明 |
| 未做 SVG 内文字重排 | 白名单内的 <text> 原样呈现,不参与 Markdown 的行内解析(设计如此) |
| 单张超大 SVG | 数十万元素的超复杂 SVG 在低端机上会拖慢渲染;必要时加"体积阈值 + 提示" |
.svg 文件链接 |  按图片处理(网页端走 <img>,鸿蒙端离线环境降级为占位提示) |
八、变更文件清单(2026-09-18)
鸿蒙端(mdreader 与 mdreader-lite-app 两份同步)
| 文件 | 变更 |
|---|---|
entry/src/main/ets/utils/MarkdownParser.ts | 新增 svg 块类型与两种识别路径;段落收集排除裸 SVG 起点 |
entry/src/main/ets/utils/SvgSanitizer.ts | 新增:字符串级清洗 |
entry/src/main/ets/components/SvgView.ets | 新增:内嵌 SVG 渲染组件 |
entry/src/main/ets/components/MermaidView.ets | 改载 graph.html;全屏键改为 graphFullscreenKind/Code |
entry/src/main/ets/components/MarkdownView.ets | 新增 svg 分支 |
entry/src/main/ets/pages/Index.ets | 全屏层支持两类图形,按 kind 选择渲染入口 |
entry/src/main/resources/rawfile/graph.html | 新增:合并原 mermaid.html,加入 SVG 能力(原文件删除) |
entry/src/main/resources/rawfile/demo.md | 新增"八、SVG 图形"章节与更新日志条目 |
网页端(c:/bzm/page/online/md)
| 文件 | 变更 |
|---|---|
js/parser.js | 同上解析规则 |
js/graph.js | 新增:清洗 + 内联渲染 + 通用放大查看器 |
js/app.js | 接入 graph 模块;svg 分支;Mermaid 接入放大查看器 |
styles.css | 图形块、提示角标、查看器样式 |
index.html | 资源版本 bump 到 ?20260918b |
README.md / doc/功能指南.md | 文档更新 |