鸿蒙 PC Markdown 编辑器大纲解析:ATX、Setext 与源码跳转

鸿蒙 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 为每一项返回四个字段:

ts 复制代码
export interface MarkdownHeading {
  level: number;
  title: string;
  offset: number;
  line: number;
}

level 决定视觉缩进和层级;title 是清理后的显示文字;offset 是源码起始位置,直接用于 CodeMirror 选择和滚动;line 用于界面显示,也便于测试和未来的"转到行"能力。标题身份不是 title,真正可定位的身份是当前文档版本里的偏移。

同时保留行号与偏移有现实价值。行号适合人阅读和日志,偏移适合编辑器 API。只存行号意味着点击时还要重新遍历文档,处理 CRLF 时也容易把换行长度算错;只存偏移则难以在大纲中给用户提示,也不方便诊断。两个字段在一次扫描中就能得到,成本很低。

先把换行拆对,再讨论 Markdown

Markdown 文件可能使用 LF、CRLF,也可能来自历史工具而包含单独 CR。JavaScript 的 split('\n') 会在 CRLF 文档的每行末尾留下 \r,单独 CR 又完全不会切行。大纲服务先用字符扫描建立统一行模型:

ts 复制代码
interface MarkdownLine {
  text: string;
  offset: number;
  line: number;
}

function splitMarkdownLines(content: string): Array<MarkdownLine> {
  const lines: Array<MarkdownLine> = [];
  let lineStart: number = 0;
  let lineNumber: number = 1;
  for (let index: number = 0; index <= content.length; index += 1) {
    const atEnd: boolean = index === content.length;
    const character: string = atEnd ? '' : content[index];
    if (!atEnd && character !== '\n' && character !== '\r') {
      continue;
    }
    lines.push({
      text: content.slice(lineStart, index),
      offset: lineStart,
      line: lineNumber
    });
    if (!atEnd && character === '\r' && content[index + 1] === '\n') {
      index += 1;
    }
    lineStart = index + 1;
    lineNumber += 1;
  }
  return lines;
}

循环条件使用 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 标题使用一到六个 #

md 复制代码
# 一级标题
### 三级标题
## 标题文字 ##

OhMarkdown 的匹配规则允许最多三个前导空格,要求井号后至少有空格或制表符,并限制为六级:

ts 复制代码
const atxMatch: RegExpMatchArray | null = line.text.match(
  /^ {0,3}(#{1,6})[ \t]+(.+?)\s*$/
);
if (atxMatch) {
  const title: string = cleanHeadingTitle(atxMatch[2]);
  if (title.length > 0) {
    headings.push({
      level: atxMatch[1].length,
      title: title,
      offset: line.offset,
      line: line.line
    });
  }
  continue;
}

要求井号后有空白可以避免把 #include#tag 之类文本误判为标题。超过三个前导空格通常进入缩进代码语义,当前轻量解析器不把它识别为标题。#{1,6} 直接给出层级,不需要再次循环计数。

尾部井号是 Markdown 允许的可选关闭标记,大纲显示时应去掉:

ts 复制代码
function cleanHeadingTitle(value: string): string {
  return value.replace(/[ \t]+#+[ \t]*$/, '').trim();
}

这里要求尾部井号前至少有空白。C# 不会变成 C,而 ## 标题 ## 会显示为"标题"。清理后为空的标题不会进入大纲,避免出现只有缩进却无法理解的条目。

这不是完整的 Markdown inline 解析。标题中的反引号、强调和链接标记仍按源码文本显示,例如 ## 使用 \code`` 会保留反引号。Alpha 阶段这样做有两个好处:无需引入 AST 到 ArkTS,点击后的偏移也始终对应源码。未来若希望大纲显示纯文本,可以使用与预览相同的 Markdown 解析器提取 inline 文本,但必须保持跳转偏移来自原始源码。

Setext 标题需要向前看一行

Setext 语法用下一行的等号或连字符表示一级、二级标题:

md 复制代码
一级标题
========

二级标题
--------

扫描到非空正文行时,解析器检查下一行:

ts 复制代码
if (line.text.trim().length === 0 || index + 1 >= lines.length) {
  continue;
}
const underlineMatch: RegExpMatchArray | null =
  lines[index + 1].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
  });
  index += 1;
}

标题偏移指向文字行,而不是下划线。点击大纲后,用户首先看到标题内容。识别成功后 index += 1 跳过下划线,防止它再被当成下一项的普通文字。

Setext 与水平线存在语法接近的问题。单独一行 --- 通常表示水平线,但前一行存在可解释文字时,它也可以作为 Setext 二级标题下划线。Markdown 规范本身需要上下文决定,当前规则遵循"非空前一行加连字符下划线"为标题。因此测试中的 正文\n--- 会产生二级标题"正文"。这不是解析器偶然行为,而是必须写进测试和产品预期的语法选择。

如果希望大纲与某个特定 Markdown 渲染器百分之百一致,最可靠方式是直接复用该渲染器的 token 流。当前服务采用轻量扫描,是因为只需要标题、运行在原生侧、无额外依赖且行为容易测试。选择轻量解析器的代价,就是必须明确它覆盖的语法子集。

代码围栏是一台小状态机

技术文档经常在代码块里展示 Markdown:

md 复制代码
```md
## 这只是示例,不是文档章节
```

如果用逐行标题正则直接扫描,这个 ## 会污染大纲。解析器记录当前围栏字符和开启长度:

ts 复制代码
let fenceCharacter: string = '';
let fenceLength: number = 0;

const fenceMatch: RegExpMatchArray | null = line.text.match(
  /^ {0,3}(`{3,}|~{3,})/
);
if (fenceMatch) {
  const marker: string = fenceMatch[1];
  if (fenceCharacter.length === 0) {
    fenceCharacter = marker[0];
    fenceLength = marker.length;
  } else if (marker[0] === fenceCharacter && marker.length >= fenceLength) {
    fenceCharacter = '';
    fenceLength = 0;
  }
  continue;
}
if (fenceCharacter.length > 0) {
  continue;
}

反引号围栏只能由反引号关闭,波浪号围栏只能由波浪号关闭。关闭标记长度必须不小于开启长度,因此四个反引号包裹的内容不会被内部三个反引号提前终止。围栏行本身直接跳过,围栏内部所有行也跳过标题与 Setext 判断。

这段逻辑很短,却比"遇到 ```就翻转布尔值"可靠。简单布尔值无法区分字符类型和长度,也会在代码示例中错误结束。状态机依然有边界,例如完整 CommonMark 对围栏信息字符串和缩进还有更细规则,但当前覆盖了技术文章最常见的误判来源。

缩进代码块暂未专门建模。由于 ATX 只允许最多三个前导空格,四空格代码里的井号不会成为 ATX 标题;Setext 前瞻仍可能遇到复杂边缘组合。后续增加语料时,应优先覆盖四空格代码、列表内围栏、未闭合围栏和超长围栏,而不是只添加正常标题。

原生侧刷新避免使用过期正文

大纲按钮位于 ArkUI 侧边栏。用户可能刚输入一个标题,Bridge 的节流同步尚未触发。如果直接对 this.documentContent 解析,就会漏掉最新输入。刷新前先从编辑器捕获活动文档:

ts 复制代码
private async refreshOutline(): Promise<void> {
  await this.captureActiveDocumentSession();
  this.outlineEntries = extractMarkdownHeadings(this.documentContent);
}

活动面板已是大纲时,正文变化也会重新提取:

ts 复制代码
this.syncActiveDocumentSession(content);
if (this.activePanel === 'outline') {
  this.outlineEntries = extractMarkdownHeadings(content);
}

这样大纲打开期间能随编辑更新,关闭期间又不必在每次按键后重复扫描。把计算与可见性绑定,是桌面应用常用的成本控制策略。对于几兆文本,全文扫描仍需要测量;后续可以在 CodeMirror transaction 中获取变更范围,只重算受影响标题,但实现复杂度明显更高。

多标签切换后,大纲必须属于当前会话。由于刷新总是先捕获活动正文,且 outlineEntries 是页面当前面板状态,不会把甲文档标题继续显示在乙文档中。若未来需要为每个标签保留大纲展开状态,可以把条目缓存到会话对象,但缓存键必须包含 revision,避免正文变化后读取旧结构。

点击标题后由 CodeMirror 完成定位

ArkUI 点击条目时切换到源码模式,并把偏移传入 Web 内核:

ts 复制代码
private jumpToHeading(entry: MarkdownHeading): void {
  this.viewMode = 'source';
  this.runEditorScript(
    `window.OhMarkdownEditor?.jumpToOffset(${entry.offset})`
  );
}

Web 侧先验证边界,再设置选区和滚动:

ts 复制代码
function jumpToOffset(offset: number): boolean {
  if (!Number.isInteger(offset) ||
    offset < 0 ||
    offset > editor.state.doc.length) {
    return false;
  }
  if (currentMode === 'preview') {
    setMode('source');
  }
  editor.dispatch({
    selection: { anchor: offset },
    effects: EditorView.scrollIntoView(offset, {
      y: 'start',
      yMargin: 18
    })
  });
  editor.focus();
  return true;
}

边界检查防止过期大纲把偏移传给已经变化的文档。正常情况下,大纲在内容变化后会刷新;但异步 UI 中仍可能出现用户点击旧渲染项与正文更新交错的窗口,Web 层不能无条件相信原生参数。

预览模式没有源码选区,所以跳转会切回源码。分栏模式则可以保留分栏,只要 currentMode 不是纯预览。目标放在视口顶部并留出十八像素边距,标题不会被顶栏或边框紧贴。最后恢复编辑器焦点,用户点击大纲后可直接继续写作。

脚本参数是整数,不包含用户文本,因此没有字符串转义问题;仍然只通过受限的 OhMarkdownEditor API 暴露功能,而不是让原生层拼接任意 DOM 操作。这个边界便于测试,也减少 ArkWeb 能力面。

鸿蒙 PC 模拟器中的大纲

下图来自 MateBook Pro 2in1 模拟器。左侧大纲提取出 H1 Title 与 H2 Target,同时显示源文件行号 9 和 10。编辑区保留原始 Markdown,点击条目后由源码偏移完成定位。

截图中首行包含看似标题标记的混合文本,但没有满足 ATX 标题的行首规则,因此不会进入大纲。第九、十行满足规则,准确生成两个条目。此类带噪声样本比只有 # A\n## B 的理想文档更能证明解析器不会随便寻找井号。

设备端 ohosTest 使用中文、Setext 和围栏代码构造语料:

ts 复制代码
const content =
  '# 鸿蒙 PC\n\n正文\n---\n\n```md\n' +
  '## 代码标题\n```\n\n### 目标标题';
const headings: Array<MarkdownHeading> =
  extractMarkdownHeadings(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/ArkTS indexOf 对比,直接验证 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 依赖,也为后续符号搜索、面包屑、章节折叠和导出目录提供了可复用的基础。

相关推荐
●VON1 小时前
鸿蒙 PC Markdown 编辑器内部隐私与安全评审
安全·华为·编辑器·harmonyos·鸿蒙
木木子221 小时前
# [特殊字符] 音乐播放器 — 鸿蒙ArkTS播放控制与列表管理
华为·harmonyos
运维行者_1 小时前
如何查看每个IP的带宽使用情况?NetFlow 技术实战指南
开发语言·网络·分布式·后端·架构·带宽
2301_768103492 小时前
HarmonyOS趣味相机实战第24篇:前摄镜像、旋转补偿与识别框坐标统一
图像处理·harmonyos·arkts·camerakit·坐标映射
国服第二切图仔2 小时前
21-MCP服务
harmonyos
绝世番茄2 小时前
HarmonyOS List 上拉加载更多(LoadMore)深度实战指南
华为·list·harmonyos·鸿蒙
杨充2 小时前
4.接口而非实现编程
java·后端·架构
勇踏前人未索之境2 小时前
Unity打包运行于鸿蒙手机
unity·智能手机·harmonyos
2601_954526752 小时前
【硬核架构】打破 IT 与 OT 的数据孤岛!基于 Rust 异步协程的工业 IoT 网关重构,兼谈顶级自动化智能仪表厂家选型指南
物联网·架构·rust
●VON2 小时前
鸿蒙 PC Markdown 编辑器离线专业渲染管线
华为·编辑器·harmonyos·鸿蒙