项目进展2026-09-18

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>

识别规则(两端一致):

  1. 行首(容忍 ≤3 空格缩进)为 <svg 且后接空白或 > → 图形起点;
  2. 起点的前一行若为 <?xml … ?> 声明,则一并跳过(设计工具导出文件常见);
  3. 向后收集到行尾为 </svg> 的那一行为止;
  4. 收集过程中若遇到空行、且空行之后的行不再以 < 开头 → 判定为未闭合残片,立即停止(避免把后续正文吞进图形块);
  5. 收集到的尾部空行会被裁掉。

第 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.etsMermaid:同一页面 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 文件链接![alt](x.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文档更新
← 返回文章列表