鸿蒙 PC Markdown 编辑器专业 HTML 与 PDF 输出一致性
鸿蒙 PC Markdown 编辑器专业 HTML 与 PDF 输出一致性
Markdown 编辑器的“预览正常”和“导出正常”不是同一个结论。预览可以依赖应用运行时、内存中的 Blob、本地字体包和异步组件,导出的 HTML 却可能被复制到另一台设备,PDF 还要经过系统打印服务重新排版。只要输出链路绕开最终预览 DOM,公式就可能退回源码,Mermaid 只剩占位节点,本地图片变成失效相对路径,代码高亮也会丢失。对鸿蒙 PC 编辑器而言,这不是装饰差异,而是用户是否敢把编辑器用于交付文档的可靠性问题。
本文讨论 OhMarkdown 在第三阶段实现的一条专业输出管线:KaTeX、Mermaid、Highlight.js 与本地图片完成后才允许输出;HTML 克隆最终安全 DOM,把图片、字体和样式装入一个独立文件;PDF 不自造编码器,而是把同一份最终预览交给鸿蒙系统打印服务。代码来自公开仓库 https://gitcode.com/VON-/codex_md_oh,对应功能提交是 6c823eb。文章只陈述已经进入主分支并通过自动化的部分;系统文件选择器与打印窗口的本轮模拟器复验因 Mac 桌面锁定尚未完成,不能把编译通过写成设备通过。
输出一致性的真实对象是什么
一种常见做法是在用户点击“导出 HTML”时重新执行 markdownRenderer.render(source)。它对标题、列表和粗体通常有效,所以很容易在早期验证中通过。然而 OhMarkdown 的专业预览是两阶段管线:markdown-it 先生成安全占位节点,随后异步加载 KaTeX、Mermaid 和 Highlight.js,用净化后的最终结果替换占位。重新执行基础 Markdown 只会得到公式源码容器、Mermaid 源文本和未高亮代码,而不是用户刚刚看到的页面。
因此输出一致性的对象不是 Markdown 源码,也不是第一次 sanitizeMarkdown 的字符串,而是“当前文档、当前主题、当前本地资源状态、当前渲染代际全部收敛后的预览 DOM”。这一定义还要求输出不能读取旧标签的 DOM,不能在图表仍计算布局时抢跑,也不能把内存 Blob URL直接写进文件。Blob 只在当前 ArkWeb 进程有效,应用关闭后必然失效。
系统 PDF 面对同样问题。ArkWeb 的打印适配器读取页面当前布局,如果调用时 Mermaid Promise 尚未完成,系统打印服务可能在占位阶段生成页面。即使预览在几百毫秒后变正确,已经创建的打印作业也不会自动回到应用等待。因此 HTML 与 PDF 必须共用同一个“输出已准备”判断,不能分别凭经验等待固定时间。
用 settled 回调连接异步渲染与输出
专业渲染增强器原本使用 Promise.allSettled 隔离三类结果,但调用方无法知道它们何时全部结束。G3-08 没有暴露三个第三方 Promise,也没有让 ArkTS 理解 Web 内部模块,而是在当前渲染代际结束时执行一个可选回调:
export interface ProfessionalRenderOptions {
theme: ProfessionalPreviewTheme;
messages: ProfessionalRenderMessages;
onNavigateToLine: (line: number) => void;
onSettled?: () => void;
}
enhance(root: HTMLElement, options: ProfessionalRenderOptions): void {
const generation = this.generation + 1;
this.generation = generation;
void Promise.allSettled([
this.enhanceMath(root, generation, options),
this.enhanceCode(root, generation, options),
this.enhanceMermaid(root, generation, options)
]).then((): void => {
if (this.generation === generation && root.isConnected) {
options.onSettled?.();
}
});
}
这里的条件比“Promise 已完成”更严格。generation 必须仍是当前代际,根节点也必须连接在文档中。用户在渲染期间切换标签、打开新文件、改变主题或重新生成预览时,旧任务即使最终 resolved,也不能把旧文档标记为输出就绪。allSettled 则保证单个错误公式或错误图表不会让整个等待 Promise reject;错误已经被对应节点转换成局部错误界面,输出应保留这个可解释结果。
renderPreview 在开始时把 previewRenderReady 设为 false,在当前代际 settled 回调中设为 true。这个状态不参与保存和脏标记,它只是视图输出闸门。用户仍可以在源码模式高速输入,只有真正请求预览、HTML、PDF 或 PNG 时才需要等待专业增强完成。
本地图片也属于输出完成条件
只等待公式与图表还不够。文档图片通过 ArkTS 受限 Bridge 分块读取,Web 侧先拿到 Markdown 相对路径,再异步得到 Base64 并建立 Blob URL。专业渲染可能在图片读取之前结束。如果此时直接克隆 DOM,导出会把相对路径或 Blob 写进去。
OhMarkdown 为每个会话保存受管理资源的两种表示:url 用于应用内预览,通常是可回收 Blob URL;dataUrl 用于独立输出。两者共享字节数和会话生命周期,但用途严格分开:
interface CachedAssetPreview {
url: string;
dataUrl: string;
byteLength: number;
revocable: boolean;
}
function hasPendingExportAssets(): boolean {
const previews = assetPreviewsBySession.get(activeSessionId);
return Array.from(preview.querySelectorAll<HTMLImageElement>('img')).some((image) => {
const source = image.dataset.localAssetPath ?? image.getAttribute('src') ?? '';
return Boolean(decodeMarkdownAssetPath(source)) && !previews?.has(source) &&
image.dataset.localAssetUnavailable !== 'true';
});
}
prepareOutput 只有在专业渲染完成且受管理图片没有待处理项时才返回真。图片读取失败会留下明确不可用状态,用户可以看到替代文本与错误;读取仍在进行则继续等待,不悄悄生成缺图文件。Playwright 专门把图片 Bridge 改成延迟响应,第一次导出必须返回空字符串,随后补交 Base64,导出才产生结果。这个用例防止未来重构只记得等待 Mermaid,却忘记图片是另一条异步管线。
为什么 ArkTS 采用有界轮询
ArkWeb 的 runJavaScript 适合执行短脚本并获得序列化结果,但把复杂 Promise 直接跨 Bridge 返回会引入不同系统版本的 Promise 序列化差异。实现选择状态式协议:Web 方法同步返回“尚未准备”或结果,ArkTS 每 50 毫秒检查一次,最多 600 次,也就是 30 秒。
private async waitForEditorBoolean(script: string): Promise<void> {
try {
for (let attempt: number = 0; attempt < OUTPUT_PREPARATION_ATTEMPTS; attempt += 1) {
const result = await this.editorController.runJavaScript(script);
if (result === 'true') {
return;
}
await this.waitForOutputTick();
}
} catch (error) {
throw new Error(`The editor output could not be prepared: ${error instanceof Error ?
error.message : String(error)}`);
}
throw new Error('The editor output timed out while waiting for asynchronous rendering.');
}
轮询不是无限等待。30 秒后返回可见失败,operationInProgress 在 finally 中恢复,用户仍可编辑、修复图片路径或重试。固定 setTimeout(500) 看似更短,却无法覆盖首次加载 Mermaid、多个图表或较大本地图片;无限 Promise 又可能把整个导出面板永久锁住。有界轮询把跨运行时协议保持为布尔与字符串,同时给出明确故障边界。
HTML 使用类似循环,但就绪时直接返回完整文档。为了避免输出失败后系统选择器已经创建一个空文件,实现先完成 HTML 预检,再打开保存选择器。用户在系统对话框期间不能修改 ArkWeb 文档,所以预检结果对应稳定快照;取消选择器不会写任何正文,也不会改变文档状态。
最终 DOM 的克隆与清理
导出不能直接返回 preview.innerHTML。预览节点带有内部 ID、渲染代际数据、源码行号、Blob URL 和点击监听器语义,其中一部分对独立文档无用。实现克隆节点,不触碰屏幕上的预览,再逐项清理内部信息。
图片处理是关键。若图片来自受管理资源目录,就根据 data-local-asset-path 查找同一会话的 Data URL;若文档本身已经使用 data:image/,可以保留;远程 URL、file URL、Blob 或无法证明可独立读取的路径移除 src,保留 alt 并标记不可用。这样“自包含”不是“尽量带上资源”,而是产物不再悄悄依赖当前进程、当前磁盘结构或网络。
所有后代节点删除 data-professional-kind、data-render-state 和 data-source-line。公式的 KaTeX 结构、图表 SVG、代码高亮 span、无障碍属性和错误按钮正文继续保留。克隆完成后再次使用 DOMPurify,允许专业输出需要的 HTML、MathML、SVG 和 SVG filter,继续禁止脚本、样式标签、iframe、object、embed、form 与 formaction。
字体为什么必须进入导出文件
KaTeX DOM 并不是只靠 CSS 颜色工作的。不同字体族承担普通符号、数学斜体、伸缩括号、书法体、Fraktur、等宽字符和不同尺寸运算符。应用预览中的字体位于 HAP 单文件,但导出的 HTML 离开应用后不能引用 /node_modules/...,也不能引用 rawfile 内部路径。
实现显式导入 20 个 WOFF2 字体,通过 Vite 的 ?inline 获得 Data URL,为对应 KaTeX 字体族重建 @font-face。原始 KaTeX CSS 中带相对路径的 @font-face 被移除,只保留布局规则,再拼入自包含字体规则。自动化断言输出不存在 /node_modules/,也不存在指向 HTTP、file 或绝对路径的字体 URL。
只使用 WOFF2 而不同时嵌入 WOFF 和 TTF,是包体与桌面兼容性的明确取舍。目标 HarmonyOS ArkWeb 支持 WOFF2,一个字体保留三种格式只会把每份 HTML 扩大数倍。20 个字体仍使 HAP 从 G3-07 的约 6.64 MB 増长到本轮约 8.48 MB,这个成本已经记录在测试报告中。它换来的是:HTML 在断网、脱离应用、脱离源图片目录后仍可正确排公式,而不是只在开发服务器上看起来正常。
一份适合独立阅读的 HTML 外壳
输出文档包含 UTF-8、viewport、标题、主题和严格 CSP。正文进入 <main>,既能限制屏幕阅读宽度,又能在打印媒体中解除最大宽度。主题以根节点 data-theme 固化,避免用户导出深色文档后换一台系统主题不同的设备得到不可预测结果。
核心 CSP 如下:
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'none'; style-src 'unsafe-inline';
img-src data:; font-src data:; connect-src 'none'; media-src 'none';
object-src 'none'; base-uri 'none'; form-action 'none'">
这里允许内联样式,因为 KaTeX 几何布局和 Mermaid SVG 需要受控样式;允许 Data URL 图片与字体,因为这正是独立资源容器;其他能力全部关闭。输出没有脚本,也不依赖脚本进行公式二次渲染。用户双击文件时看到的是已经完成的静态 DOM,减少运行时差异,也降低分发文档的活动内容风险。
普通 Markdown 外部链接可以保留为用户主动导航,但 javascript: 在 Markdown 净化和最终 DOM 净化中都被移除。图表内部 <a> 已在 G3-07 的 Mermaid 专用净化中禁止,所以导出不会产生绕过应用链接边界的第二通道。
PDF 为什么交给系统而不是引入生成库
PDF 生成涉及字体子集、分页、纸张、打印边距、矢量图、图像压缩和系统打印目标。引入一个 JavaScript PDF 库看似让应用直接写文件,实际上会再造一套排版系统,而且它很难与 ArkWeb 预览保持一致。鸿蒙 PC 已提供打印服务和 ArkWeb 打印适配器,OhMarkdown 的职责是提供准备完成、净化正确的页面。
工作台调用 preparePrint(),它与 HTML 使用同一个 prepareOutput。返回真后,原生侧开启背景打印,按安全导出文件名创建 WebPrintDocumentAdapter,再调用系统 print.print。打印媒体 CSS 隐藏编辑器、命令面板、链接助手和冲突对话框,只显示预览正文,取消高度与内部滚动限制,让系统分页处理完整内容。
这种架构的优势不是代码少,而是所有权清晰:Markdown 解析、专业渲染和安全净化属于应用;打印机发现、PDF 目标、纸张、分页预览和系统权限属于 HarmonyOS。应用不申请网络,不上传文档,也不把 PDF 交给远端转换服务。
PDF 与 HTML 共用安全事实,但不共用文件形式
HTML 需要把 Blob 转成 Data URL,因为文件会离开应用;打印作业仍在当前 ArkWeb 中读取页面,Blob 图片在作业创建期间有效,所以没有必要把屏幕 DOM 改成巨大的 Data URL。两种输出共用“预览已经完成”这一事实,但选择适合自身生命周期的资源表示。
同样,HTML 的 <main> 和独立主题是静态文档外壳,PDF 使用现有 #preview 与 @media print。强行让两者共用同一字符串模板,反而可能把静态 CSP、Data URL 和浏览器阅读边距混入打印页面。正确复用的是最终渲染结果与安全策略,不是每一层容器代码。
应用内部的源事实
下图来自 G3-07 最终 Debug HAP 在 HarmonyOS MateBook Pro 2in1 模拟器中的应用内部界面。公式、Mermaid 流程图和 TypeScript 高亮同时完成,这正是 G3-08 输出等待与克隆的源事实,而不是另写一套导出预览。

这张图证明应用内最终预览已经存在,不证明本轮系统保存对话框和打印窗口已经复验。最新输出按钮与系统对话框仍需在 Mac 解锁、模拟器重新在线后补拍。文章把两类证据分开,是为了避免用上一阶段截图替代本阶段设备结论。
自动化如何检查“自包含”而不是只检查有文件
Playwright 构造含标题、粗体、行内公式、本地图片、TypeScript、Mermaid、脚本标签和危险链接的文档。测试等待 exportHtml 返回非空,再断言最终结果包含 KaTeX 节点、SVG、高亮类和图片 Data URL;同时断言没有脚本、JavaScript URL、外部样式链接、node_modules 字体路径或外部 font-face。
打印测试先载入含公式和脚本的文档,轮询 preparePrint,再模拟打印媒体。断言编辑器隐藏、预览可见、公式 KaTeX 已完成、脚本节点为零。另一个用例故意延迟图片 Bridge,确认导出先保持未就绪,图片交付后才生成结果。
完整 Web 回归从 38 项增加到 40 项并全部通过。Debug HAP、ArkTS UnitTestBuild 和 ohosTest HAP 都能构建。构建只说明平台 API、类型和资源可以进入包,不等于系统打印 UI 已经走完,因此报告仍把设备用例列为待测。
错误与取消路径决定产品是否可靠
输出期间所有入口受 operationInProgress 保护,防止用户同时打开多个系统选择器或并发创建打印任务。等待异常、JavaScript 执行异常、选择器异常和写入异常都会进入统一错误状态,最后恢复按钮。用户取消 HTML 保存时,状态回到“已修改”或“就绪”;取消不保存空正文,也不清除脏标记。
图片读取超时不会悄悄生成缺图 HTML。30 秒输出等待到期后给出失败,用户可以修正路径或重新打开工作区。单个公式或图表语法错误不是输出超时:增强器会把它转换为局部错误节点并 settled,所以 HTML 与 PDF仍然可生成,错误位置和说明继续可见。
标题进入 HTML 前进行字符转义,系统文件名则由 ExportService 清理控制字符与桌面保留字符。两个边界不能混用:HTML 转义保护标签结构,文件名清理保护系统路径与选择器建议名。
对鸿蒙 PC 产品优势的意义
很多编辑器可以导出“普通 Markdown 的 HTML”,但专业用户关心的是编辑器里看到的复杂内容能否可靠交付。OhMarkdown 的差异点不应描述成“支持导出”四个字,而应拆成可测事实:输出等待最终代际;错误局部保留;本地图片随文件走;数学字体不依赖 CDN;静态 HTML 无脚本;PDF 复用系统服务;失败不改变源文档。
这套实现当前在竞争优势记分卡中只能得到 2 分,因为自动化和失败路径完整,但鸿蒙 PC 模拟器的系统保存与打印尚未在本轮产物上复验。达到 3 分必须在指定模拟器中保存 HTML、断网打开、进入系统打印并保存 PDF,保留应用和产物证据。达到 4 分还需要真机 Release 与竞品相同复杂文档任务测量,不能因为代码结构合理就提前宣称领先。
仍然公开的限制
首先,自包含 HTML 会明显增大文件,尤其是任何公式文档都携带 KaTeX 字体。后续可以研究按实际字体族裁剪,但必须证明不会让少见符号缺字。其次,HTML 目前内联受管理图片,对无法读取或不受授权管理的资源保留替代文本,不会自动联网抓取。这个限制符合本地优先边界。
第三,PDF 的分页、页眉页脚和目录书签由系统打印能力决定,当前没有应用自定义模板。第四,超复杂 Mermaid 和大量高分辨率图片需要在 Release 真机测量内存。第五,本轮没有把“复制富文本”合入 P0 输出闭环,它仍是后续 P1 能力。
结论
专业导出的关键不是再运行一次 Markdown,而是建立一个可观察、可等待、可净化的最终输出状态。OhMarkdown 使用渲染代际 settled、本地资源双表示和 prepareOutput 把 HTML 与 PDF连接到同一份最终预览;HTML再把 DOM、Data URL 图片、KaTeX 字体、主题和 CSP 封装成独立静态文档,PDF 则把准备完成的页面交给鸿蒙系统打印服务。
提交 6c823eb 已把这条管线、测试和原生调用边界进入主分支。自动化证明专业节点、图片等待与危险内容边界成立,构建证明 API 可用;模拟器系统交互仍保持明确待验。这样的结论可能不如一句“导出完成”简洁,却更适合一个希望长期做大的桌面编辑器:用户交付的是文档,工程团队交付的必须是可以追溯的事实。
模拟器恢复后的系统交互复验
MateBook Pro 2in1 模拟器恢复后,G3-08 不再只停留在构建和浏览器自动化。最终 Debug HAP 进入真实应用设置面板,专业输出区域能够同时看到“导出自包含 HTML”“打印 / PDF”“导出预览 PNG”和“分享 Markdown”。应用先完成 ArkWeb 专业渲染准备,再打开系统 DocumentSave;保存 G3-08-验证.html 后状态栏明确显示“HTML 已导出”,说明从 Web 最终 DOM、ArkTS 返回值解码、系统文件选择器到文件写入的主链已经在设备上走通。

系统打印也已实际启动。打印预览能够看到当前 Markdown 的真实渲染正文,证明 preparePrint()、WebPrintDocumentAdapter 和 PRINT 权限没有停在静态代码层。模拟器随后显示“无可用打印机”,因此本次证据只能确认打印任务和预览建立成功,不能把它写成 PDF 文件输出成功。这个边界很重要:应用能正确交付打印文档,不等于当前测试系统安装了“保存为 PDF”服务。

最终设备回归还执行了 ohosTest 9/9,Failure 与 Error 均为 0;包含导出命名、分享快照和文件系统路径的原生测试都在同一模拟器通过。G3-08 的 HTML 系统写入因此可以从“待验”升级为“设备通过”,PDF 仍保持“打印适配与预览通过、产物待具备 PDF 服务的设备验证”。
系统文件管理器可以访问 DocumentSave 创建的用户文件。双击 G3-08-验证.html 后,系统浏览器以 file:///storage/Users/currentUser/Documents/... 独立打开,正文“鸿蒙PC剪贴板验证”清晰可见;浏览器窗口与后方文件管理器同时保留在截图中,证明它不是 OhMarkdown 应用内预览。

这次复验也修正了文章早期的环境描述:不是“模拟器整体不可用”,而是“模拟器已恢复,特定系统能力缺席”。工程报告应描述能力矩阵,而不是把平台环境压缩成通过或失败两个字。HTML 写入与独立打开已闭环;当前导出能力在竞争记分卡中仍保持 2 分,只因为实际 PDF 文件、兼容分享目标和真机 Release 复杂文档尚未形成证据。
更多推荐



所有评论(0)