鸿蒙 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 把 htmlbody#workspace 固定为 height: 100% 并隐藏外层溢出,#preview 自己滚动。这是桌面编辑体验需要的布局,但 whole-page snapshot 看到的“页面高度”仍可能只是窗口高度。捕获前必须把内部滚动转为页面自然高度。

Web 侧的 prepareImageExport 先调用与 HTML/PDF 相同的 prepareOutput,确认专业渲染和图片已经完成。随后记录 currentMode,把工作区切到预览模式,在根节点设置 data-export-capture="true"。对应 CSS 将 html、body、workspace 和 preview 改为自动高度与可见溢出,隐藏 editor,保持预览宽度和最小视口高度。

状态不是设置后立即返回真,而是等待一次 requestAnimationFrame。这让浏览器完成样式计算和布局,再读取 scrollWidthscrollHeightgetBoundingClientRect。如果同一 JavaScript 栈里设置属性后立刻捕获,平台可能读到旧几何,造成截断或空白。

几何协议必须显式包含上限

ArkWeb webPageSnapshot 的平台上限是 16000 px × 16000 px。实现没有用 Math.min 静默裁剪,而是把 widthheightmaximumsupported 一起序列化给 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/markdownuriability.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 pngshare markdown。Web 命令注册表只发送 exportImageshareMarkdown 两个固定字符串,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 真机上完成成功接收,才允许把系统分享写成完整闭环。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐