
鸿蒙 PC Markdown 编辑器图片粘贴从系统剪贴板到标准相对链接在 Markdown 编辑器里粘贴图片看起来像一个非常短的动作按下CtrlV正文出现![]()预览显示图片。真正的文件工作流至少包含剪贴板格式识别、大小限制、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.etsexportinterfaceAssetImportRequest{requestId:string;name:string;mimeType:string;base64:string;byteLength:number;source:string;}exportinterfaceImportedAsset{requestId:string;storedName:string;relativePath:string;byteLength:number;}请求不携带目标绝对路径。资源目录由原生层依据当前文档 URI、用户设置和已授权工作区决定。Web 即使构造恶意名称也只能被原生清洗后写入允许目录。返回值同样只给标准相对路径不把系统 URI 暴露给页面。类型白名单拒绝活动内容当前只接受 PNG、JPEG、GIF 和 WebPfunctionextensionForMimeType(mimeType:string):string{if(mimeTypeimage/png)return.png;if(mimeTypeimage/jpeg)return.jpg;if(mimeTypeimage/gif)return.gif;if(mimeTypeimage/webp)return.webp;thrownewError(Only PNG, JPEG, GIF and WebP images are supported.);}SVG 没有进入第一阶段白名单。SVG 可以包含链接、脚本语义和复杂外部资源需要独立净化与呈现策略。仅通过扩展名允许 SVG 会扩大 ArkWeb 攻击面。项目宁可先支持四种常见位图格式也不为了“格式更多”跳过安全设计。原生层会同时校验 MIME、Base64 字符集与长度、声明字节数、解码后字节数。单个资源上限 10 MiB。声明大小不是可信事实只有解码结果与声明相等才继续。Base64 最大字符数也受限避免在解析前就接受巨大 JSON 载荷。请求验证是第二道边界Web 已经检查过数据但原生不能信任来自页面的载荷。真实校验逻辑包括functionvalidateAssetRequest(request:AssetImportRequest):void{if(!/^asset-[0-9]-[0-9]$/.test(request.requestId)||request.requestId.length64){thrownewError(The asset request identifier is invalid.);}if(request.source!pasterequest.source!drop){thrownewError(The asset import source is invalid.);}if(!Number.isInteger(request.byteLength)||request.byteLength0||request.byteLengthMAX_IMPORTED_ASSET_BYTES){thrownewError(The image exceeds the 10 MB import limit.);}extensionForMimeType(request.mimeType);if(request.base64.length0||request.base64.length%4!0||!/^[A-Za-z0-9/]*{0,2}$/.test(request.base64)){thrownewError(The image payload is not valid Base64 data.);}}请求 ID 让 Web 可以把异步结果对应到正确的粘贴位置并阻止任意超长标识占用映射。来源字段决定拖放特殊策略能否进入当前路径。验证失败只返回错误不修改正文。Bridge JSON 本身还有总字符上限。解析前检查载荷长度避免先为异常大字符串分配结构。这样的限制需要 Web 和原生都存在Web 提前反馈体验更好原生负责最终安全。文件名清洗保证跨工具可用剪贴板图片名称可能为空、含路径分隔符、控制字符或不同平台禁止字符。项目保留可识别基础名同时把危险字符和空白归一化exportfunctionsanitizeAssetBaseName(name:string):string{letsanitizedstripFileExtension(name.trim()).replace(/[:/\\|?*\u0000-\u001F\u007F]/g,-).replace(/\s/g,-).replace(/-/g,-).replace(/^[.-]|[.-]$/g,);if(sanitized.length0){sanitizedimage;}returnsanitized.slice(0,MAX_ASSET_NAME_CHARACTERS);}扩展名不直接沿用输入而是由经过白名单的 MIME 重新决定。这防止photo.png.exe或名称与类型不一致。基础名最多 96 个字符为冲突编号和扩展名保留空间也减少极端文件名对文件系统与 UI 的影响。清洗不意味着悄悄覆盖。目标已存在时按name-2.jpg、name-3.jpg递增最多尝试 9999 次。每次提交前再次检查目标处理“查到可用名称后另一个操作抢先创建”的竞态。资源目录受授权父目录约束目录名称由设置规则生成exportfunctioncreateAssetDirectoryName(documentName:string,rule:AssetDirectoryRule):string{if(ruleAssetDirectoryRule.SHARED_ASSETS){returnassets;}return${sanitizeAssetBaseName(documentName)}.assets;}原生从已授权工作区父 URI 或文档可访问父目录出发通过安全单段名称创建子 URI。createChildUri拒绝空名称、.、..、正反斜杠。路径不是用未经解析的用户文本任意拼接。若目标同名对象已经存在但不是目录导入失败并解释“资源目录被文件占用”。应用不会删除或重命名该文件。文件系统冲突必须显式交给用户处理不能为了完成粘贴破坏其他数据。先完整写入再提交最终文件资源导入使用临时文件事务。服务先在目标目录分配隐藏临时名完整写入字节、检查返回长度并fsync再提交到最终名称。真实写入函数为asyncfunctionwriteAssetBytes(targetUri:string,bytes:Uint8Array):Promisevoid{try{constfileawaitfileIo.open(targetUri,fileIo.OpenMode.CREATE|fileIo.OpenMode.WRITE_ONLY|fileIo.OpenMode.TRUNC);try{constbuffernewArrayBuffer(bytes.length);newUint8Array(buffer).set(bytes);constwrittenBytesawaitfileIo.write(file.fd,buffer);if(writtenBytes!bytes.length){thrownewError(The complete image could not be written.);}awaitfileIo.fsync(file.fd);}finally{awaitfileIo.close(file);}}catch(error){if(awaitfileIo.access(targetUri))awaitfileIo.unlink(targetUri);throwerror;}}沙箱可原子重命名路径使用 rename用户存储受文件服务限制时服务从已同步缓存写入最终文件并重新打开复核大小成功后删除临时文件。失败路径尽力清理临时与不完整目标。这套事务不能保证所有硬件断电情形已经验证但它明确阻止常见的半写文件和“write 返回较短却当成功”。G3-10 仍需故障注入与真机文件系统测试。Markdown 链接最后才插入importAsset只有在文件提交成功后返回relativePath。Web 侧保持一个 pending 请求成功回调时才在原选择位置插入 MarkdownfunctioncompleteAssetImport(requestId:string,relativePath:string,storedName:string):void{constpendingpendingAssetImports.get(requestId);if(!pending||!isSafeRelativeAssetPath(relativePath)){return;}constmarkdownPathencodeMarkdownAssetPath(relativePath);constmarkdown;editor.dispatch({changes:{from:pending.from,to:pending.to,insert:markdown},selection:{anchor:pending.frommarkdown.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 模拟器闭环。真机性能和极限压力仍待后续评审这个边界同样属于可靠实现的一部分。