鸿蒙 PC Markdown 编辑器文本兼容UTF-8 BOM 与 CRLF 无损处理本文从字节层解释 BOM、LF、CRLF、Mixed EOL 在读取、CodeMirror 编辑和安全保存中的完整处理链。完整示例代码https://gitcode.com/VON-/codex_md_oh。文本相同不代表文件相同两个 Markdown 文件在编辑器中可能显示完全相同但底层字节并不一致UTF-8 文件可能带有EF BB BFBOM也可能没有。行尾可能是 LF也可能是 CRLF。历史文件还可能同时包含多种行尾。如果编辑器在打开时把这些信息丢掉用户即使只打开再保存Git 也可能显示整份文件发生变化。对面向开发者的鸿蒙 PC 编辑器来说这属于数据兼容问题不是外观细节。打开文件时记录格式元数据文档服务把正文和格式分开保存exportenumLineEnding{LFLF,CRLFCRLF,MIXEDMIXED,NONENONE}exportinterfaceDocumentFormat{hasUtf8Bom:boolean;lineEnding:LineEnding;}exportinterfaceOpenedDocument{uri:string;name:string;content:string;format:DocumentFormat;}代码来源entry/src/main/ets/shared/services/DocumentService.ets编辑器正文不保留 BOM 字符BOM 只作为DocumentFormat元数据存在避免它进入光标位置、字数统计和 Markdown 解析结果。区分 LF、CRLF 与混合行尾检测逻辑按字符扫描并把\r\n作为一个整体处理exportfunctiondetectLineEnding(content:string):LineEnding{letlfCount:number0;letcrlfCount:number0;letcrCount:number0;for(letindex0;indexcontent.length;index1){constcodecontent.charCodeAt(index);if(code13index1content.lengthcontent.charCodeAt(index1)10){crlfCount1;index1;}elseif(code10){lfCount1;}elseif(code13){crCount1;}}if(lfCount0crlfCount0crCount0){returnLineEnding.NONE;}if(crlfCount0lfCount0crCount0){returnLineEnding.CRLF;}if(lfCount0crlfCount0crCount0){returnLineEnding.LF;}returnLineEnding.MIXED;}代码来源entry/src/main/ets/shared/services/DocumentService.ets保存时根据原格式恢复 CRLF 和 BOMexportfunctionserializeDocument(content:string,format:DocumentFormat):string{letserializedContentcontent;if(format.lineEndingLineEnding.CRLF){serializedContentcontent.replace(/\r\n|\r|\n/g,\n).replace(/\n/g,\r\n);}returnformat.hasUtf8Bom?\uFEFF${serializedContent}:serializedContent;}CodeMirror 也必须知道行分隔符只在原生保存层处理 CRLF 还不够。CodeMirror 默认会规范化行尾因此编辑器状态需要配置lineSeparator读取正文时统一使用sliceDoc()functioncreateEditorState(content:string):EditorState{constuseLargeDocumentSetupcontent.lengthLARGE_DOCUMENT_CHARACTER_THRESHOLD;constlineSeparatorcontent.includes(\r\n)?\r\n:\n;returnEditorState.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;}documentRevision1;pendingDirtyforcedDirty||!update.state.doc.eq(baselineDocument);updateLargeDocumentMode(update.state.doc.length);if(currentModesource){previewDirtytrue;}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-8和LF带 BOM 或 CRLF 的文件会分别显示UTF-8 BOM和CRLF。已验证与待验证浏览器自动化已经验证 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。解码后常表示为 UFEFF但不同解码 API 的ignoreBOM语义容易被误解。第一个误区是把所有 UFEFF 都删除。只有文档开头由 UTF-8 BOM 产生的字符属于编码标记正文中间的 UFEFF 可能是用户内容不能全局替换。第二个误区是读取时保留 BOM保存时又根据元数据添加一次导致双 BOM。检测函数应在字节或解码结果开头识别一次编辑正文去掉它序列化最多添加一次。第三个误区是用界面显示“UTF-8 BOM”代替字节验证。只有保存后读取前三个字节确认恰好为EF BB BF才能证明没有丢失或重复。普通 UTF-8 文件同样要确认没有被自动添加 BOM。CRLF 不是简单的显示差异Windows/部分工具使用 CRLF也就是0D 0AUnix 风格使用 LF也就是0A。编辑器如果统一为 LF 再保存Git 会把每一行都视为变化代码审查和 blame 受到污染。某些构建脚本、补丁工具和校验文件还会直接依赖字节格式。检测时必须把 CRLF 当作一对。如果先统计所有\nCRLF 中的 LF 会被重复计入从而误判为 Mixed。孤立\r也不能悄悄当作 CRLF应归入 Mixed 或单独策略因为它可能来自旧系统或损坏内容。单行无换行文档使用NONE避免凭空选择风格。用户第一次插入换行时可以使用应用默认 LF一旦保存并建立新格式基线状态栏与后续保存都按实际结果更新。CodeMirror 的 lineSeparator 为什么关键CodeMirror 内部按行组织文档。EditorState.lineSeparator决定外部字符串与内部行结构之间的序列化方式。创建 CRLF 文档状态时配置\r\n随后用sliceDoc()获取内容才能让编辑后的新行沿用 CRLF。直接读取state.doc.toString()容易忽略配置语义项目统一使用sliceDoc()作为对外正文接口。测试要覆盖打开 CRLF、在中间回车、删除一行、撤销重做、保存快照和导出确保所有出口都没有重新规范化。预览渲染不需要区分 LF 与 CRLFMarkdown 语义相同。但预览使用的派生字符串不能反向覆盖编辑状态否则渲染器规范化结果会破坏格式。Mixed EOL 为什么必须让用户决定Mixed 文档可能由历史工具、合并冲突或复制粘贴产生。未编辑直接关闭时不应产生任何写入编辑后保存则需要决定新输入行使用哪种风格以及是否保留旧混合分布。完全保留每一个原始行尾需要把行尾序列作为独立结构跟踪并处理插入、删除、移动和粘贴复杂度接近编辑模型的一部分。当前实现选择更清晰的策略保存时提示规范化为 LF 或 CRLF。用户决定后所有换行统一状态元数据更新后续保存不再重复询问。这个对话必须发生在写入和备份之前取消时保持 Modified 与恢复快照。不能默认选中某个选项后立即倒计时保存也不能根据操作系统猜测因为文件可能属于跨平台代码仓库。外部冲突比较也要包含格式保存前重新读取磁盘时只比较规范化后的可见文本不够。外部工具可能只改变 BOM 或行尾编辑器显示内容完全相同却仍代表磁盘版本发生变化。持久化基线应包含正文和DocumentFormat任一变化都阻止静默覆盖。对 20MiB 上限内文档重新读取和严格比较成本可控。未来若用摘要优化应对原始字节或明确的序列化结果计算不要只对\n规范化字符串计算否则会遗漏格式冲突。冲突后可以提供重新打开、另存为或未来三方合并。强制覆盖必须是用户明确动作且保存前旧磁盘版本备份仍要生效。安全保存如何保护格式版本保存备份不能只存正文。旧文件的hasUtf8Bom和lineEnding必须一起记录否则写入失败后恢复内容看似相同字节格式却被改变。备份 JSON 在应用沙箱中原子提交目标 URI 写入失败时使用旧格式序列化并恢复。目标保存顺序应是读取当前磁盘并检查冲突、确定 Mixed 策略、写原子备份、序列化新正文、写入并校验 UTF-8 字节数、truncate、fsync、清理备份、更新持久化基线。任何一步失败都不能提前清除编辑恢复快照或把界面标成 Saved。授权 URI 未必支持同目录临时文件原子替换所以沙箱旧版本是降级保护不应在技术描述中称为目标文件原子保存。真正的可靠性还需保存中强杀、短写、只读和空间不足故障注入。字节级夹具应该怎样设计测试夹具需要覆盖格式和内容的交叉而不是只做一个 CRLF 文件夹具关键字节操作UTF-8/LF无 BOM只有0A打开、改一字、保存UTF-8 BOM/LF开头EF BB BF保存后恰好一个 BOMUTF-8/CRLF行尾0D 0A新增行、撤销、保存UTF-8 BOM/CRLF两类格式组合中文与 emoji 往返Mixed同时含 LF/CRLF/孤立 CR分别选择 LF、CRLF、取消NONE无任何换行插入首个换行并保存尾随换行文件末尾有换行保存后尾部语义不变非法 UTF-8截断多字节序列明确拒绝打开每个夹具记录原始十六进制和 SHA-256。对于“未编辑保存不改变”用例前后哈希应完全相同对于编辑用例不能要求哈希相同而要检查 BOM 数量、每个行尾和预期正文。可使用系统工具辅助检查shasum-a256fixture.md xxd-g1fixture.md|sed-n1,12p工具输出是证据断言仍应进入自动化避免人工在大量十六进制中漏看单个0D。Git 工作区中的实际影响Markdown 编辑器常用于代码仓库。格式错误最直观的后果是用户只改一行git diff却显示全文件变化。验收可以在临时仓库中提交夹具使用应用修改一个字符再检查 diff 行数、--ignore-space-at-eol前后差异和文件哈希。.gitattributes可能设置text、eollf或eolcrlfGit 检出与索引会做转换。应用当前处理工作树实际字节不应猜测 Git 配置。未来工作区功能可以读取属性并提示推荐行尾但不能在没有用户确认时重写文件。不同系统工具还可能在文件末尾自动添加换行。Markdown 编辑器应把尾随换行视为正文的一部分除非用户开启明确格式化选项。无损默认和格式化命令必须是两条路径。文本兼容验收清单打开时只识别开头 BOM正文中 UFEFF 不被误删。普通 UTF-8 不自动添加 BOMBOM 文件保存后恰好保留一个。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 和 BOMGB18030涉及无法表示字符时的保存决策。自动检测可能误判纯 ASCII因为它同时符合多种编码。安全默认是严格 UTF-8失败后提示用户选择编码。选择成功后状态栏持续显示保存按原编码尝试新增字符无法编码时建议另存 UTF-8不使用替换问号。转换前后展示字节变化和 Git 影响。编码库会增加 HAP 体积与攻击面必须限制输入大小、使用成熟实现和固定夹具。没有完整往返证据前拒绝比静默猜测更可靠。无损测试的自动断言层次第一层测试纯函数BOM 检测、行尾分类、序列化和非法枚举。第二层测试 WeblineSeparator、输入、撤销、粘贴和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 提示“保存将统一换行”外部变化提示“磁盘版本的内容或格式已改变”。不要把所有问题压成“编码错误”也不展示难懂堆栈。高级详情可以给出编码、行尾计数和摘要不显示敏感路径。准确文案能防止用户在不理解影响时强制覆盖。