鸿蒙 PC Markdown 编辑器换行兼容:LF、CRLF 与混合换行归一化

鸿蒙 PC Markdown 编辑器换行兼容:LF、CRLF 与混合换行归一化

换行在编辑器界面里只是"下一行",在文件字节中却可能是 LF、CRLF或单独 CR。若应用打开 Windows文档后统一保存为 LF,会制造整文件差异;若混合换行不做决策直接继续编辑,新输入到底采用哪一种又不明确。Markdown正文相同不等于文件格式相同。

本文基于鸿蒙 PC Markdown编辑器 OhMarkdown,说明如何线性检测换行、在 CodeMirror中保留行分隔语义、保存时统一归一化、为 Mixed文档显式询问策略,并用设备字节测试锁定行为。完整代码位于 https://gitcode.com/VON-/codex_md_oh,对应提交 3a9146e

四种格式状态

应用定义:

ts 复制代码
export enum LineEnding {
  LF = 'LF',
  CRLF = 'CRLF',
  MIXED = 'MIXED',
  NONE = 'NONE'
}

NONE用于没有任何换行的文档,包括空文件和单行文本。它不能简单等同 LF,因为"尚无换行"与"明确全是 LF"是不同检测结果;保存时可根据新内容重新检测或使用默认策略。

MIXED表示存在两种以上终止形式,或者出现单独 CR。当前产品不尝试逐行保留混合模式,而是在修改后保存前让用户选择统一 LF或 CRLF。未编辑保存可以保留原正文,因为序列化对 MIXED不转换。

单次扫描检测所有终止符

检测函数逐字符统计:

ts 复制代码
export function detectLineEnding(
  content: string
): LineEnding {
  let lfCount = 0;
  let crlfCount = 0;
  let crCount = 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;
}

遇到 CRLF后额外递增 index,避免下一轮把其中 LF重复计数。单独 CR进入 crCount,哪怕全文只有 CR也返回 MIXED,因为产品不提供 CR保存目标;保存时需要转成现代格式。

扫描复杂度 O(n),没有用多个正则反复遍历全文。文档读取本身已经产生字符串,再做一次线性检测成本可控。超大文件仍应记录耗时。

为什么不能只检查第一个换行

快速实现常找到第一处 \n,看前一个字符是否 \r,然后把整篇标为 CRLF或 LF。这会漏掉后半文档的混合换行。代码生成器、复制粘贴和 Git冲突都可能让一份文件前面 CRLF、后面 LF。

格式状态用于保存决策,必须扫描完整正文。状态栏显示 MIXED也是用户发现问题的入口。性能优化可以在分块读取时增量统计,但不能牺牲全文件覆盖。

CodeMirror 行分隔配置

创建 EditorState时:

ts 复制代码
const lineSeparator = content.includes('\r\n')
  ? '\r\n'
  : '\n';

return EditorState.create({
  doc: content,
  extensions: [
    EditorState.lineSeparator.of(lineSeparator),
    // 其他扩展
  ]
});

CRLF文档按 CRLF配置,新按 Enter产生同类分隔符。LF文档使用 LF。Mixed当前只要包含 CRLF就选 CRLF作为编辑期间新增行策略,最终保存仍要求用户统一。

这个策略简单但有边界:Mixed以 LF为主、只含一处 CRLF时,新行仍用 CRLF;单独 CR文档会用 LF。更精细做法可以根据计数选择多数格式或使用用户设置。关键是把编辑期策略和保存期策略分开,不把 CodeMirror配置当作原始格式检测结果。

归一化必须先统一为 LF

序列化:

ts 复制代码
if (format.lineEnding === LineEnding.LF) {
  serializedContent = content.replace(
    /\r\n|\r|\n/g,
    '\n'
  );
} else if (
  format.lineEnding === LineEnding.CRLF
) {
  serializedContent = content
    .replace(/\r\n|\r|\n/g, '\n')
    .replace(/\n/g, '\r\n');
}

CRLF转换不能直接把每个 \n替换为 \r\n,否则原有 CRLF中的 LF前面已有 CR,会变成 \r\r\n。正确做法是先把 CRLF、CR、LF全部归一为单个 LF,再将 LF扩展为 CRLF。

正则 alternation把 \r\n放在单独 \r\n之前,确保成对消费。LF目标只做第一步。MIXED和 NONE没有进入分支,保留当前字符串;对修改后的 Mixed,原生保存对话框会先将目标改成 LF或 CRLF。

Mixed 保存必须由用户决定

保存前:

ts 复制代码
private async resolveSaveFormat():
  Promise<DocumentFormat | undefined> {
  const format: DocumentFormat = {
    hasUtf8Bom: this.documentFormat.hasUtf8Bom,
    lineEnding: this.documentFormat.lineEnding
  };
  if (!this.documentDirty ||
    format.lineEnding !== LineEnding.MIXED) {
    return format;
  }

  const result = await this.getUIContext()
    .getPromptAction().showDialog({
      title: $r('app.string.mixed_line_ending_title'),
      message: $r('app.string.mixed_line_ending_message'),
      buttons: [
        { text: $r('app.string.cancel_action') },
        { text: $r('app.string.save_lf_action') },
        { text: $r('app.string.save_crlf_action') }
      ]
    });
  if (result.index === 1) {
    format.lineEnding = LineEnding.LF;
    return format;
  }
  if (result.index === 2) {
    format.lineEnding = LineEnding.CRLF;
    return format;
  }
  return undefined;
}

只有文档被修改且原格式 Mixed时询问。未编辑保存直接保留原字符串,可达到字节一致;修改后必须明确新行和旧混合如何统一。取消返回 undefined,保存中止且 dirty保留,关闭标签流程也不会继续删除。

当前对话框没有"保持混合"选项,因为新编辑已让逐行来源难以解释。保真优先并不意味着永远维持异常格式;可控转换需要用户知情。

保存成功后更新格式

写入后:

ts 复制代码
this.documentFormat = {
  hasUtf8Bom: saveFormat.hasUtf8Bom,
  lineEnding: saveFormat.lineEnding === LineEnding.NONE
    ? detectLineEnding(this.documentContent)
    : saveFormat.lineEnding
};

单行 NONE文档如果编辑后出现换行,保存结果应反映实际格式。LF或 CRLF目标直接成为新会话格式。状态栏与下一次保存都使用新基线,不会继续显示 Mixed。

persistedDocumentContent保存内存正文,外部冲突检测还比较磁盘格式与当前格式。另一个应用只改变 CRLF/LF,即使视觉正文相同,也被视为外部变化,避免静默覆盖。

设备测试覆盖 CRLF和 Mixed

BOM+CRLF用例读取、保存后比较完整十六进制,证明 CRLF没有变。Mixed用例:

ts 复制代码
await writeRawText(
  testPath,
  '第一行\r\n第二行\n第三行\r'
);
const opened = await readUtf8Document(testPath);
expect(opened.format.lineEnding)
  .assertEqual(LineEnding.MIXED);

await writeUtf8Document(
  testPath,
  opened.content,
  {
    hasUtf8Bom: false,
    lineEnding: LineEnding.LF
  }
);

重新读取:

ts 复制代码
const normalized = await readUtf8Document(testPath);
expect(normalized.content).assertEqual(
  '第一行\n第二行\n第三行\n'
);
expect(normalized.format.lineEnding)
  .assertEqual(LineEnding.LF);

它覆盖 CRLF、LF和单独 CR三种输入,确保全部统一为 LF。Web自动化还打开 CRLF文档,按 End、Enter输入第三行,断言新内容包含 \r\n第三行,验证 CodeMirror编辑期分隔符。

鸿蒙 PC 状态栏

下图来自鸿蒙 PC模拟器。状态栏显示当前编码和换行格式,让用户在保存前看见 LF、CRLF或 Mixed。

截图证明格式元数据进入应用界面;具体字节由 ohosTest保证。测试还需补一张 Mixed保存策略对话框,覆盖取消、LF和 CRLF三条设备路径。

恢复与多标签中的格式

DocumentSession保存独立 format,标签切换不会串换行。恢复记录和保存中断备份也保存 lineEnding字符串,加载时通过 parseLineEnding映射到枚举。非法值被校验拒绝。

如果只把正文写入恢复 JSON,恢复后保存可能采用默认 LF,破坏原 CRLF。格式保真必须贯穿异常路径,而不只存在于正常打开结果。

当前边界

单独 CR没有独立保存选项,统一视为 Mixed。Mixed编辑期新增行偏向 CRLF,只是临时策略。没有全局默认换行设置,也没有状态栏点击转换。二进制和非 UTF-8文件在进入换行检测前已拒绝。

未来可提供显式"转换换行"命令,将转换作为一笔可撤销编辑或保存格式操作;还可为 Git仓库读取 .gitattributes,但需要定义工作区规则优先级。任何自动策略都不能在用户不知情时造成整文件差异。

结语

换行兼容需要完整扫描而非猜第一行,使用 NONE、LF、CRLF、MIXED表达事实,CodeMirror按会话设置编辑期分隔符,保存时先统一为 LF再转换目标,Mixed修改后让用户选择,设备测试按字节和内容双重验证。

这些规则让鸿蒙 PC Markdown编辑器面对 Windows、Linux和历史文档时保持可预测。换行不可见,但正因为不可见,应用更应把它当正式格式而不是顺手归一化的空白字符。

相关推荐
qizayaoshuap19 小时前
# [特殊字符] 骰子模拟器 — 鸿蒙ArkTS随机算法与动画系统设计
算法·华为·harmonyos
ldsweet19 小时前
《HarmonyOS技术精讲-Basic Services Kit》电源管理进阶:亮度调节与休眠控制
华为·harmonyos
千逐6819 小时前
鸿蒙新特性 | 页面路由——router 怎么跳怎么传参
华为·harmonyos·鸿蒙
2301_7681034919 小时前
HarmonyOS趣味相机实战第19篇:CameraKit输出Profile协商、宽高比评分与会话提交
harmonyos·arkts·camerakit·photosession·设备适配
熊猫钓鱼>_>19 小时前
ArkTS 方舟编程语言 · 原创快速入门教程
运维·架构·ts·harmonyos·arkts·鸿蒙·js
小时代的大玩家20 小时前
HarmonyOS新特性-沉浸光感在叠叠消小游戏中的落地实践
前端·harmonyos
大龄秃头程序员20 小时前
SwiftUI 实战:从零构建双引擎 LLM 聊天客户端(Ollama + DeepSeek)
架构
YM52e20 小时前
鸿蒙Flutter Stack堆叠布局:实现多层级界面
学习·flutter·华为·harmonyos·鸿蒙·鸿蒙系统
●VON20 小时前
鸿蒙 PC Markdown 编辑器错误处理:让失败可恢复而不是只弹提示
华为·架构·编辑器·harmonyos·鸿蒙