鸿蒙 PC Markdown 编辑器图片粘贴:从系统剪贴板到标准相对链接
鸿蒙 PC Markdown 编辑器图片粘贴:从系统剪贴板到标准相对链接
在 Markdown 编辑器里粘贴图片,看起来像一个非常短的动作:按下 Ctrl+V,正文出现 ![](),预览显示图片。真正的文件工作流至少包含剪贴板格式识别、大小限制、MIME 校验、文件名清洗、资源目录授权、重名处理、完整落盘、链接插入、重新打开后的读取和缓存回收。任何一步顺序错误,都可能留下断链、覆盖旧资源,甚至让 Web 页面获得不该拥有的文件访问能力。
OhMarkdown 的图片资源闭环已经进入公开仓库 https://gitcode.com/VON-/codex_md_oh。第一纵切提交是 0a02ce3,设备落盘、持久预览和文件拖放最终收口于 89a5e57。本文聚焦系统剪贴板粘贴,不把拖放的 Move/Reference 语义混在一起,也不把 10 MiB 压力和鸿蒙 PC 真机结果提前描述为已完成。
产品语义必须先于实现细节
图片粘贴的完成条件不是“编辑器里看到了图片”,而是资源文件已经完整写入用户可管理目录,Markdown 正文只保存标准相对链接,保存并重新打开后仍能显示,失败时正文不发生变化。这个定义排除了临时 Data URL 直接塞进 Markdown、只在内存 Blob 中预览以及先插链接再异步写文件等看似快速的方案。
项目提供两种资源目录规则:与文档同名的 ${documentName}.assets,以及工作区共享 assets。两者都是文档父目录下的一层目录,链接保持两段相对路径。用户可以用其他编辑器、Git 或文件管理器理解这些资源,不依赖私有数据库。
未保存文档没有稳定父目录,因此不能粘贴到未知位置。单文件选择器若只授予文件 URI,也不推测相邻目录权限;应用提示先保存并打开所在文件夹。拒绝“猜权限”会多一步操作,却能避免在鸿蒙权限模型下产生无法重开的伪成功。
Web 层只负责识别与提出请求
ArkWeb 内的 CodeMirror 能接收剪贴板事件,读取浏览器提供的图片 File,但它不直接创建用户目录。Web 生成受限请求:请求 ID、原始名称、MIME、Base64、声明字节长度和来源。Bridge 方法固定为 onAssetImport。
真实请求类型位于 AssetService.ets:
export interface AssetImportRequest {
requestId: string;
name: string;
mimeType: string;
base64: string;
byteLength: number;
source: string;
}
export interface ImportedAsset {
requestId: string;
storedName: string;
relativePath: string;
byteLength: number;
}
请求不携带目标绝对路径。资源目录由原生层依据当前文档 URI、用户设置和已授权工作区决定。Web 即使构造恶意名称,也只能被原生清洗后写入允许目录。返回值同样只给标准相对路径,不把系统 URI 暴露给页面。
类型白名单拒绝活动内容
当前只接受 PNG、JPEG、GIF 和 WebP:
function extensionForMimeType(mimeType: string): string {
if (mimeType === 'image/png') return '.png';
if (mimeType === 'image/jpeg') return '.jpg';
if (mimeType === 'image/gif') return '.gif';
if (mimeType === 'image/webp') return '.webp';
throw new Error('Only PNG, JPEG, GIF and WebP images are supported.');
}
SVG 没有进入第一阶段白名单。SVG 可以包含链接、脚本语义和复杂外部资源,需要独立净化与呈现策略。仅通过扩展名允许 SVG 会扩大 ArkWeb 攻击面。项目宁可先支持四种常见位图格式,也不为了“格式更多”跳过安全设计。
原生层会同时校验 MIME、Base64 字符集与长度、声明字节数、解码后字节数。单个资源上限 10 MiB。声明大小不是可信事实,只有解码结果与声明相等才继续。Base64 最大字符数也受限,避免在解析前就接受巨大 JSON 载荷。
请求验证是第二道边界
Web 已经检查过数据,但原生不能信任来自页面的载荷。真实校验逻辑包括:
function validateAssetRequest(request: AssetImportRequest): void {
if (!/^asset-[0-9]+-[0-9]+$/.test(request.requestId) ||
request.requestId.length > 64) {
throw new Error('The asset request identifier is invalid.');
}
if (request.source !== 'paste' && request.source !== 'drop') {
throw new Error('The asset import source is invalid.');
}
if (!Number.isInteger(request.byteLength) || request.byteLength <= 0 ||
request.byteLength > MAX_IMPORTED_ASSET_BYTES) {
throw new Error('The image exceeds the 10 MB import limit.');
}
extensionForMimeType(request.mimeType);
if (request.base64.length === 0 || request.base64.length % 4 !== 0 ||
!/^[A-Za-z0-9+/]*={0,2}$/.test(request.base64)) {
throw new Error('The image payload is not valid Base64 data.');
}
}
请求 ID 让 Web 可以把异步结果对应到正确的粘贴位置,并阻止任意超长标识占用映射。来源字段决定拖放特殊策略能否进入当前路径。验证失败只返回错误,不修改正文。
Bridge JSON 本身还有总字符上限。解析前检查载荷长度,避免先为异常大字符串分配结构。这样的限制需要 Web 和原生都存在:Web 提前反馈体验更好,原生负责最终安全。
文件名清洗保证跨工具可用
剪贴板图片名称可能为空、含路径分隔符、控制字符或不同平台禁止字符。项目保留可识别基础名,同时把危险字符和空白归一化:
export function sanitizeAssetBaseName(name: string): string {
let sanitized = stripFileExtension(name.trim())
.replace(/[<>:"/\\|?*\u0000-\u001F\u007F]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[.-]+|[.-]+$/g, '');
if (sanitized.length === 0) {
sanitized = 'image';
}
return sanitized.slice(0, MAX_ASSET_NAME_CHARACTERS);
}
扩展名不直接沿用输入,而是由经过白名单的 MIME 重新决定。这防止 photo.png.exe 或名称与类型不一致。基础名最多 96 个字符,为冲突编号和扩展名保留空间,也减少极端文件名对文件系统与 UI 的影响。
清洗不意味着悄悄覆盖。目标已存在时按 name-2.jpg、name-3.jpg 递增,最多尝试 9999 次。每次提交前再次检查目标,处理“查到可用名称后另一个操作抢先创建”的竞态。
资源目录受授权父目录约束
目录名称由设置规则生成:
export function createAssetDirectoryName(
documentName: string,
rule: AssetDirectoryRule
): string {
if (rule === AssetDirectoryRule.SHARED_ASSETS) {
return 'assets';
}
return `${sanitizeAssetBaseName(documentName)}.assets`;
}
原生从已授权工作区父 URI 或文档可访问父目录出发,通过安全单段名称创建子 URI。createChildUri 拒绝空名称、.、..、正反斜杠。路径不是用未经解析的用户文本任意拼接。
若目标同名对象已经存在但不是目录,导入失败并解释“资源目录被文件占用”。应用不会删除或重命名该文件。文件系统冲突必须显式交给用户处理,不能为了完成粘贴破坏其他数据。
先完整写入再提交最终文件
资源导入使用临时文件事务。服务先在目标目录分配隐藏临时名,完整写入字节、检查返回长度并 fsync,再提交到最终名称。真实写入函数为:
async function writeAssetBytes(targetUri: string, bytes: Uint8Array): Promise<void> {
try {
const file = await fileIo.open(targetUri,
fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY | fileIo.OpenMode.TRUNC);
try {
const buffer = new ArrayBuffer(bytes.length);
new Uint8Array(buffer).set(bytes);
const writtenBytes = await fileIo.write(file.fd, buffer);
if (writtenBytes !== bytes.length) {
throw new Error('The complete image could not be written.');
}
await fileIo.fsync(file.fd);
} finally {
await fileIo.close(file);
}
} catch (error) {
if (await fileIo.access(targetUri)) await fileIo.unlink(targetUri);
throw error;
}
}
沙箱可原子重命名路径使用 rename;用户存储受文件服务限制时,服务从已同步缓存写入最终文件并重新打开复核大小,成功后删除临时文件。失败路径尽力清理临时与不完整目标。
这套事务不能保证所有硬件断电情形已经验证,但它明确阻止常见的半写文件和“write 返回较短却当成功”。G3-10 仍需故障注入与真机文件系统测试。
Markdown 链接最后才插入
importAsset 只有在文件提交成功后返回 relativePath。Web 侧保持一个 pending 请求,成功回调时才在原选择位置插入 Markdown:
function completeAssetImport(
requestId: string,
relativePath: string,
storedName: string
): void {
const pending = pendingAssetImports.get(requestId);
if (!pending || !isSafeRelativeAssetPath(relativePath)) {
return;
}
const markdownPath = encodeMarkdownAssetPath(relativePath);
const markdown = ``;
editor.dispatch({
changes: { from: pending.from, to: pending.to, insert: markdown },
selection: { anchor: pending.from + markdown.length }
});
pendingAssetImports.delete(requestId);
}
返回路径还要再次检查为允许的两段相对路径。文件名编码和 alt 文本转义分别处理,避免空格、括号或方括号破坏 Markdown 语法。编辑事务一次完成替换和光标定位,进入正常 undo/redo 历史。
若写入失败、超时、会话切换或请求不存在,只清 pending 并显示错误,不插入链接。先插链接再回滚的方案会污染撤销历史,也可能在用户继续输入后错误删除其他文本,因此未采用。
异步结果必须绑定文档会话
图片编码和文件写入期间,用户可能切换标签。请求不仅有 requestId,原生层还读取当前 activeDocumentSessionId,完成前复核。持久预览请求更显式携带 sessionId。旧标签的图片不能被插入新标签。
原生导入占用文件操作锁,阻止同时启动另一个文件选择或资源事务,但 CodeMirror 输入仍可继续。成功回调使用粘贴时记录的范围;若文档发生大量变化,CodeMirror 的事务映射需要保证位置有效。当前 pending 机制和自动化覆盖连续操作,未来多图并发仍需更强位置映射测试。
外部冲突可见时拒绝图片导入。否则资源文件和 Markdown 正文可能基于不同磁盘版本提交,进一步复杂化冲突。先解决文档冲突,再进行新资源事务,是清晰的串行规则。
持久预览不让 ArkWeb直接读用户文件
保存并重开后,Markdown 里只有相对路径。ArkWeb 的 fileAccess 保持关闭,不能直接用页面 URL 读取任意文件。预览解析出形如 assets/name.ext 或 ${documentName}.assets/name.ext 的路径,发送 onAssetRead 给原生。
原生验证路径只能有两段,拒绝绝对路径、.、..、反斜杠、查询串、片段和非白名单扩展,并以 NOFOLLOW 打开不超过 10 MiB 的图片。读取后 Base64 按 64 KiB 字符分块传回,避免一次巨大脚本调用。
Web 校验元数据、分块总长度和最终解码字节数,创建 Blob URL 供预览使用。URL 按文档会话缓存,以真实字节计入 32 MiB 上限;淘汰、切换和关闭会话时调用 URL.revokeObjectURL。Base64 不长期留在 DOM。
CSP 与网络图片边界
编辑页 CSP 使用 default-src 'none' 与 connect-src 'none',图片只允许 data:、blob:、resource: 和 file:,不允许 http: 或 https:。应用不申请网络权限。即使 Markdown 内含远程图片,预览也不会主动联网追踪用户阅读行为。
设备测试发现当前 HarmonyOS 的 onlineImageAccess(false) 会连本地 Data URL 和 Blob URL 一并阻断,因此没有继续使用该开关。网络阻断由 CSP、无连接权限和受限资源读取共同承担。这是基于真实设备行为的取舍,而不是简单把一个名字听起来安全的开关全部打开。
本地图片读取仍不接受任意 file: 路径。只有 Markdown 中符合资源目录规则的相对路径能进入原生服务。多层防线避免 CSP 配置变化后页面突然获得广泛本地文件能力。
真实系统剪贴板设备闭环
MateBook Pro 2in1 模拟器中,图片从系统文件管理器复制,剪贴板所有者为 com.huawei.hmos.filemanager,记录 MIME 为 text/uri。应用把 203,166 字节 JPEG 写入文档专属资源目录,源文件与目标文件 SHA-256 一致,随后才插入相对链接。

保存并重新打开文档后,原生读取、分块 Bridge 和 Blob 缓存恢复同一图片预览:

完整报告位于 docs/test/ohmarkdown/2026-07-18-g3-04-image-assets/。这里的哈希和大小来自专用测试图片,不代表任意用户图片都会被上传或记录。
自动化与设备测试分工
Playwright 覆盖 PNG/JPEG 等类型、粘贴请求、成功后插入、失败不改正文、持久预览请求、分块读取、会话切换和 Blob URL 回收。ArkTS 单元测试覆盖名称清洗、目录规则、冲突编号和模式解析。ohosTest 在设备文件系统写入真实字节、重名并读取。人工模拟器路径覆盖系统剪贴板和重启重开。
G3-04 完成时 Playwright 为 28/28、ohosTest 为 6/6;后续 2ca99e9 统一基线达到 29/29 和 7/7。不能把后一个数字全部归因于图片功能。最终图片闭环提交 89a5e57 的 HAP 为未签名 Debug 产物。
压力测试仍需覆盖恰好 10 MiB、超过一字节、多图并发、32 MiB 缓存淘汰、反复切标签、低磁盘空间和应用中断。当前报告明确把这些收口到 G3-10。
常见失败路径
剪贴板没有受支持图片时,普通文本粘贴继续由编辑器处理;图片超过上限时提示并保持正文;未保存文档提示先保存;没有工作区父目录授权时提示打开文件夹;资源目录被普通文件占用时拒绝;Base64 与声明大小不符时拒绝;目标重名时分配编号;临时写入失败时清理;会话变化时不插入。
预览失败不会删除 Markdown 链接。链接仍是用户文档事实,应用显示占位与错误,用户可以修复文件。找不到资源、类型不支持、路径越界、读取变短和超时都必须独立处理,不能把异常资源拖垮整个预览。
恢复和保存也与图片事务相互影响。插入链接后文档变 dirty,由正常恢复快照保护;图片文件已经落盘,即使文档尚未保存也不会出现链接指向半文件。若用户撤销链接,资源目前不会自动删除,因为它可能被其他位置引用。自动垃圾回收需要引用分析与确认,不在当前范围。
没有采用的实现
没有把图片永久转为 Base64 Data URI 写进 Markdown。那会让文档体积暴涨、Git diff 难以审查,也降低跨工具兼容。没有把资源存入私有数据库,因为用户无法用普通文件工具管理,导出还需要额外解包。
没有让 ArkWeb直接打开系统绝对路径。页面脚本的攻击面比原生领域服务更宽,且 URI 授权生命周期难以管理。没有先插入占位链接再后台落盘,避免失败后残留断链和复杂回滚。
没有根据扩展名支持所有图片。当前 MIME 与扩展双重白名单更保守,SVG、AVIF、HEIC 等格式需分别评估解码、活动内容和系统兼容性后再加入。
性能与体验预算
粘贴路径当前使用 Base64 跨 Bridge,会产生约三分之一体积膨胀和两端内存副本。10 MiB 上限是基于风险的硬边界,不是最终性能结论。未来可以研究系统共享内存或临时 URI,但必须保持原生校验和会话绑定。
文件 I/O 异步进行,Web 编码仍可能对大图产生主线程压力。G3-10 要在真机记录粘贴开始、原生收到请求、fsync 完成、链接插入和预览显示各阶段耗时,并观察输入响应。没有这些数据前不宣称快于竞品。
视觉反馈使用状态栏而不是阻塞弹窗,正常导入不打断写作;错误信息要说明下一步,如先保存或打开文件夹。多图粘贴的进度、取消和批量事务还未完成,当前按单资源保证原子语义。
验收清单与结论
图片粘贴验收应确认:真实系统剪贴板可识别;四种白名单类型;10 MiB 双边限制;文件名清洗;重名不覆盖;目录授权不越界;完整写入并同步后才插链接;失败不改正文;链接是标准相对路径;重启后预览;跨标签结果不串线;Blob URL 回收;远程图片不联网;BOM、换行和恢复功能无回归。
当前 OhMarkdown 已经把图片粘贴从一个前端事件做成可审查的本地文件事务。它的优势不只是“能粘图”,而是资源可迁移、失败不污染正文、页面不获得任意文件权限,并且真实系统剪贴板到重新打开预览已经在鸿蒙 PC 模拟器闭环。真机性能和极限压力仍待后续评审,这个边界同样属于可靠实现的一部分。
更多推荐




所有评论(0)