鸿蒙 PC Markdown 编辑器预览 PNG 与系统分享
鸿蒙 PC Markdown 编辑器预览 PNG 与系统分享
桌面 Markdown 编辑器的输出不只发生在“导出 HTML”和“保存 PDF”。真实工作流里,用户会把一段完整排版交给即时沟通工具,会把当前草稿交给另一台设备继续编辑,也会在未保存时临时分享文件。图片导出和系统分享看起来都像设置面板里的一个按钮,背后却分别跨越 ArkWeb 布局、PixelMap、图片编码、系统文件选择器、应用缓存、URI 授权和隐式 Want。如果把这些边界挤进一个点击回调,正常路径可能能演示,长文、取消、失败、未保存内容和资源释放很快就会失控。
本文拆解 OhMarkdown 在鸿蒙 PC 上实现的两条原生输出路径:预览 PNG 使用 ArkWeb whole-page drawing 捕获最终预览,经过平台尺寸检查后由 ImagePacker 写入用户选择的位置;Markdown 分享始终从当前 CodeMirror 缓冲区创建独立缓存快照,通过只读 URI 进入 HarmonyOS 系统分享。代码来自公开仓库 https://gitcode.com/VON-/codex_md_oh,对应真实提交为 6c823eb。本文不把尚未在本轮模拟器完成的系统目标页描述为已通过,设备证据与自动化证据会明确分开。
图片输出不是截取窗口
直接对应用窗口截图会同时得到标题栏、活动栏、侧栏、标签栏、状态栏和编辑器控件。用户需要的是 Markdown 预览内容,而不是一张操作界面照片。更麻烦的是预览区域本身是内部滚动容器:窗口只能看到其中一段,普通截图无法得到完整文档。
OhMarkdown 的图片导出目标被定义为“当前文档最终预览的 PNG 表示”。它要复用公式、图表、高亮、主题和本地图片,要排除编辑器控件,要覆盖完整预览高度,还要在操作结束后恢复用户原来的源码、分栏或预览模式。这个定义让图片导出与文章证据截图成为两件事:前者是产品输出,后者是测试记录。
选择 PNG 而不是 JPEG 是因为文档包含大量文字、细线、代码和透明边缘。PNG 在这些内容上不会引入有损压缩噪点,也避免质量滑块造成难以复现的测试组合。后续如需超长社交媒体图片、分片或 JPEG 体积优化,可以建立新能力,但基础输出先保证清晰、确定和无静默截断。
whole-page drawing 必须在 Web 初始化前启用
ArkWeb 的全页绘制不是截图时临时打开的开关。平台 API 要求 WebviewController.enableWholeWebPageDrawing() 只在 Web 组件初始化期间生效。调用太晚时,编译不会报错,运行时却可能只得到可见区域或失败结果。
工作台在组件 aboutToAppear 中启用能力,然后才由 ArkUI 构建 Web 组件:
aboutToAppear(): void {
webview.WebviewController.enableWholeWebPageDrawing();
}
@Builder
private editorSurface() {
Web({ src: $rawfile('editor/index.html'), controller: this.editorController })
.javaScriptAccess(true)
.domStorageAccess(false)
.imageAccess(true)
.fileAccess(false)
.geolocationAccess(false)
}
启用全页绘制没有放宽 Web 文件权限、网络权限或地理位置权限。它只允许控制器在当前页面渲染范围内生成 PixelMap。Bridge 白名单继续限制为文档状态、资源读取、链接和命令消息,图片捕获由原生控制器直接完成,不需要给 JavaScript 新增任意文件写能力。
为什么要临时展开页面布局
Web 页面原有 CSS 把 html、body、#workspace 固定为 height: 100% 并隐藏外层溢出,#preview 自己滚动。这是桌面编辑体验需要的布局,但 whole-page snapshot 看到的“页面高度”仍可能只是窗口高度。捕获前必须把内部滚动转为页面自然高度。
Web 侧的 prepareImageExport 先调用与 HTML/PDF 相同的 prepareOutput,确认专业渲染和图片已经完成。随后记录 currentMode,把工作区切到预览模式,在根节点设置 data-export-capture="true"。对应 CSS 将 html、body、workspace 和 preview 改为自动高度与可见溢出,隐藏 editor,保持预览宽度和最小视口高度。
状态不是设置后立即返回真,而是等待一次 requestAnimationFrame。这让浏览器完成样式计算和布局,再读取 scrollWidth、scrollHeight 与 getBoundingClientRect。如果同一 JavaScript 栈里设置属性后立刻捕获,平台可能读到旧几何,造成截断或空白。
几何协议必须显式包含上限
ArkWeb webPageSnapshot 的平台上限是 16000 px × 16000 px。实现没有用 Math.min 静默裁剪,而是把 width、height、maximum 和 supported 一起序列化给 ArkTS:
function getImageExportMetrics(): string {
if (!imageExportLayoutReady) {
return '';
}
const width = Math.ceil(Math.max(preview.scrollWidth, preview.getBoundingClientRect().width));
const height = Math.ceil(Math.max(preview.scrollHeight, preview.getBoundingClientRect().height));
return JSON.stringify({
width,
height,
maximum: 16000,
supported: width <= 16000 && height <= 16000
});
}
显式失败对文档输出很重要。静默截成 16000 px 会生成一个看似成功、底部内容丢失的文件,用户很难立刻发现。当前版本对超限文档显示失败并恢复界面;分片合成真正超长图需要额外的滚动坐标、分块 PixelMap、内存预算和接缝验证,不能用一个上限截断冒充。
宽高由最终预览测量,不由 Markdown 字符数估算。相同字符数在代码块、表格、公式和图表下会产生完全不同的像素高度。ArkTS还会再次检查宽高为正、没有超过声明最大值,避免被异常 JSON 或未来 Web 回归绕过。
PixelMap 捕获与 PNG 编码是两个责任
工作台负责向 WebviewController 请求快照,ExportService 负责把 PixelMap 可靠写入文件。控制器调用被包装成 Promise,回调错误、status 为假或没有 imagePixelMap 都视为失败。只有拿到完整 PixelMap 后才进入编码。
private captureWebSnapshot(metrics: ExportImageMetrics): Promise<webview.SnapshotResult> {
return new Promise<webview.SnapshotResult>((resolve, reject) => {
this.editorController.webPageSnapshot({
id: `ohmarkdown-export-${Date.now()}`,
size: { width: `${metrics.width}vp`, height: `${metrics.height}vp` }
}, (error: BusinessError, result: webview.SnapshotResult) => {
if (error) {
reject(error);
return;
}
resolve(result);
});
});
}
快照 ID 带时间戳,便于平台内部区分连续请求。尺寸单位显式使用 vp,与 Web 页面几何在鸿蒙桌面窗口中的布局单位对齐。平台返回的 SnapshotResult.size 可用于后续设备报告,但当前业务以预检尺寸和实际 PixelMap 成功为准。
ImagePacker 的资源释放不能依赖成功路径
PNG 服务打开系统选择器返回的目标 URI,将原内容截断为零,从文件偏移起点编码,调用 fsync 后关闭。ImagePacker 和文件描述符都在 finally 中释放:
export async function writePngPixelMap(targetUri: string,
pixelMap: image.PixelMap): Promise<void> {
const file = await fileIo.open(targetUri, fileIo.OpenMode.READ_WRITE);
const packer = image.createImagePacker();
try {
await fileIo.truncate(file.fd, 0);
await packer.packToFile(pixelMap, file.fd, {
format: 'image/png',
quality: 100
});
await fileIo.fsync(file.fd);
} finally {
await packer.release();
await fileIo.close(file);
}
}
工作台在调用服务的外层 finally 释放 snapshot.imagePixelMap。即使编码器抛出磁盘、格式或内存错误,PixelMap 也不会留在长生命周期组件中。再外一层 finally 无条件执行 Web 的 finishImageExport,恢复根属性与原视图,并解除 operationInProgress。
这种三层清理不是形式主义。PixelMap 可能占据宽×高×4 字节的原始内存,一张 4000×8000 图片接近 122 MiB;忘记释放一次就可能影响下一次编辑或导出。页面捕获模式若不恢复,会让用户回到应用时只看到展开预览,误以为源码消失。
保存选择器为什么在预检之后打开
许多系统保存选择器会在用户确认文件名时先创建目标。若应用随后才发现预览超过 16000 px,用户目录可能留下零字节 PNG。实现顺序是:等待输出、展开布局、读取并验证几何、打开系统选择器、用户确认后捕获和编码。
用户取消选择器时不会创建快照,也不会分配 PixelMap,状态回到“已修改”或“就绪”,页面捕获模式在 finally 恢复。预检期间用户仍在应用内,可以看到“正在导出预览图片”;进入系统选择器后文档不能被编辑,因此已测几何保持稳定。
HTML 使用相同原则,先准备完整字符串再打开选择器。这个顺序让系统 UI 成为写入事务的后半段,而不是错误检查的起点。
分享必须以当前缓冲区为事实来源
如果文档有 URI,直接分享磁盘文件看起来最省事,但它可能落后于当前未保存编辑。用户点击分享的语义通常是“分享我现在看到的内容”,而不是“分享上次按保存时的版本”。直接分享原 URI 还会让 Untitled 文档没有路径。
工作台通过受限 JavaScript API读取 getDocument(),得到当前 CodeMirror 文本。这个动作不执行保存,不改变持久化正文、修订号、文件指纹或脏标记。ExportService 清理中文文件名和危险字符,在应用 cacheDir 中写入独立 share-*.md 文件,确认写入字节数、truncate 和 fsync 后再生成 file URI。
分享快照与崩溃恢复记录不是同一文件。恢复记录服务于应用重启和用户选择,包含文档身份、格式和修订元数据;分享快照只包含对外 Markdown 正文。混用两者会泄露内部状态,也会让外部应用收到非标准格式。
文件名清洗要保留中文语义
桌面文件名可能含 /\:*?"<>|、控制字符、结尾点和空白。完全转成 UUID 很安全,却让系统分享列表难以辨认。ExportService 先去掉原扩展,替换保留字符,合并多余空白与分隔符,清理开头结尾,再限制为 120 字符;空结果降级为 Untitled。
普通 ArkTS 测试使用 ../鸿蒙 PC:导出?.md,预期得到 鸿蒙 PC-导出.html;分享设备测试使用 分享:测试?.md,预期缓存文件为 share-分享-测试.md。路径分隔符不能逃出 cacheDir,中文标题和单个空格继续保留。
扩展名由调用方传入后再次只保留小写 ASCII 字母与数字。HTML、PNG 和 Markdown 使用同一命名规则,避免三个按钮产生三种清洗结果。
隐式 Want 与只读 URI
分享文件准备完成后,工作台构造 ohos.want.action.sendData 隐式 Want,类型为 text/markdown,uri 和 ability.params.stream 都指向缓存快照,同时授予 FLAG_AUTH_READ_URI_PERMISSION。内容标题使用清理后的 .md 文件名。
const shareWant: Want = {
action: 'ohos.want.action.sendData',
type: 'text/markdown',
uri: shareUri,
flags: wantConstant.Flags.FLAG_AUTH_READ_URI_PERMISSION,
parameters: {
'ability.params.stream': shareUri,
'ohos.extra.param.key.contentTitle': this.getExportName('md')
}
};
await (context as common.UIAbilityContext).startAbility(shareWant);
应用不指定目标 bundle,让系统根据 MIME 和能力解析分享目标。只授予读权限,不授予写权限,外部应用不能借分享动作修改 OhMarkdown 缓存。缓存文件不是用户原文件,所以即使目标应用持有临时读授权,也接触不到工作区目录和其他文档。
系统没有匹配目标、目标启动失败或 URI 无法读取时,异常转换为状态栏失败信息,operationInProgress 恢复。分享失败不删除编辑器内容、不清理脏标记,也不会自动改成上传网络服务。
缓存快照的生命周期与隐私
缓存目录由系统管理,文件不是长期用户资料。实现采用稳定的 share-文件名.md,下一次分享同名文档会覆盖并截断旧快照,避免每次点击累积永久文件。系统可能在存储压力或应用生命周期后清理 cacheDir,这符合临时分享语义。
当前版本不会在 startAbility 返回后立刻删除文件,因为系统分享目标可能在随后才读取 URI。过早删除会导致目标页显示文件名却无法打开。后续可以增加带代际的缓存清理策略,例如保留最近若干快照并在下一次启动清理过期项,但必须避免清理仍被系统目标使用的文件。
分享正文可能包含用户敏感信息。应用只在用户明确点击分享时启动系统目标,不自动上传,不持久化到远程,不给 Web 开放任意 URI。缓存快照的路径只通过系统 URI 权限交给匹配目标。真机安全复核还应检查目标选择器、权限持续时间和应用卸载后的清理行为。
PC 可发现性不应只靠设置面板
设置与导出侧栏新增“导出预览 PNG”和“分享 Markdown”,与 HTML、打印 PDF 组成完整输出区。按钮在操作期间禁用,PNG 在大文档保护模式禁用,分享仍可分享纯 Markdown,因为它不需要生成预览。
键盘用户可以打开命令面板,搜索 preview png 或 share markdown。Web 命令注册表只发送 exportImage、shareMarkdown 两个固定字符串,ArkTS onEditorCommand 明确分支处理。任意输入不能变成 JavaScript 或系统动作。Playwright 在同一用例中确认 HTML、PNG 和分享各自到达白名单命令。
中文和英文资源同时增加按钮名称。动态状态提供“正在导出预览图片”“预览图片已导出”“正在准备 Markdown 分享”“系统分享已打开”及对应失败前缀,避免中文界面在新操作中突然显示全英文状态。
应用内部界面证据与本轮边界
下图是 HarmonyOS MateBook Pro 2in1 模拟器中的真实 OhMarkdown 设置与导出侧栏,记录了简体中文、命令面板、HTML、PDF、自动保存、图片资源和拖放规则共同存在的 PC 工作台。G3-08 的 PNG 与分享入口是在同一面板、同一资源体系和同一命令路由上继续增加,而不是另做浏览器页面。

截图来自新增 PNG 与分享按钮之前的真实 HAP,所以它证明工作台承载面与本地化基线,不证明最新两个按钮已经在模拟器显示。Mac 当前锁定,Computer Use 无法进入 DevEco Studio,hdc 也没有在线目标;最新设置面板、PNG 文件查看器和系统分享目标页仍需解锁后补拍。保留这一说明比把旧图裁剪成“新功能截图”更符合技术文章的证据标准。
自动化与原生测试怎样分层
Playwright 无法调用 HarmonyOS webPageSnapshot 和 ImagePacker,但可以验证捕获前最容易回归的 Web 状态机:长文在源码模式请求图片导出,先等待专业渲染,再进入 data-export-capture,完整高度大于视口,结束后恢复源码模式。它还验证命令路由和现有编辑能力不受影响。
ArkTS UnitTestBuild 编译并检查导出文件名纯函数。ohosTest 新增真实文件系统用例:创建包含“未保存正文”的分享快照,通过返回 URI 用现有 UTF-8 文档服务重读,正文必须逐字符相等,文件名必须是清理后的中文名称,最后清理缓存文件。
Debug HAP 为 8,478,815 字节,SHA-256 是 118322889817aeb34d60faa73c42e6d299810aac3e045d7d7441ed2206c636ac;ohosTest HAP 为 9,293,978 字节,SHA-256 是 851081a4c4f927e5427456e8337386861e22b97868a5bac3242e3165ad329bd6。两者编译通过,但新增 ohosTest 尚未在本轮在线模拟器执行,因此文章不写“设备 9/9 通过”。
失败恢复是桌面输出的核心体验
PNG 可能在预览等待、几何解析、选择器、快照、编码或 fsync 任一步失败。每一层只负责自己的资源,并把异常交给工作台状态;最外层始终退出捕获模式。用户不需要重启应用才能继续编辑,也不会因为失败自动切换保存策略。
分享可能在读取 Web 正文、写缓存、生成 URI或启动系统 Ability 时失败。缓存写入确认实际 UTF-8 字节数,短写直接抛错;旧文件在新内容较短时 truncate,防止尾部残留上一次分享正文。启动失败只影响本次分享,不触发保存或恢复记录清理。
大文档模式禁止 PNG 是主动保护:5 MiB 以上源码已经关闭专业预览,强制创建超大 DOM 和 PixelMap会破坏输入性能。分享 Markdown 不依赖预览,所以保持可用。不同输出命令根据成本分别启用,比“一到大文件所有按钮全禁用”更符合用户任务。
性能预算不能只看编码时间
图片输出的峰值包含专业渲染 DOM、ArkWeb 全页绘制缓冲、PixelMap、ImagePacker 工作内存和目标文件缓存。高度接近 16000 px 时,即使宽度只有 1000 px,原始 RGBA 也可能超过 60 MiB。真机验收需要记录宽高、捕获耗时、编码耗时、峰值内存和输出字节,不能只看按钮到提示的总时间。
当前 16000 px 上限防止超过平台能力,但不等于合理内存上限。G3-10 应在 Release 真机上用纯文本、表格、图片和 Mermaid 四类语料测量,必要时再增加像素总量上限,而不是只限制单边长度。
分享路径的主要成本是 UTF-8 编码和 fsync。20 MiB 文档读取服务已有上限,而大文档分享仍会复制一份缓存。后续可研究从编辑器分块传输,但现阶段 JavaScript 返回完整字符串与保存路径一致,优先保证正文版本正确。
对竞争优势的谨慎判断
PNG 与系统分享让 OhMarkdown 更接近鸿蒙 PC 原生工具,而不是把所有输出压成 Web 下载。系统文件选择器、ImagePacker、打印服务、隐式 Want 和 URI 权限分别使用平台所有权边界,用户获得符合桌面预期的保存与分享体验。
但“使用系统 API”本身不是领先。当前记分卡只能给导出复杂文档 2 分:实现、自动化、错误路径和构建已齐,在线模拟器系统流程仍待完成。达到 3 分需要最新 HAP 中确认按钮、选择器、PNG、系统分享目标和 ohosTest;达到 4 分还需真机 Release 与 Typora、Obsidian、MarkText 等使用同一复杂文档比较操作数、时间、输出一致性和失败恢复。
真正可能形成优势的是组合:未保存正文分享不会落后;PNG 只截最终预览;本地图片不越权;大文档保护不阻止纯文本分享;所有输出入口进入命令面板;失败不污染文档。这些都能用任务而非宣传语测量。
仍待解决的问题
第一,超 16000 px 的长图需要分片方案,当前明确失败。第二,PNG 暂不提供背景透明、缩放倍率和选区导出,它们属于 PRD 的 P1 增强。第三,系统分享对 text/markdown 的目标覆盖取决于设备安装应用,需要验证无目标、单目标和多目标行为。
第四,缓存快照需要长期清理策略与真机权限复核。第五,分享 HTML、PDF 或 PNG 还没有统一进入系统分享,当前 P0 只分享 Markdown。第六,最新模拟器截图和系统 UI 证据尚未取得,这个缺口已经记录在仓库测试报告中,不由文章替代。
结论
预览 PNG 和 Markdown 分享是两条不同的数据通路:前者把最终排版跨越 Web 布局和 PixelMap变成用户文件,后者把当前源码跨越缓存与 URI 权限交给系统目标。OhMarkdown 用 ExportService 收敛文件名、选择器、PNG 编码和分享快照,用有界状态协议协调 ArkWeb,用 finally 保证 PixelMap、packer、文件描述符和页面模式都能恢复。
提交 6c823eb 已让这两条路径进入主分支,并通过 40 项 Web 回归、Debug HAP、UnitTestBuild 和 ohosTest HAP 编译。设备处于离线状态时,工程结论停在“实现与自动化完成”,不会擅自升级为“鸿蒙 PC 模拟器通过”。等系统交互闸门补齐后,这篇文章再增加最新按钮、PNG 产物和分享目标截图,G3-08 才具备关闭条件。
模拟器设备闭环补测
模拟器恢复后,最新 Debug HAP 已在 MateBook Pro 2in1 环境完成 PNG 主链复验。设置与导出面板中的“导出预览 PNG”使用系统 DocumentSave 保存为 G3-08-验证.png,操作完成后应用状态栏显示“预览图片已导出”。这条结果覆盖了专业渲染等待、导出捕获模式、全页快照、PixelMap、ImagePacker、系统选择器和目标文件写入,不再只是 Playwright 对 Web 捕获状态机的替代验证。

本轮没有把“应用提示成功”直接等同于“独立产物视觉一致”。HDC 不能直接读取 DocumentSave 用户 URI,但系统文件管理器可以把文件交给独立图片查看器。双击 G3-08-验证.png 后,查看器显示 36.13 KB 文件和正文“鸿蒙PC剪贴板验证”,窗口标题、缩放控件和后方文件管理器共同证明它不是应用内预览。

这张证据关闭了 PNG “只写入未查看”的缺口,但不把一行短文扩展成复杂长图结论。公式、Mermaid、多张本地图片、16000 px 临界高度和末尾完整性仍要在 Release 真机压力语料中继续核对。
系统分享也已真实启动,不再停留在 Want 构造代码。模拟器进入系统能力解析流程,并展示了日历隐私授权页面,说明隐式 Want 已交给系统;最终系统返回 No matching ability is found.,应用状态栏显示“分享失败”,编辑正文、脏标记和当前标签均未受影响。

这个结果验证了两件事。第一,分享失败路径是可恢复的:缓存快照创建和 URI 授权不会把失败转化为保存或内容丢失。第二,安装环境决定接收能力覆盖;只有在安装明确声明接收 text/markdown 或通用文件流的应用后,才能验收成功交付。测试不能为了获得一张“成功”截图而把 MIME 降级成含义不准确的任意类型,也不能指定一个并不存在于普通用户设备的测试 Ability。
最终设备 ohosTest 9/9 通过,原生分享快照能够按 UTF-8 重读,中文清洗文件名和正文一致性成立。G3-08 当前因此形成了诚实的三段结论:PNG 系统写入与独立查看通过;分享系统调度与失败恢复通过但兼容接收目标待补;Web、ArkTS 构建和原生文件测试全部通过。只有在具备目标应用的鸿蒙 PC 真机上完成成功接收,才允许把系统分享写成完整闭环。
更多推荐

所有评论(0)