鸿蒙 PC Markdown 编辑器文本兼容:UTF-8 BOM 与 CRLF 无损处理

本文从字节层解释 BOM、LF、CRLF、Mixed EOL 在读取、CodeMirror 编辑和安全保存中的完整处理链。完整示例代码:https://gitcode.com/VON-/codex_md_oh

文本相同不代表文件相同

两个 Markdown 文件在编辑器中可能显示完全相同,但底层字节并不一致:

  • UTF-8 文件可能带有 EF BB BF BOM,也可能没有。
  • 行尾可能是 LF,也可能是 CRLF。
  • 历史文件还可能同时包含多种行尾。

如果编辑器在打开时把这些信息丢掉,用户即使只打开再保存,Git 也可能显示整份文件发生变化。对面向开发者的鸿蒙 PC 编辑器来说,这属于数据兼容问题,不是外观细节。

打开文件时记录格式元数据

文档服务把正文和格式分开保存:

export enum LineEnding {
  LF = 'LF',
  CRLF = 'CRLF',
  MIXED = 'MIXED',
  NONE = 'NONE'
}

export interface DocumentFormat {
  hasUtf8Bom: boolean;
  lineEnding: LineEnding;
}

export interface OpenedDocument {
  uri: string;
  name: string;
  content: string;
  format: DocumentFormat;
}

代码来源:entry/src/main/ets/shared/services/DocumentService.ets

编辑器正文不保留 BOM 字符,BOM 只作为 DocumentFormat 元数据存在,避免它进入光标位置、字数统计和 Markdown 解析结果。

区分 LF、CRLF 与混合行尾

检测逻辑按字符扫描,并把 \r\n 作为一个整体处理:

export function detectLineEnding(content: string): LineEnding {
  let lfCount: number = 0;
  let crlfCount: number = 0;
  let crCount: number = 0;

  for (let index = 0; index < content.length; index += 1) {
    const code = content.charCodeAt(index);
    if (code === 13 && index + 1 < content.length && content.charCodeAt(index + 1) === 10) {
      crlfCount += 1;
      index += 1;
    } else if (code === 10) {
      lfCount += 1;
    } else if (code === 13) {
      crCount += 1;
    }
  }

  if (lfCount === 0 && crlfCount === 0 && crCount === 0) {
    return LineEnding.NONE;
  }
  if (crlfCount > 0 && lfCount === 0 && crCount === 0) {
    return LineEnding.CRLF;
  }
  if (lfCount > 0 && crlfCount === 0 && crCount === 0) {
    return LineEnding.LF;
  }
  return LineEnding.MIXED;
}

代码来源:entry/src/main/ets/shared/services/DocumentService.ets

保存时根据原格式恢复 CRLF 和 BOM:

export function serializeDocument(content: string, format: DocumentFormat): string {
  let serializedContent = content;
  if (format.lineEnding === LineEnding.CRLF) {
    serializedContent = content.replace(/\r\n|\r|\n/g, '\n').replace(/\n/g, '\r\n');
  }
  return format.hasUtf8Bom ? `\uFEFF${serializedContent}` : serializedContent;
}

CodeMirror 也必须知道行分隔符

只在原生保存层处理 CRLF 还不够。CodeMirror 默认会规范化行尾,因此编辑器状态需要配置 lineSeparator,读取正文时统一使用 sliceDoc()

function createEditorState(content: string): EditorState {
  const useLargeDocumentSetup = content.length >= LARGE_DOCUMENT_CHARACTER_THRESHOLD;
  const lineSeparator = content.includes('\r\n') ? '\r\n' : '\n';
  return EditorState.create({
    doc: content,
    extensions: [
      useLargeDocumentSetup ? minimalSetup : basicSetup,
      useLargeDocumentSetup ? [] : markdown({ base: markdownLanguage }),
      EditorState.lineSeparator.of(lineSeparator),
      placeholder('Start writing Markdown...'),
      useLargeDocumentSetup ? [] : EditorView.lineWrapping,
      EditorView.updateListener.of((update) => {
        if (!update.docChanged) {
          return;
        }

        documentRevision += 1;
        pendingDirty = forcedDirty || !update.state.doc.eq(baselineDocument);
        updateLargeDocumentMode(update.state.doc.length);
        if (currentMode === 'source') {
          previewDirty = true;
        } else {
          renderPreview(update.state.sliceDoc());
        }
        notifyNative(update.state.doc.length);
        scheduleRecoverySnapshot();
      }),
      EditorView.theme({
        '&': { height: '100%' },
        '.cm-scroller': { overflow: 'auto' },
        '.cm-content': { minHeight: '100%' }
      })
    ]
  });
}

代码来源:web-editor/src/main.ts

鸿蒙 PC 格式状态截图

下图来自鸿蒙 MateBook Pro 2in1 模拟器中的真实文档打开流程。右下角状态栏展示当前文档的编码与换行信息;普通 UTF-8/LF 文件显示为 UTF-8LF,带 BOM 或 CRLF 的文件会分别显示 UTF-8 BOMCRLF

在这里插入图片描述

已验证与待验证

浏览器自动化已经验证 CRLF 文档在编辑后仍通过 sliceDoc() 返回 CRLF,鸿蒙应用内状态栏也能展示格式元数据。Mixed EOL 保存现在会要求用户选择规范化为 LF 或 CRLF,不再静默决定。仍需对固定 BOM、LF、CRLF 语料执行保存前后 SHA-256 和十六进制比较,并对保存途中故障做稳定注入。

从字节到编辑状态的完整路径

无损处理不能只在保存函数末尾加一次替换。格式信息会经过多个层次:

授权 URI 字节
  → UTF-8 严格解码
  → 检测 BOM 与行尾
  → 正文 + DocumentFormat
  → CodeMirror lineSeparator
  → sliceDoc() 取得当前正文
  → Mixed EOL 决策
  → serializeDocument()
  → UTF-8 字节写入、truncate、fsync

任何一层丢掉元数据都可能导致全文件变化。比如原生侧正确检测 CRLF,但 Web 侧用默认 LF 创建状态,用户只改一个字,保存时很难区分原有换行与新换行;又比如正文仍含 BOM 字符,光标列、标题识别和字数统计都会多出一个不可见字符。

因此正文和格式元数据必须同时属于文档会话,但承担不同职责。正文进入编辑、搜索和渲染,格式只控制状态显示和序列化。

BOM 的三个常见误区

UTF-8 BOM 是开头三个字节 EF BB BF。解码后常表示为 U+FEFF,但不同解码 API 的 ignoreBOM 语义容易被误解。

第一个误区是把所有 U+FEFF 都删除。只有文档开头由 UTF-8 BOM 产生的字符属于编码标记,正文中间的 U+FEFF 可能是用户内容,不能全局替换。

第二个误区是读取时保留 BOM,保存时又根据元数据添加一次,导致双 BOM。检测函数应在字节或解码结果开头识别一次,编辑正文去掉它,序列化最多添加一次。

第三个误区是用界面显示“UTF-8 BOM”代替字节验证。只有保存后读取前三个字节,确认恰好为 EF BB BF,才能证明没有丢失或重复。普通 UTF-8 文件同样要确认没有被自动添加 BOM。

CRLF 不是简单的显示差异

Windows/部分工具使用 CRLF,也就是 0D 0A;Unix 风格使用 LF,也就是 0A。编辑器如果统一为 LF 再保存,Git 会把每一行都视为变化,代码审查和 blame 受到污染。某些构建脚本、补丁工具和校验文件还会直接依赖字节格式。

检测时必须把 CRLF 当作一对。如果先统计所有 \n,CRLF 中的 LF 会被重复计入,从而误判为 Mixed。孤立 \r 也不能悄悄当作 CRLF,应归入 Mixed 或单独策略,因为它可能来自旧系统或损坏内容。

单行无换行文档使用 NONE,避免凭空选择风格。用户第一次插入换行时可以使用应用默认 LF;一旦保存并建立新格式基线,状态栏与后续保存都按实际结果更新。

CodeMirror 的 lineSeparator 为什么关键

CodeMirror 内部按行组织文档。EditorState.lineSeparator 决定外部字符串与内部行结构之间的序列化方式。创建 CRLF 文档状态时配置 \r\n,随后用 sliceDoc() 获取内容,才能让编辑后的新行沿用 CRLF。

直接读取 state.doc.toString() 容易忽略配置语义;项目统一使用 sliceDoc() 作为对外正文接口。测试要覆盖打开 CRLF、在中间回车、删除一行、撤销重做、保存快照和导出,确保所有出口都没有重新规范化。

预览渲染不需要区分 LF 与 CRLF,Markdown 语义相同。但预览使用的派生字符串不能反向覆盖编辑状态,否则渲染器规范化结果会破坏格式。

Mixed EOL 为什么必须让用户决定

Mixed 文档可能由历史工具、合并冲突或复制粘贴产生。未编辑直接关闭时,不应产生任何写入;编辑后保存则需要决定新输入行使用哪种风格,以及是否保留旧混合分布。

完全保留每一个原始行尾需要把行尾序列作为独立结构跟踪,并处理插入、删除、移动和粘贴,复杂度接近编辑模型的一部分。当前实现选择更清晰的策略:保存时提示规范化为 LF 或 CRLF。用户决定后,所有换行统一,状态元数据更新,后续保存不再重复询问。

这个对话必须发生在写入和备份之前,取消时保持 Modified 与恢复快照。不能默认选中某个选项后立即倒计时保存,也不能根据操作系统猜测,因为文件可能属于跨平台代码仓库。

外部冲突比较也要包含格式

保存前重新读取磁盘时,只比较规范化后的可见文本不够。外部工具可能只改变 BOM 或行尾,编辑器显示内容完全相同,却仍代表磁盘版本发生变化。持久化基线应包含正文和 DocumentFormat,任一变化都阻止静默覆盖。

对 20MiB 上限内文档,重新读取和严格比较成本可控。未来若用摘要优化,应对原始字节或明确的序列化结果计算,不要只对 \n 规范化字符串计算,否则会遗漏格式冲突。

冲突后可以提供重新打开、另存为或未来三方合并。强制覆盖必须是用户明确动作,且保存前旧磁盘版本备份仍要生效。

安全保存如何保护格式版本

保存备份不能只存正文。旧文件的 hasUtf8BomlineEnding 必须一起记录,否则写入失败后恢复内容看似相同,字节格式却被改变。备份 JSON 在应用沙箱中原子提交,目标 URI 写入失败时使用旧格式序列化并恢复。

目标保存顺序应是:读取当前磁盘并检查冲突、确定 Mixed 策略、写原子备份、序列化新正文、写入并校验 UTF-8 字节数、truncate、fsync、清理备份、更新持久化基线。任何一步失败都不能提前清除编辑恢复快照或把界面标成 Saved。

授权 URI 未必支持同目录临时文件原子替换,所以沙箱旧版本是降级保护,不应在技术描述中称为目标文件原子保存。真正的可靠性还需保存中强杀、短写、只读和空间不足故障注入。

字节级夹具应该怎样设计

测试夹具需要覆盖格式和内容的交叉,而不是只做一个 CRLF 文件:

夹具 关键字节 操作
UTF-8/LF 无 BOM,只有 0A 打开、改一字、保存
UTF-8 BOM/LF 开头 EF BB BF 保存后恰好一个 BOM
UTF-8/CRLF 行尾 0D 0A 新增行、撤销、保存
UTF-8 BOM/CRLF 两类格式组合 中文与 emoji 往返
Mixed 同时含 LF/CRLF/孤立 CR 分别选择 LF、CRLF、取消
NONE 无任何换行 插入首个换行并保存
尾随换行 文件末尾有换行 保存后尾部语义不变
非法 UTF-8 截断多字节序列 明确拒绝打开

每个夹具记录原始十六进制和 SHA-256。对于“未编辑保存不改变”用例,前后哈希应完全相同;对于编辑用例,不能要求哈希相同,而要检查 BOM 数量、每个行尾和预期正文。

可使用系统工具辅助检查:

shasum -a 256 fixture.md
xxd -g 1 fixture.md | sed -n '1,12p'

工具输出是证据,断言仍应进入自动化,避免人工在大量十六进制中漏看单个 0D

Git 工作区中的实际影响

Markdown 编辑器常用于代码仓库。格式错误最直观的后果是用户只改一行,git diff 却显示全文件变化。验收可以在临时仓库中提交夹具,使用应用修改一个字符,再检查 diff 行数、--ignore-space-at-eol 前后差异和文件哈希。

.gitattributes 可能设置 texteol=lfeol=crlf,Git 检出与索引会做转换。应用当前处理工作树实际字节,不应猜测 Git 配置。未来工作区功能可以读取属性并提示推荐行尾,但不能在没有用户确认时重写文件。

不同系统工具还可能在文件末尾自动添加换行。Markdown 编辑器应把尾随换行视为正文的一部分,除非用户开启明确格式化选项。无损默认和格式化命令必须是两条路径。

文本兼容验收清单

  • 打开时只识别开头 BOM,正文中 U+FEFF 不被误删。
  • 普通 UTF-8 不自动添加 BOM,BOM 文件保存后恰好保留一个。
  • CRLF 检测不把成对 LF 重复计数,孤立 CR 进入 Mixed。
  • CodeMirror 配置 lineSeparator,所有正文出口统一使用 sliceDoc()
  • 预览和导出不反向覆盖源码行尾。
  • Mixed EOL 保存前由用户明确选择 LF/CRLF,取消不写入。
  • 保存冲突比较正文、BOM 和行尾,而不只比较可见文本。
  • 备份记录包含旧格式,恢复后字节语义正确。
  • 固定夹具执行 SHA-256、十六进制和 Git diff 验证。
  • 保存成功点位于写入、truncate、fsync 和备份清理之后。

无损文本处理的难点不是识别两个换行符,而是让字节格式贯穿读取、编辑、Bridge、恢复、冲突检测和保存。只要其中一个出口私自规范化,用户就会在 Git diff 或下游工具中承担代价。把格式作为文档会话的一等元数据,才是鸿蒙 PC 编辑器处理跨平台资料的可靠基础。

Unicode 规范化不能混入普通保存

视觉相同的字符可能有不同 Unicode 序列,例如预组字符与字母加组合音标。中文也可能包含兼容字符、全角形式和变体选择符。编辑器如果在打开或保存时自动执行 NFC/NFKC 规范化,会改变用户原始字节,甚至影响代码、链接和签名。

普通编辑路径应保留解码后的代码点序列,只改变用户实际编辑的部分。搜索可以提供规范化匹配,但替换和保存仍以正文为准。格式化命令若提供 Unicode 规范化,必须单独预览 diff 并由用户确认。

字数统计、光标列和字符串长度要意识到代码单元、代码点和可见字素并不相同。这个差异不应反向推动正文规范化。

emoji 与代理对的字节校验

JavaScript 使用 UTF-16,某些 emoji 占两个代码单元;UTF-8 写出可能占四个或更多字节。保存完整性不能比较 content.length 与写入字节,必须先按 UTF-8 计算期望长度。

夹具应包含单个 emoji、肤色修饰、ZWJ 家庭序列、旗帜和组合音标,并让多字节序列跨越 64KiB 读取块边界。严格流式解码必须把它们恢复为原序列,不能产生替换字符或拆分。

CodeMirror 选区以代码单元位置表达时,程序化修改要使用其 API,不自行按“字符数”切片。否则可能从代理对中间截断,保存成非法或意外字符。

分块解码的结束处理

流式 TextDecoder 在非最后一块使用 stream: true,最后一块必须结束流,让解码器报告尾部不完整序列。若文件正好在多字节字符中间截断,fatal: true 应抛错,而不是丢弃残余字节。

读取循环还要处理 read 返回零、实际累计字节小于初始 stat 和文件在读取中增长。任何不一致都拒绝建立持久化基线,因为后续保存可能覆盖一个从未完整读取的版本。

BOM 检测最好基于完整首部字节或明确解码结果,不受分块大小影响。空文件、只有 BOM 的文件和只有换行的文件都需要独立夹具。

粘贴内容如何继承行尾

剪贴板文本可能来自 LF 或 CRLF 系统。进入 CodeMirror 后,外部字符串应按当前 lineSeparator 转换为文档行结构,新插入行最终使用当前文档格式。不能因为剪贴板含 CRLF 就把 LF 文档整体变成 Mixed。

Mixed 文档在用户尚未选择目标格式时,编辑状态仍需要一个操作分隔符。可以采用检测到的主要风格或 LF 作为内部新增行策略,但最终保存必须提示规范化,并在文案中说明会统一所有换行。内部选择不能伪装成无损保留。

粘贴超大文本还要经过大小和大文件模式判断,格式转换不创建多份无上限字符串。

搜索替换不能隐藏行尾变化

普通搜索通常不显示 \r\n,替换跨行文本时却可能构造换行。所有程序化 changes 应通过 CodeMirror 文档 API,让 lineSeparator 统一序列化。正则搜索若支持 \r?\n,界面需说明匹配语义,避免用户在 CRLF 文档中得到意外结果。

“替换全部”之后保存前仍执行 Mixed 策略和外部冲突检测。格式化器、排序行、删除尾随空格等命令属于主动批量修改,应在撤销栈中形成清楚事务,并让 diff 可预览。

只读搜索索引可以规范化换行以简化匹配,但索引结果映射回正文位置时必须准确,不能用规范化字符串偏移直接修改原文。

恢复记录中的格式兼容

恢复快照包含正文和 DocumentFormat。版本升级后读取记录先验证 lineEnding 枚举和 BOM 布尔值;未知格式不直接序列化回原 URI,可以恢复为未命名文档并要求用户选择格式。

快照正文来自 sliceDoc(),CRLF 会作为配置行分隔符出现。恢复时重新创建匹配 lineSeparator 的 EditorState,不能先用默认 LF 创建再设置状态栏。保存备份同样保留写入前格式,以便故障后恢复原字节语义。

测试强杀 CRLF/BOM 文档,恢复后新增一行再保存,检查的不只是屏幕内容,还包括 BOM 和每个行尾。

导出与源文件格式相互独立

HTML 与 PDF 的换行由 HTML/CSS 排版,不需要保留 Markdown 的 CRLF 字节;导出正文经过解析后,源文件仍保持原格式。导出成功不能更新源文档格式基线,也不能清除 dirty,除非 Markdown 本身另行保存成功。

复制 Markdown 到剪贴板、另存为 .md 和导出 HTML 是三个命令。前两者需要明确是否保留 BOM/行尾,后者只保证语义内容和安全结构。界面名称要避免用户把“导出”误认为无损备份。

若提供“复制带格式 HTML”,净化后的片段进入剪贴板,不反向改变 CodeMirror 文档。

编辑器默认设置与项目规则

新文档可以默认 UTF-8 无 BOM、LF,这是跨平台常见选择;但打开已有文档优先保留检测格式。用户全局设置只影响新文档或明确转换,不能覆盖已有文件无损原则。

工作区可能通过 .editorconfig.gitattributes 声明行尾。读取这些规则后可以在状态栏提示“项目建议 LF”,保存 Mixed 时作为推荐选项,但自动转换需要产品设置和用户可见行为。规则文件本身也受工作区授权和大小限制。

设置变更应记录作用域:全局、工作区还是文档。多个规则冲突时按明确优先级处理,并允许用户查看最终决定来源。

非 UTF-8 编码的未来扩展

支持更多编码时,DocumentFormat 需要增加 encoding,而不是把解码结果一律标为 UTF-8。UTF-16 还涉及 LE/BE 和 BOM,GB18030涉及无法表示字符时的保存决策。自动检测可能误判纯 ASCII,因为它同时符合多种编码。

安全默认是严格 UTF-8失败后提示用户选择编码。选择成功后状态栏持续显示,保存按原编码尝试;新增字符无法编码时建议另存 UTF-8,不使用替换问号。转换前后展示字节变化和 Git 影响。

编码库会增加 HAP 体积与攻击面,必须限制输入大小、使用成熟实现和固定夹具。没有完整往返证据前,拒绝比静默猜测更可靠。

无损测试的自动断言层次

第一层测试纯函数:BOM 检测、行尾分类、序列化和非法枚举。第二层测试 Web:lineSeparator、输入、撤销、粘贴和 sliceDoc()。第三层测试原生文件服务:真实 UTF-8 字节、短写、truncate、fsync和备份。第四层在鸿蒙设备上走 DocumentViewPicker URI,保存重开并比较文件。

每层使用同一语料命名和预期,失败可以快速定位。设备层无法直接读取提供方原始字节时,可以通过应用另存测试副本、系统可访问目录或专用诊断命令获得证据,但不能用状态栏显示代替字节断言。

回归报告分别写内容相等、格式相等和字节相等。只有未编辑往返才通常要求完整哈希相等;编辑后按预期差异断言,避免错误地追求相同哈希。

尾随空白与文件末尾换行

行尾风格之外,每行尾随空格和文件末尾是否有换行也是源码的一部分。Markdown 中两个尾随空格还表示硬换行,自动清理可能改变渲染语义。普通保存不能执行 trim,也不能无条件添加末尾 LF。

格式化命令可以提供“移除无意义尾随空格”或“确保文件末尾换行”,但要识别 Markdown 硬换行、代码块和用户设置,显示 diff 并允许撤销。保存服务只负责序列化既有正文,不承担风格格式化。

夹具覆盖空文件、只有 BOM、末尾有/无换行、末行多个空格和空白行,保存重开后逐字节比较。

行尾显示与主动转换命令

状态栏显示 UTF-8、BOM、LF、CRLF 或 Mixed,让格式变化可见。点击状态可以打开转换菜单,但转换属于一次编辑事务:正文被规范化、dirty 变为 true、可撤销,保存前仍做外部冲突与备份。

不要只修改 DocumentFormat 而不更新 EditorState,那会让界面显示 CRLF却保存出与当前事务不一致的内容。转换后重新建立适当 lineSeparator 或用新状态保留选区和可接受历史,具体方式需要 CodeMirror 回归。

批量工作区转换风险更高,应在单文件能力稳定后提供预览与逐文件结果。

文件监听事件与格式变化

工作区未来加入文件监听后,外部工具只改行尾也必须被识别。监听事件只是“可能变化”提示,收到后重新读取字节并比较正文与格式;某些提供方会发重复事件,不能每次都强制弹窗。

当前文档 clean 时可以提示重新加载或按设置自动刷新,dirty 时必须保留本地内容并进入冲突状态。监听器自身不能直接更新持久化基线,否则会失去三方比较的打开版本。

文件保存可能触发自己的监听事件,使用已写 revision/摘要识别自有变化,避免保存后立刻误报外部冲突。

Diff 视图也要尊重原始格式

冲突比较为了可读性可以把行尾符号可视化,默认按逻辑行显示内容差异,并提供“显示空白”模式展示 LF/CRLF、尾随空格和末尾换行。不能完全规范化后告诉用户“没有变化”。

三方合并结果选择目标行尾,冲突标记只存在编辑会话,保存前不得把临时 DOM 或格式化文本覆盖用户版本。大型文档 diff 需要大小限制与后台计算,超限时提供另存和外部工具路径。

格式变化单独统计行数和字节影响,帮助用户识别一次保存是否会让 Git 显示全文件修改。

字节语义的错误提示

非法 UTF-8、双 BOM、Mixed EOL 和外部格式变化需要不同文案。错误说明检测到什么、应用尚未做什么、用户可以选择什么。例如非法 UTF-8明确“未修改文件,可选择其他编码或取消”;Mixed 提示“保存将统一换行”;外部变化提示“磁盘版本的内容或格式已改变”。

不要把所有问题压成“编码错误”,也不展示难懂堆栈。高级详情可以给出编码、行尾计数和摘要,不显示敏感路径。准确文案能防止用户在不理解影响时强制覆盖。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐