
鸿蒙 PC Markdown 编辑器大纲解析ATX、Setext 与源码跳转长篇 Markdown 文档的导航通常依赖大纲。用户看到的是左侧标题列表工程实现却必须同时理解 Markdown 语法、换行格式、代码围栏、字符偏移和编辑器滚动。大纲如果把代码块里的## 示例当成真实章节或者点击标题后跳到中文字符之前的错误位置它不仅不好用还会削弱用户对整份文档结构的信任。本文以鸿蒙 PC 编辑器 OhMarkdown 的大纲能力为例拆解一个不依赖完整 Markdown AST 的轻量解析器如何覆盖 ATX 标题、Setext 标题和围栏代码块并通过 UTF-16 偏移让 ArkUI 大纲准确驱动 ArkWeb 中的 CodeMirror。真实代码位于公开仓库 https://gitcode.com/VON-/codex_md_oh本文基于提交3a9146e。大纲的输出不只是标题文字如果大纲只输出字符串数组界面可以显示标题却无法稳定跳转。相同标题可能出现多次标题文本也可能包含 Markdown 尾部井号。OhMarkdown 为每一项返回四个字段exportinterfaceMarkdownHeading{level:number;title:string;offset:number;line:number;}level决定视觉缩进和层级title是清理后的显示文字offset是源码起始位置直接用于 CodeMirror 选择和滚动line用于界面显示也便于测试和未来的“转到行”能力。标题身份不是title真正可定位的身份是当前文档版本里的偏移。同时保留行号与偏移有现实价值。行号适合人阅读和日志偏移适合编辑器 API。只存行号意味着点击时还要重新遍历文档处理 CRLF 时也容易把换行长度算错只存偏移则难以在大纲中给用户提示也不方便诊断。两个字段在一次扫描中就能得到成本很低。先把换行拆对再讨论 MarkdownMarkdown 文件可能使用 LF、CRLF也可能来自历史工具而包含单独 CR。JavaScript 的split(\n)会在 CRLF 文档的每行末尾留下\r单独 CR 又完全不会切行。大纲服务先用字符扫描建立统一行模型interfaceMarkdownLine{text:string;offset:number;line:number;}functionsplitMarkdownLines(content:string):ArrayMarkdownLine{constlines:ArrayMarkdownLine[];letlineStart:number0;letlineNumber:number1;for(letindex:number0;indexcontent.length;index1){constatEnd:booleanindexcontent.length;constcharacter:stringatEnd?:content[index];if(!atEndcharacter!\ncharacter!\r){continue;}lines.push({text:content.slice(lineStart,index),offset:lineStart,line:lineNumber});if(!atEndcharacter\rcontent[index1]\n){index1;}lineStartindex1;lineNumber1;}returnlines;}循环条件使用index content.length因此即使最后一行没有换行符也会在atEnd分支写入。遇到 CRLF 时额外跳过\n下一行偏移自然落在两个码元之后。遇到单独 CR 或 LF 时只前进一个。所有offset都来自原始字符串索引不需要在后续根据“行号乘平均长度”重新估算。空文档会产生一个空行对象这并不会生成标题却让扫描逻辑保持一致。尾部换行也可能产生最后一个空行同样不会影响结果。这样的行模型比在正则表达式中混合处理\r?\n更容易审查也便于为每种换行格式编写单元测试。为什么偏移必须使用 UTF-16 语义ArkTS 字符串、JavaScript 字符串和 CodeMirror 的位置都以 UTF-16 码元为基础。中文常用字通常占一个码元许多表情或扩展字符占两个。大纲服务通过字符串索引逐步累积偏移得到的正好是 CodeMirror 接受的坐标。如果原生层按 UTF-8 字节数计算偏移# 鸿蒙 PC中的每个汉字占三个字节传给 CodeMirror 后位置会严重偏后。如果按 Unicode 码点计算遇到代理对又会与 JavaScript 索引不同。跨运行时协议必须明确坐标单位“字符位置”这个含糊说法不足以成为接口契约。当前实现把大纲解析放在 ArkTS 服务中但两端都共享 UTF-16 语义所以偏移无需转换。未来若把解析器下沉到 Rust、C 或服务端就必须在边界处显式转换否则中文标题和表情标题会成为第一批错误样本。ATX 标题解析要处理缩进和尾部井号ATX 标题使用一到六个## 一级标题 ### 三级标题 ## 标题文字 ##OhMarkdown 的匹配规则允许最多三个前导空格要求井号后至少有空格或制表符并限制为六级constatxMatch:RegExpMatchArray|nullline.text.match(/^ {0,3}(#{1,6})[ \t](.?)\s*$/);if(atxMatch){consttitle:stringcleanHeadingTitle(atxMatch[2]);if(title.length0){headings.push({level:atxMatch[1].length,title:title,offset:line.offset,line:line.line});}continue;}要求井号后有空白可以避免把#include、#tag之类文本误判为标题。超过三个前导空格通常进入缩进代码语义当前轻量解析器不把它识别为标题。#{1,6}直接给出层级不需要再次循环计数。尾部井号是 Markdown 允许的可选关闭标记大纲显示时应去掉functioncleanHeadingTitle(value:string):string{returnvalue.replace(/[ \t]#[ \t]*$/,).trim();}这里要求尾部井号前至少有空白。C#不会变成C而## 标题 ##会显示为“标题”。清理后为空的标题不会进入大纲避免出现只有缩进却无法理解的条目。这不是完整的 Markdown inline 解析。标题中的反引号、强调和链接标记仍按源码文本显示例如## 使用 \code 会保留反引号。Alpha 阶段这样做有两个好处无需引入 AST 到 ArkTS点击后的偏移也始终对应源码。未来若希望大纲显示纯文本可以使用与预览相同的 Markdown 解析器提取 inline 文本但必须保持跳转偏移来自原始源码。Setext 标题需要向前看一行Setext 语法用下一行的等号或连字符表示一级、二级标题一级标题 二级标题 --------扫描到非空正文行时解析器检查下一行if(line.text.trim().length0||index1lines.length){continue;}constunderlineMatch:RegExpMatchArray|nulllines[index1].text.match(/^ {0,3}(|-)[ \t]*$/);if(underlineMatch){headings.push({level:underlineMatch[1][0]?1:2,title:line.text.trim(),offset:line.offset,line:line.line});index1;}标题偏移指向文字行而不是下划线。点击大纲后用户首先看到标题内容。识别成功后index 1跳过下划线防止它再被当成下一项的普通文字。Setext 与水平线存在语法接近的问题。单独一行---通常表示水平线但前一行存在可解释文字时它也可以作为 Setext 二级标题下划线。Markdown 规范本身需要上下文决定当前规则遵循“非空前一行加连字符下划线”为标题。因此测试中的正文\n---会产生二级标题“正文”。这不是解析器偶然行为而是必须写进测试和产品预期的语法选择。如果希望大纲与某个特定 Markdown 渲染器百分之百一致最可靠方式是直接复用该渲染器的 token 流。当前服务采用轻量扫描是因为只需要标题、运行在原生侧、无额外依赖且行为容易测试。选择轻量解析器的代价就是必须明确它覆盖的语法子集。代码围栏是一台小状态机技术文档经常在代码块里展示 Markdownmd ## 这只是示例不是文档章节 如果用逐行标题正则直接扫描这个##会污染大纲。解析器记录当前围栏字符和开启长度letfenceCharacter:string;letfenceLength:number0;constfenceMatch:RegExpMatchArray|nullline.text.match(/^ {0,3}({3,}|~{3,})/);if(fenceMatch){constmarker:stringfenceMatch[1];if(fenceCharacter.length0){fenceCharactermarker[0];fenceLengthmarker.length;}elseif(marker[0]fenceCharactermarker.lengthfenceLength){fenceCharacter;fenceLength0;}continue;}if(fenceCharacter.length0){continue;}反引号围栏只能由反引号关闭波浪号围栏只能由波浪号关闭。关闭标记长度必须不小于开启长度因此四个反引号包裹的内容不会被内部三个反引号提前终止。围栏行本身直接跳过围栏内部所有行也跳过标题与 Setext 判断。这段逻辑很短却比“遇到 就翻转布尔值”可靠。简单布尔值无法区分字符类型和长度也会在代码示例中错误结束。状态机依然有边界例如完整 CommonMark 对围栏信息字符串和缩进还有更细规则但当前覆盖了技术文章最常见的误判来源。缩进代码块暂未专门建模。由于 ATX 只允许最多三个前导空格四空格代码里的井号不会成为 ATX 标题Setext 前瞻仍可能遇到复杂边缘组合。后续增加语料时应优先覆盖四空格代码、列表内围栏、未闭合围栏和超长围栏而不是只添加正常标题。原生侧刷新避免使用过期正文大纲按钮位于 ArkUI 侧边栏。用户可能刚输入一个标题Bridge 的节流同步尚未触发。如果直接对this.documentContent解析就会漏掉最新输入。刷新前先从编辑器捕获活动文档privateasyncrefreshOutline():Promisevoid{awaitthis.captureActiveDocumentSession();this.outlineEntriesextractMarkdownHeadings(this.documentContent);}活动面板已是大纲时正文变化也会重新提取this.syncActiveDocumentSession(content);if(this.activePaneloutline){this.outlineEntriesextractMarkdownHeadings(content);}这样大纲打开期间能随编辑更新关闭期间又不必在每次按键后重复扫描。把计算与可见性绑定是桌面应用常用的成本控制策略。对于几兆文本全文扫描仍需要测量后续可以在 CodeMirror transaction 中获取变更范围只重算受影响标题但实现复杂度明显更高。多标签切换后大纲必须属于当前会话。由于刷新总是先捕获活动正文且outlineEntries是页面当前面板状态不会把甲文档标题继续显示在乙文档中。若未来需要为每个标签保留大纲展开状态可以把条目缓存到会话对象但缓存键必须包含 revision避免正文变化后读取旧结构。点击标题后由 CodeMirror 完成定位ArkUI 点击条目时切换到源码模式并把偏移传入 Web 内核privatejumpToHeading(entry:MarkdownHeading):void{this.viewModesource;this.runEditorScript(window.OhMarkdownEditor?.jumpToOffset(${entry.offset}));}Web 侧先验证边界再设置选区和滚动functionjumpToOffset(offset:number):boolean{if(!Number.isInteger(offset)||offset0||offseteditor.state.doc.length){returnfalse;}if(currentModepreview){setMode(source);}editor.dispatch({selection:{anchor:offset},effects:EditorView.scrollIntoView(offset,{y:start,yMargin:18})});editor.focus();returntrue;}边界检查防止过期大纲把偏移传给已经变化的文档。正常情况下大纲在内容变化后会刷新但异步 UI 中仍可能出现用户点击旧渲染项与正文更新交错的窗口Web 层不能无条件相信原生参数。预览模式没有源码选区所以跳转会切回源码。分栏模式则可以保留分栏只要currentMode不是纯预览。目标放在视口顶部并留出十八像素边距标题不会被顶栏或边框紧贴。最后恢复编辑器焦点用户点击大纲后可直接继续写作。脚本参数是整数不包含用户文本因此没有字符串转义问题仍然只通过受限的OhMarkdownEditorAPI 暴露功能而不是让原生层拼接任意 DOM 操作。这个边界便于测试也减少 ArkWeb 能力面。鸿蒙 PC 模拟器中的大纲下图来自 MateBook Pro 2in1 模拟器。左侧大纲提取出 H1Title与 H2Target同时显示源文件行号 9 和 10。编辑区保留原始 Markdown点击条目后由源码偏移完成定位。截图中首行包含看似标题标记的混合文本但没有满足 ATX 标题的行首规则因此不会进入大纲。第九、十行满足规则准确生成两个条目。此类带噪声样本比只有# A\n## B的理想文档更能证明解析器不会随便寻找井号。设备端 ohosTest 使用中文、Setext 和围栏代码构造语料constcontent# 鸿蒙 PC\n\n正文\n---\n\nmd\n## 代码标题\n\n\n### 目标标题;constheadings:ArrayMarkdownHeadingextractMarkdownHeadings(content);expect(headings.length).assertEqual(3);expect(headings[0].title).assertEqual(鸿蒙 PC);expect(headings[1].level).assertEqual(2);expect(headings[2].title).assertEqual(目标标题);expect(headings[2].offset).assertEqual(content.indexOf(### 目标标题));预期只有三个标题ATX 一级标题、由正文\n---形成的 Setext 二级标题、围栏之后的三级标题。代码块中的“代码标题”必须被忽略。最后的偏移与 JavaScript/ArkTSindexOf对比直接验证 UTF-16 坐标契约。Web 自动化则验证跳转行为先进入纯预览调用jumpToOffset后断言工作区回到源码模式并确认浏览器选区落在 CodeMirror 内容区域。原生测试负责“算对偏移”Web 测试负责“使用偏移”模拟器负责“用户看到正确界面”三层证据覆盖了完整调用链。解析器的边界应当公开当前轻量服务不是完整 CommonMark/GFM 解析器。它明确支持一到六级 ATX、一级和二级 Setext、反引号与波浪号围栏过滤并保留源码标题文本。它没有处理 HTML 块内伪标题、所有容器块嵌套、引用中的复杂标题语义也没有把强调或链接转换成纯显示文本。这种边界并不等于实现质量低。对本地桌面编辑器而言一个小而确定的解析器可以减少依赖、降低 ArkTS 侧开销并让标题跳转与源码完全一致。真正的问题不是“没有支持所有语法”而是产品是否错误宣称全覆盖测试是否遗漏已承诺范围。如果后续需要与预览严格同构可以让 Web 侧 markdown-it 输出标题 token 与源码 map再通过 Bridge 传给原生大纲。那样能复用解析语义却会增加跨运行时数据传输和更新调度。另一条路线是在 ArkTS 引入 CommonMark 解析库但要评估包体、性能和 HarmonyOS 兼容性。技术选择应由差异语料和性能数据驱动而不是为了“用了 AST”而增加复杂度。结语一个可靠的大纲功能由几项朴素但关键的约束组成先按原始换行建立带偏移的行模型用小状态机排除围栏代码分别识别 ATX 与 Setext把坐标单位固定为 UTF-16在解析前捕获最新正文并让 CodeMirror 负责选区和滚动。每个环节都不复杂组合后却跨越了文件格式、Markdown 语法、原生 UI 和 Web 编辑内核。鸿蒙 PC 编辑器的桌面体验不只取决于窗口是否像 PC。用户点击一个标题应用能否准确带他回到正在编辑的源码位置才是工具成熟度的直接体现。大纲服务保持独立、无 UI 依赖也为后续符号搜索、面包屑、章节折叠和导出目录提供了可复用的基础。