鸿蒙 PC Markdown 编辑器离线专业渲染管线
数学公式、流程图和代码高亮经常被归为"Markdown 预览插件",但在桌面编辑器里,它们实际上共同构成了一条不可信内容处理管线。输入来自用户文档,解析器和渲染器来自不同生态,输出最终进入具备 DOM 能力的 ArkWeb。只要其中一个环节把"文档内容"误当成"应用配置"或"可信 HTML",离线编辑器也可能出现脚本注入、界面阻断、内存失控和异步结果串文档等问题。
本文讨论一套面向鸿蒙 PC 的工程实现:使用 markdown-it 识别扩展语法,使用 KaTeX 生成公式,使用 Mermaid 生成图表,使用 Highlight.js 处理代码着色,再以分层净化、资源上限和代际取消把它们收敛为可验证的预览能力。文章中的代码来自 OhMarkdown 的真实实现,仓库地址为 https://gitcode.com/VON-/codex_md_oh,对应功能提交为 f133bbe。本文只讨论已经落地并验证的应用内预览,不把尚待开发的专业 HTML、PDF 和图片导出描述为现成功能。
专业渲染不是三个互不相关的插件
公式、图表和代码块表面上是三种视觉组件,输入与风险却完全不同。公式输入是一段 TeX 风格表达式,渲染结果含普通 HTML、MathML、大量类名和受控的内联几何样式;Mermaid 输入近似声明式程序,渲染器会解析图类型、计算布局并生成 SVG;代码高亮输入本应是纯文本,输出只需要一层带类名的 span。如果对三类结果使用同一个宽松 HTML 白名单,就会把最复杂输出需要的权限错误地授予最简单输出。
因此实现没有设计一个"任意插件返回 HTML"的通用接口,而是定义三个固定类型:math、mermaid 和 code。每个类型有独立的源码长度上限、输出净化策略、错误表现和降级路径。这个选择看似保守,却直接减少了权限交叉:代码高亮不能借用 SVG 能力,Mermaid 不能借用普通 HTML 的表单或外部对象能力,公式也不能通过 KaTeX 的可信扩展插入任意标签。
这条管线的顺序是:Markdown 解析阶段只产生带源码行号的安全占位节点;基础 HTML 先经过一次 DOMPurify;占位节点进入对应本地渲染器;渲染结果再按类型净化;最后才替换当前节点。任何一步失败都只替换当前占位,不清空整个预览区域,也不回写编辑器文档。
先明确语法契约
OhMarkdown 接受 $...$ 行内公式、$$...$$ 块公式、语言名为 mermaid 的围栏代码块,以及其他带语言名的代码围栏。公式规则不是对 Markdown 文本做全局正则替换,而是安装到 markdown-it 的行内和块级规则链。这样可以让 Markdown 自己处理转义、代码围栏和块边界,避免公式识别穿过不应该进入的区域。
下面是实际的规则安装入口。它在 GFM 任务列表插件之后执行,最终覆盖围栏渲染函数,把 Mermaid 与普通代码块转换成不同占位结构:
ts
const markdownRenderer = new MarkdownIt({
html: false,
linkify: true,
typographer: false,
breaks: false
});
markdownRenderer.use(taskLists, { enabled: false, label: true, labelAfter: true });
installProfessionalMarkdownRules(markdownRenderer);
const professionalPreviewEnhancer = new ProfessionalPreviewEnhancer();
语法契约需要克制。没有实现任意 TeX 宏配置,没有允许 Markdown 覆盖 Mermaid 全局配置,也没有自动猜测未知代码语言。未知语言仍然是合法代码块,只是保持纯文本显示。这样做保证源码可迁移:文件继续是标准围栏与常见数学扩展,不需要写入 OhMarkdown 私有节点、缓存 ID 或序列化后的 SVG。
行内公式为什么不能只靠一个正则
最简单的公式实现通常是把 /$([^$]+)$/ 一类表达式套到整篇文档上。它会迅速遇到货币符号、转义美元符、连续美元符、代码区块和跨行内容。真实实现使用游标查找结尾,并对候选字符做转义与空白判断。开头后面不能是空白,结尾前面也不能是空白;$$ 不会误入行内规则;奇数个反斜线表示美元符已经转义。
ts
function findInlineMathEnd(source: string, from: number): number {
let cursor = from;
while (cursor < source.length) {
const candidate = source.indexOf('$', cursor);
if (candidate < 0) {
return -1;
}
if (!isEscaped(source, candidate) && source[candidate - 1] !== '$' &&
source[candidate + 1] !== '$' && !/\s/.test(source[candidate - 1] ?? '')) {
return candidate;
}
cursor = candidate + 1;
}
return -1;
}
规则只把公式原文放进 <code class="professional-render-source">,并记录 data-source-line。此时公式仍然是转义后的文本,不是 KaTeX 输出。基础 Markdown HTML 经过 DOMPurify 后,增强器才读取 textContent。这个顺序避免了把公式字符串拼回 HTML 属性,也让错误公式仍有确定的来源位置。
行内 token 本身没有完整块级行号,所以实现还记录 token 在段落内容中的偏移,再统计偏移前的换行数。这样多行段落中的行内公式不会全部错误地指向段落第一行。定位并不追求 TeX 子表达式的列号,它承诺的是用户点击错误后能回到包含该公式的 Markdown 行,这一粒度对桌面修复流程足够稳定。
块公式要尊重 Markdown 的行边界
块公式支持同一行 $$...$$,也支持起始和结束标记分别占行。解析器逐行寻找只在行尾留下空白的关闭标记;没有找到关闭标记时返回 false,让后续 Markdown 规则继续处理,而不是吞掉文档剩余部分。这一点直接决定错误隔离是否可信:一个忘记闭合的块公式不应该把后面几十页都变成公式源码。
块 token 的 map 保存开始和结束行,渲染占位时取开始行作为错误位置。内容在进入 KaTeX 前只做首尾空白整理,不改写内部换行、反斜线或宏文本。文档模式切换不会重新序列化 token,保存和恢复仍然只面对 CodeMirror 中的原始 Markdown。
KaTeX 的可信边界
KaTeX 提供的 trust 选项决定某些可能产生链接、HTML 或外部资源的命令是否可信。桌面编辑器打开的文档不等于可信配置,因此实现固定 trust: false,同时启用抛错和资源上限:
ts
const rendered = katex.renderToString(source, {
displayMode: element.dataset.displayMode === 'block',
output: 'htmlAndMathml',
throwOnError: true,
trust: false,
maxExpand: 1000,
maxSize: 20,
strict: (errorCode): 'error' | 'ignore' =>
errorCode === 'htmlExtension' ? 'error' : 'ignore'
});
output: 'htmlAndMathml' 同时服务视觉显示和辅助技术。HTML 部分负责稳定排版,MathML 为能够理解数学语义的工具提供结构信息。throwOnError: true 不意味着整篇预览抛出异常,而是把错误交给当前公式节点的 try/catch,由应用生成本地化错误按钮。maxExpand 防止宏展开失控,maxSize 限制异常尺寸命令,单公式 20,000 字符上限在进入引擎前进一步截断攻击面。
KaTeX 输出仍然不能因为来自成熟库就跳过净化。实现允许公式必需的 HTML、MathML 与 SVG profile,但显式禁止 script、style、iframe、object、embed 和 form。KaTeX 用于几何布局的元素级 style 属性由白名单保留,因为 trust: false 和 HTML 扩展拒绝已经限制输入能力;整个 <style> 标签仍被禁止。这里的重点是区分"库为了排版生成的受控样式属性"和"文档作者注入的活动标签"。
Mermaid 必须把文档当作程序输入
Mermaid 比普通 Markdown 扩展更接近一个小型语言运行时。它不仅解析文本,还会根据图类型加载实现、计算布局、生成标识符和 SVG。安全配置不能由文档决定,否则作者可以使用初始化指令把应用的 strict 改为更宽松模式。因此实现不只是设置默认值,还在解析前拒绝任何 %%{...} 配置指令。
ts
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
maxTextSize: MAX_MERMAID_CHARACTERS,
maxEdges: 500,
htmlLabels: false,
theme: options.theme === 'dark' ? 'dark' : 'neutral',
fontFamily: 'HarmonyOS Sans, Noto Sans SC, sans-serif',
secure: [
'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
'suppressErrorRendering', 'theme', 'themeCSS', 'themeVariables',
'htmlLabels', 'fontFamily'
]
});
startOnLoad: false 避免 Mermaid 自己扫描全页,应用只处理已经由 markdown-it 标记的节点。securityLevel: 'strict' 禁止点击回调等宽松能力,suppressErrorRendering: true 防止引擎把错误 SVG 写到页面其他位置。主题与字体由应用传入,文档不能重定义。每张图先 parse,成功后才 render;单图失败只把当前 figure 替换为错误按钮。
图表数量限制为 24,单图文本限制为 50,000 字符,边数限制为 500。这些数字不是性能承诺,而是预览保护线。达到上限时,已经完成的正文和前序图表继续显示,超出部分明确报错。真正的大规模图表性能仍需要在 Release 真机上测量,不能用开发机一次成功就取消保护。
SVG 生成后仍要二次净化
严格模式是必要条件,不是最终输出白名单。Mermaid 及其依赖会随版本更新,输出结构也可能变化。应用在拿到 SVG 字符串后再次执行 DOMPurify,SVG profile 只保留绘图需要的元素,显式移除活动或可跳转节点:
ts
function sanitizeMermaidSvg(svg: string): string {
return String(DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ['script', 'foreignObject', 'iframe', 'object', 'embed', 'a'],
FORBID_ATTR: ['href', 'xlink:href']
}));
}
为什么连 <a> 也移除?因为预览中的普通 Markdown 链接已经有受限的本地导航协议,会交给 ArkTS 重新解析工作区边界。允许 Mermaid SVG 自带链接会产生第二条难以统一审计的导航通道。图表阅读是 G3-07 的目标,图内可执行点击不是目标,删除它比增加新的 Bridge 特例更可靠。
净化后还会检查结果是否真的包含 <svg>。如果白名单把异常输出清空,就显示可定位错误,不把空白区域伪装成成功。成功节点增加 role="img" 和本地化 aria-label,让图表至少具备来源行和类型语义;更细的节点级无障碍描述仍属于后续可用性专项。
代码高亮的最小权限原则
代码高亮不需要 HTML、MathML 或 SVG。Highlight.js 返回的内容只需要 span 和 class,所以净化白名单可以非常窄:
ts
function sanitizeHighlightedCode(html: string): string {
return String(DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['span'],
ALLOWED_ATTR: ['class']
}));
}
引擎通过 import('highlight.js/lib/common') 延迟加载常用语言集合。只有预览里出现普通代码块时才执行加载;没有代码的文档不会运行语言注册逻辑。语言名先经过长度和字符集限制,再调用 getLanguage。已知语言使用显式 highlight(source, { language, ignoreIllegals: true }),未知语言不做自动探测,原样显示为安全文本。
不自动探测是桌面编辑器中的重要取舍。探测会让大代码块在多种语法间反复评分,也可能把普通日志错误标成某种语言。Markdown 围栏已经提供了作者声明,应用尊重声明;没有声明就保持纯文本。单代码块超过 200,000 字符时同样回退,不阻止正文和编辑操作。
测试语料故意把字符串 "<script>alert(1)</script>" 放在 TypeScript 代码中。最终预览可以看到完整字符串,但 DOM 中没有 script 节点。这个断言比肉眼看到尖括号更可靠,因为安全目标是"文本仍在,活动节点不存在"。
异步渲染最容易出现串文档
KaTeX、Mermaid 和 Highlight.js 都以动态导入进入增强器。用户可能在依赖加载或图布局期间切换标签、打开新文档、修改源码、改变主题或切回源码模式。如果旧 Promise 完成后仍写 DOM,就会出现很危险的错觉:标签标题是文档 B,预览内容却来自文档 A。
实现使用单调递增的 generation 作为渲染代际。每次增强或取消都会产生新代际,异步步骤在写入前同时检查代际和节点是否仍连接在当前 DOM:
ts
export class ProfessionalPreviewEnhancer {
private generation = 0;
cancel(): void {
this.generation += 1;
}
enhance(root: HTMLElement, options: ProfessionalRenderOptions): void {
const generation = this.generation + 1;
this.generation = generation;
void Promise.allSettled([
this.enhanceMath(root, generation, options),
this.enhanceCode(root, generation, options),
this.enhanceMermaid(root, generation, options)
]);
}
private isActive(element: HTMLElement, generation: number): boolean {
return this.generation === generation && element.isConnected;
}
}
代际检查既发生在模块加载后,也发生在 Mermaid 的 parse 和 render 之后。Promise.allSettled 让三类增强互不阻断,但真正的错误仍由各类型转换为局部 UI。Playwright 回归会先触发 Mermaid 渲染,再立即切到一份只有标题的新文档,等待旧任务可能完成后确认页面仍是新标题且不存在旧图表节点。
错误界面必须能进入修复流程
只显示"渲染失败"会让用户在长文档里继续寻找问题。占位节点记录源行,错误按钮标题显示"公式错误 · 第 N 行"或"图表错误 · 第 N 行",详情截断到 240 字符并以纯文本写入。点击按钮调用统一的 navigateToSourceLine:切换源码模式,限制行号到当前文档合法范围,再把 CodeMirror 选区移动到该行。
错误详情不直接拼入 innerHTML。即使第三方解析器把用户输入包含在错误消息中,textContent 也会保证它只是文本。按钮具备键盘焦点、悬停状态和足够高度,错误区使用独立的红色语义变量;深色模式下使用另一组对比色,而不是简单反色。
代码高亮失败选择不同降级:代码原文仍在,节点记录 error 状态和标题,不用错误按钮替换整段代码。原因是代码本身就是用户需要阅读的内容,高亮只是增强;公式和图表的源码通常不等同于可读结果,因此错误按钮更合适。
主题切换为什么需要重新渲染
代码高亮主要依赖 CSS 变量,主题切换后可以直接换色;KaTeX 大多继承文字颜色;Mermaid 却会把主题颜色写进生成 SVG。只修改根节点 data-theme 会留下浅色图表嵌在深色预览中的不一致。因此 setTheme 在预览或分栏模式重新执行 renderPreview,在源码模式则只标记预览为脏并取消旧任务,等用户真正打开预览时再渲染。
语言切换也采用相同策略,因为错误标题和图表辅助名称必须使用当前界面语言。重绘不会重建 CodeMirror 编辑状态,不改变撤销历史、脏标记或保存基线。主题和语言属于视图状态,Markdown 文档仍然只有一个事实来源。
鸿蒙 PC 窗口中的布局约束
桌面窗口可以自由缩放,公式、SVG 和长代码行不能把整个工作区撑宽。块公式、图表和代码块都设置 max-width: 100% 与局部横向滚动。Mermaid 容器有稳定边框和背景,SVG 使用 width: auto; max-width: 100%; height: auto,短图居中,复杂图只在自己的容器滚动。
错误按钮使用 grid-template-columns: minmax(0, 1fr),详情允许断词,避免解析器返回长标识符后撑破侧栏。代码块保留 overflow-x: auto,不把源代码强制折行成难以复制的形式。页面在 1440 x 900 最终产物测量中 scrollWidth 与 clientWidth 都是 1440,没有全页横向溢出。
下图来自最终 Debug HAP 在 HarmonyOS MateBook Pro 2in1 模拟器中的真实运行界面。文档同时包含公式、Mermaid 流程图和 TypeScript 代码,三类结果在应用内部同屏显示:

截图分辨率为 3120 x 2080,SHA-256 为 8ee32d7a0e2235837118dd5ee3532160e9b1c48471d09fae667de2b98fe7b519。它记录的是 ArkUI 工作台承载 ArkWeb 的最终应用,不是单独浏览器页面或设计稿。
CSP 与离线资源必须同时成立
"运行时不请求 CDN"和"产物没有外链"是两个不同检查。最终 editor/index.html 是一个本地单文件,脚本、样式和 KaTeX 字体全部内联;自动化断言不存在 <script src> 和外部样式 <link>。页面 CSP 继续使用 connect-src 'none',即使某个新依赖未来尝试建立网络连接,也会先被页面策略阻断。
应用没有申请互联网权限,ArkWeb Bridge 也没有因为专业渲染新增方法。公式、图表和高亮完全在 Web 侧处理,不需要把文档交给原生服务,更不需要上传远端。离线不是宣传用语,而是可以从依赖打包、CSP、权限清单和模拟器断网路径分别审计的属性。
开发服务器会使用 WebSocket 热更新并从本地地址加载字体,因此严格生产 CSP 不适合用开发服务器控制台来判断最终资源状态。测试同时覆盖开发交互和最终单 HTML:安全与性能测量直接打开构建产物,得到控制台错误 0;Playwright 功能回归仍由本地测试服务运行。把两类环境分开可以避免将开发工具请求误认为产品网络依赖。
包体增长需要公开记录
Mermaid 支持多种图类型,完整本地运行时明显增大包体。G3-06 的 Debug HAP 约为 1.61 MiB,加入专业渲染后最终 Debug HAP 为 6,638,278 字节。单 HTML 为 5,842,276 字节,系统 gzip 后为 2,212,681 字节。这个增长不能被"离线能力"四个字掩盖,它会影响安装包、冷启动解析和内存峰值。
依赖审查发现 Mermaid 自身依赖 KaTeX。如果应用直接使用不同的大版本,就会在 node_modules 和打包图里留下两套公式引擎。最终将直接依赖统一为 KaTeX 0.16.47,与 Mermaid 的兼容范围合并,移除一份重复包。与去重前相比,原始单 HTML 减少约 266 KiB,gzip 减少约 78 KiB。Highlight.js 使用 lib/common 而不是全语言全集,控制常用语言集合。
当前没有为了继续减包而自行裁剪 Mermaid 内部图类型注册表,因为这会进入更难维护的私有组合路径,并可能让"支持 Mermaid"的语义变得含糊。更进一步的按图类型拆分需要独立兼容性语料和 Release 测量,在没有证据前不把复杂构建技巧混进当前纵切。
测试必须覆盖正确结果和失败形态
专业渲染的测试不能只截一张成功页面。自动化包含四组关键回归:正确与错误公式同文档,确认两个公式成功、一个公式失败且后续正文存在;正确 Mermaid 与配置注入图同文档,确认一张 SVG 成功、危险指令被拒绝;已知与未知代码语言同文档,确认 TypeScript 有语义类名、未知语言保持纯文本;快速切换文档,确认旧 Promise 不会写回。
Mermaid 安全断言直接查询最终 DOM 中 script、foreignObject 和 a 的数量,而不是只查字符串。代码注入断言确认尖括号文本仍然可见且脚本节点为 0。生产包测试检查外链标签与 CSP。全量 Playwright 最终为 38/38,包含恢复、图片、搜索、链接、多标签、换行和导出基础回归,避免专业预览破坏已有编辑闭环。
鸿蒙原生 ohosTest 最终为 8/8,Failure 和 Error 均为 0。它不测试 Web 渲染细节,而是确认 Web 包增大后,字节保真、恢复、图片落盘、TaskPool 搜索、链接解析和大纲服务仍可运行。不同测试层有不同责任,不能因为模拟器截图成功就省略浏览器 DOM 安全断言,也不能因为浏览器通过就声称 HAP 已验证。
一次小语料性能测量能说明什么
在 1440 x 900 的无头 Chromium 中直接打开最终离线单 HTML,输入一条公式、一张流程图和一个 TypeScript 代码块。三类节点首次全部进入 ready 用时 83 ms,依赖已加载后的深色主题重绘用时 19 ms,控制台错误为 0。这个结果证明当前小语料没有明显阻断,也为后续回归提供同一测量口径。
它不能证明复杂文档的 P95,不能替代鸿蒙 PC 真机,也不能直接与其他编辑器比较。Mermaid 图布局成本与节点、边和图类型有关;数百公式还涉及 DOM 数量和字体排版;代码高亮成本与单块长度相关。项目把 24 图和各类字符上限作为防护,同时把 Release 真机冷启动、内存峰值和复杂图压力留到 Beta 质量评审统一执行。
这种诚实边界本身是产品工程的一部分。性能数据只有设备、构建模式、语料和计时起止都明确时才可复用。开发机 83 ms 是本轮回归证据,不是"比竞品快多少"的营销结论。
与导出管线的边界
应用内预览采用异步 DOM 增强,而现有基础 HTML 导出直接调用同步 Markdown 净化函数,打印准备也不会等待 Mermaid 布局。因而 G3-07 完成不代表带公式和图表的导出已经与预览一致。把当前预览 DOM 粗暴复制进导出同样不够:需要处理 KaTeX 字体、自包含 SVG、主题样式、图片资源、打印就绪信号和输出后的安全净化。
下一阶段应建立可等待的渲染完成协议,让 HTML、PDF 和图片输出消费同一份经过安全收敛的专业结果,同时保持导出 CSP 和资源内联。失败图表在导出中是显示错误占位、保留源码还是阻断操作,也需要明确产品策略。本文刻意保留这个边界,避免用一张成功预览截图冒充输出闭环。
为什么不做通用插件系统
一个通用 Markdown 插件 API 看起来可以统一公式、图表和未来扩展,但它同时需要定义插件权限、生命周期、异步取消、输出净化、资源访问、版本兼容和故障隔离。当前只有三个已确认类型,且它们的权限差异很大。现在创建插件体系会把安全边界从三个可审查分支扩大成任意第三方代码入口。
独立 ProfessionalPreviewEnhancer 是模块级抽取,不是插件平台。它解决真实存在的复杂度:主文件不再同时容纳三个引擎的动态导入、安全配置和错误处理;单一调用方仍然清晰;测试可以通过稳定的 data-professional-kind 与 data-render-state 观察结果。没有数据库、事件总线或新的跨模块状态框架,仍然符合既定 Level 2 与 D2 边界。
对鸿蒙 PC 产品力的实际价值
专业 Markdown 文档经常把论证、架构和实现放在同一页:公式表达模型,流程图表达关系,代码块表达可执行细节。如果用户必须在浏览器插件、在线图表服务和多个窗口之间切换,桌面编辑器的离线与专注价值就被削弱。OhMarkdown 现在可以在鸿蒙 PC 应用内部直接阅读这三类内容,同时保留标准 Markdown 源码。
真正的优势不是"支持列表里多了三个勾",而是失败成本更低。坏公式不会让正文空白,坏图表不会影响前后段落,未知语言不会丢代码,切换标签不会串内容,文档无法把安全级别改成宽松模式,应用不需要网络权限。错误还能回到源行,用户可以立刻修复,而不是打开开发者工具寻找堆栈。
当前竞争优势记分卡把这项能力记为 3 分,而不是 4 分。原因很具体:已有代码、自动化、最终离线产物和 HarmonyOS 2in1 模拟器证据,但还缺鸿蒙 PC 真机 Release 压力、统一竞品语料和专业导出闭环。产品要做大,优势必须建立在可复测证据上,而不是在阶段尚未完成时提前使用绝对表述。
结语
鸿蒙 PC Markdown 编辑器的专业渲染,本质上是一条受限的编译与展示管线。可靠实现需要同时解决语法边界、可信配置、输出净化、资源上限、异步竞争、错误定位、主题重绘、离线打包和设备布局。KaTeX、Mermaid 与 Highlight.js 提供成熟领域能力,应用负责把它们放进可审计的产品边界。
提交 f133bbe 已经完成应用内预览这一闭环:最终单 HTML 完全本地,公式输出兼顾视觉与 MathML,Mermaid 使用严格安全级别并二次净化,代码高亮按需加载且未知语言安全回退,错误局部可定位,迟到结果不能覆盖新文档。MateBook Pro 2in1 模拟器中的真实 HAP 已同屏显示三类内容,Playwright 38/38 与 ohosTest 8/8 通过。
下一步不是继续堆更多语法,而是把同一份安全渲染结果带入自包含 HTML、系统 PDF、图片和系统分享,并用真机 Release 数据审视包体、启动和复杂文档成本。只有输出一致性和 Beta 质量证据完成后,这条专业渲染管线才会从"可靠预览能力"进一步变成完整的鸿蒙 PC 内容交付能力。