鸿蒙 PC Markdown 编辑器持久图片预览:受限读取、分块 Bridge 与 Blob URL 生命周期
2026/7/22 6:32:09 网站建设 项目流程

鸿蒙 PC Markdown 编辑器持久图片预览:受限读取、分块 Bridge 与 Blob URL 生命周期

图片粘贴成功时看到预览并不代表功能完成。浏览器可以临时显示剪贴板 File 的 Blob URL,但保存 Markdown、关闭应用再打开后,内存 URL 已经失效;若为了恢复预览直接给 ArkWeb 用户文件 URI,又会扩大页面文件权限和路径攻击面。OhMarkdown 的持久图片预览因此采用反向请求:Web 只识别受管理相对链接,原生层验证并读取真实文件,分块传回字节,Web 构建只在当前会话有效的 Blob URL。

实现已进入公开仓库 https://gitcode.com/VON-/codex_md_oh。第一纵切提交为0a02ce3,设备文件、重新打开预览和拖放收口提交为89a5e57,当前验证基线为0d8d38b。本文聚焦“保存后重新打开仍显示图片”的读取链路,不重复讲剪贴板导入或 Move/Reference 的全部写入语义,也不宣称未完成的 10 MiB 真机性能压力数据。

持久预览的完成定义

文档正文只保存标准 Markdown 相对链接,例如![图](assets/image.png)或文档专属目录。资源字节存在用户可管理目录,不嵌入正文 Data URL。关闭应用、重新打开同一文档后,预览请求该相对路径,原生读取实际文件,图片再次显示。

完成还包含失败语义:未保存文档不能读取相邻资源;绝对路径、跨级..、反斜线、query、fragment 和非图片扩展被拒绝;文件超过 10 MiB 或读取期间变化被拒绝;切换标签后旧请求不能污染新会话;分块不完整、Base64 非法或字节长度不符不能创建 Blob;旧 Object URL 必须回收。

这个定义把“看见图片”提升为可迁移、可重启、可限制和可清理的本地资源能力。

为什么不能把文件 URI 直接交给 ArkWeb

ArkWeb 页面运行 marked、DOMPurify、命令面板和编辑器逻辑。即使内容离线,也应按不可信展示层限制权限。若页面可直接读取任意file://或系统 URI,恶意 Markdown 可能尝试引用用户其他文件,预览就变成路径探测器。

系统 URI 还可能需要 DocumentViewPicker 授权和平台文件服务解析,不是普通浏览器 URL。直接写进 img src 既不可靠,也会把原生能力泄漏给 Web。CSP 与 DOMPurify只能净化 DOM,不能替代文件授权边界。

因此 Web 只能提交 requestId、sessionId 和相对路径;ArkTS 保留 documentUri 与授权 parentUri。文件服务验证目录后读取字节,Web 永远不知道绝对路径。Bridge 传输的是有限图片数据,不是开放文件 API。

相对路径只允许两段

AssetService.validateAssetReadRequest先验证请求标识与文档 session 格式,再限制路径长度和字符。路径必须恰好两段:第一段只能是assets或当前文档名推导的专属 assets 目录,第二段是受支持图片文件名。

functionvalidateAssetReadRequest(documentName:string,request:AssetReadRequest):Array<string>{if(!/^asset-read-[0-9]+-[0-9]+$/.test(request.requestId)||request.requestId.length>72||!/^document-session-[0-9]+$/.test(request.sessionId)){thrownewError('The local image request identifier is invalid.');}if(request.relativePath.length===0||request.relativePath.length>512||request.relativePath.includes('\\')||request.relativePath.includes('?')||request.relativePath.includes('#')){thrownewError('The local image path is invalid.');}constsegments=request.relativePath.split('/');if(segments.length!==2||segments.some((segment)=>segment.length===0||segment==='.'||segment==='..')){thrownewError('The local image path must stay inside one asset directory.');}constdocumentAssets=createAssetDirectoryName(documentName,AssetDirectoryRule.DOCUMENT_ASSETS);if(segments[0]!=='assets'&&segments[0]!==documentAssets){thrownewError('The local image path is outside the configured asset directories.');}mimeTypeForFileName(segments[1]);returnsegments;}

恰好两段比先 normalize 再判断前缀更易审计。assets/../secret有三段并含..,直接拒绝;assets/a.png?x与 fragment 也拒绝,避免同一路径产生不同缓存键或绕过扩展判断。mimeTypeForFileName白名单扩展,不把任意文本当图像。

文档名决定专属资源目录

OhMarkdown 支持共享assets和文档专属目录两种规则。专属目录由当前 Markdown 文档名安全推导,例如notes.md对应一个受管理名称。读取时不相信 Markdown 自己声明任意目录,而是重新根据documentName计算允许值。

这样把文档从一个工作区复制到另一个目录时,相对资源结构仍可迁移;同时阻止链接跨到 sibling 目录。共享 assets 适合多文档共用图片,文档专属目录降低重名和误引用。

设置只选择导入默认目录,不扩大读取白名单。读取同时接受两种受管理目录,是为了已经写入的文档在用户改变设置后仍能预览。设置影响未来写入,不应让旧链接突然失效。

原生读取使用 NOFOLLOW 与完整长度检查

验证路径结构后,原生根据已授权文档 parentUri 创建目录和文件 URI,以READ_ONLY | NOFOLLOW打开。NOFOLLOW 避免最终图片项通过符号链接跳到受管理目录外。

exportasyncfunctionreadAsset(documentUri:string,documentName:string,request:AssetReadRequest,authorizedParentUri:string=''):Promise<ReadAsset>{if(documentUri.length===0){thrownewError('Save the document before loading a local image.');}constsegments=validateAssetReadRequest(documentName,request);constdirectoryUri=createChildUri(getDocumentParentUri(documentUri,authorizedParentUri),segments[0]);constassetUri=createChildUri(directoryUri,segments[1]);constfile=awaitfileIo.open(assetUri,fileIo.OpenMode.READ_ONLY|fileIo.OpenMode.NOFOLLOW);try{conststat=awaitfileIo.stat(file.fd);if(stat.size<=0||stat.size>MAX_IMPORTED_ASSET_BYTES){thrownewError('The local image exceeds the 10 MB preview limit.');}constcontent=newArrayBuffer(stat.size);constbytesRead=awaitfileIo.read(file.fd,content,{length:stat.size});if(bytesRead!==stat.size){thrownewError('The local image changed while it was being read.');}return{requestId:request.requestId,sessionId:request.sessionId,relativePath:request.relativePath,mimeType:mimeTypeForFileName(segments[1]),base64:BASE64_HELPER.encodeToStringSync(newUint8Array(content)),byteLength:bytesRead};}finally{awaitfileIo.close(file);}}

先 stat 再一次完整读取,随后核对 bytesRead。文件在读取时截断会失败,不把部分字节当合法图像。finally 始终关闭 fd。当前最大 10 MiB 与导入限制一致,避免预览读取比写入允许更大的资源。

未保存文档没有安全父目录

Untitled 文档尚无稳定 documentUri,无法定义“相邻 assets”是谁。服务直接要求先保存,而不是猜工作区根或写应用沙箱。这样 Markdown 相对链接的基准始终清楚。

如果文档通过工作区枚举打开,authorizedParentUri来自已授权条目;如果是可解析本地路径,则由 documentUri 获取父目录。Web 无法提交 parentUri,避免自行选择权限边界。

错误会返回预览不可用状态,但不修改正文链接。用户保存文档或恢复资源后可以再次渲染,失败不应删除 Markdown 内容。

sessionId 阻止跨标签污染

多文档编辑器中,图片请求发出后用户可能切换标签。请求携带document-session-N,WorkspaceShell 在读取前和传回前都比较activeDocumentSessionId。旧会话结果不能应用到新标签。

privateasyncreadEditorAsset(payload:string):Promise<void>{constrequest=JSON.parse(payload)asAssetReadRequest;constsessionId=typeofrequest.sessionId==='string'?request.sessionId:'';if(sessionId!==this.activeDocumentSessionId){thrownewError('The document session changed before the local image was loaded.');}constasset=awaitreadAsset(this.documentUri,this.documentName,request,this.getActiveWorkspaceParentUri());if(sessionId===this.activeDocumentSessionId){awaitthis.completeEditorAssetRead(asset);}else{this.failEditorAssetRead(asset.requestId,sessionId,asset.relativePath,'The document session changed before the local image was loaded.');}}

双重检查覆盖异步文件 I/O 窗口。只在开始比较不足,读取 10 MiB 期间完全可能切换标签。失败回传也携带原 request/session/path,Web 只标记对应图片。

session 格式受正则限制,不接受任意长字符串。请求 payload 还限制 2048 字符,JSON 解析失败走统一错误路径。

为什么要分块通过 runJavaScript

AssetService 返回 Base64,若一次拼进巨型 JavaScript 字符串,10 MiB 图片会膨胀到约 13.3 MiB,单次 Bridge 调用和脚本解析压力过大。WorkspaceShell 使用 64 KiB 字符块,先 begin 声明元数据,逐块 append,最后 finish。

constASSET_READ_TRANSFER_CHUNK_CHARACTERS:number=64*1024;awaitthis.editorController.runJavaScript(`window.OhMarkdownEditor?.beginAssetRead(${JSON.stringify(asset.requestId)},`+`${JSON.stringify(asset.sessionId)},${JSON.stringify(asset.relativePath)},`+`${JSON.stringify(asset.mimeType)},${asset.byteLength},${asset.base64.length})`);for(letoffset=0;offset<asset.base64.length;offset+=ASSET_READ_TRANSFER_CHUNK_CHARACTERS){constchunk=asset.base64.slice(offset,offset+ASSET_READ_TRANSFER_CHUNK_CHARACTERS);awaitthis.editorController.runJavaScript(`window.OhMarkdownEditor?.appendAssetReadChunk(`+`${JSON.stringify(asset.requestId)},${JSON.stringify(chunk)})`);}awaitthis.editorController.runJavaScript(`window.OhMarkdownEditor?.finishAssetRead(${JSON.stringify(asset.requestId)})`);

每次 await 保证顺序,requestId 关联同一接收状态。参数都用 JSON.stringify,Base64 和路径不会作为代码片段解释。分块不能减少 Base64 总内存,但降低单脚本峰值与调用风险。

Web begin 阶段先验证声明

beginAssetRead不立即信任原生。它检查 pending 请求是否存在、session/path 是否一致、MIME 是否受支持、Base64 总字符数是否为合法四的倍数、byteLength 是否在 10 MiB 内。任何条件失败都完成并释放 pending。

functionbeginAssetRead(requestId:string,sessionId:string,relativePath:string,mimeType:string,byteLength:number,totalBase64Characters:number):void{constpending=pendingAssetReads.get(requestId);constmaximum=Math.ceil(MAX_IMPORTED_ASSET_BYTES/3)*4+4;if(!pending||pending.sessionId!==sessionId||pending.relativePath!==relativePath||!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)||!Number.isInteger(totalBase64Characters)||totalBase64Characters<=0||totalBase64Characters>maximum||totalBase64Characters%4!==0||!Number.isInteger(byteLength)||byteLength<=0||byteLength>MAX_IMPORTED_ASSET_BYTES){constrejected=completePendingAssetRead(requestId);if(rejected)releaseRequestedAssetRead(rejected.sessionId,rejected.relativePath);return;}receivingAssetReads.set(requestId,{sessionId,relativePath,mimeType,byteLength,totalBase64Characters,receivedBase64Characters:0,chunks:[]});}

Bridge 双方都验证并非不信任自家代码,而是保护协议边界和未来变更。原生 bug 或乱序调用不会让 Web 无限分配数组。

append 与 finish 保证流完整

每块不能为空,不能超过单块上限,字符只能是 Base64,累计不能超过声明。finish 要求累计字符数完全等于声明,pending/session/path 再次一致。少一块、多一块、错 requestId 都不会创建图片。

创建 Blob 时按四字符边界解码,每块末尾余数用 carry 拼到下一块;最终 carry 必须为空。解码总字节数必须等于原生声明的byteLength

functionfinishAssetRead(requestId:string):void{constreceiving=receivingAssetReads.get(requestId);if(!receiving||receiving.receivedBase64Characters!==receiving.totalBase64Characters){constrejected=completePendingAssetRead(requestId);if(rejected){releaseRequestedAssetRead(rejected.sessionId,rejected.relativePath);}return;}constpending=completePendingAssetRead(requestId);if(!pending||pending.sessionId!==receiving.sessionId||pending.relativePath!==receiving.relativePath){return;}constpreviewUrl=createBase64ObjectUrl(receiving.chunks,receiving.mimeType,receiving.byteLength);storeLocalAssetPreview(encodeMarkdownAssetPath(receiving.relativePath),previewUrl,receiving.byteLength,true,receiving.sessionId);}

长度一致不能证明图片语义有效,但 MIME 扩展白名单、Base64 规则和浏览器解码共同构成当前边界。更强魔数检测可在原生读取时补充,当前导入路径已有 MIME/扩展约束。

Blob URL 只存在于当前进程

Web 将解码后的 ArrayBuffer parts 组成 Blob,再URL.createObjectURL。Markdown 正文仍是相对路径,Blob URL 只用于当前预览 DOM,不写回文档。应用重启后重新请求文件并生成新 URL。

这种设计兼顾迁移性与浏览器渲染:文档可以被其他 Markdown 工具按相对链接理解,ArkWeb 得到可显示的安全对象 URL,却不知道用户文件路径。

缓存项记录字节长度、是否持久资源和 sessionId。切换或关闭 session 时应撤销不再使用的 Object URL,防止长时间打开大量图片积累内存。Playwright 覆盖 Blob URL 回收和会话切换。

请求队列与重复资源

预览渲染可能同时发现多个本地图片。Web 将请求排队并限制活动读取,避免一次向 Bridge 发大量 10 MiB 请求。相同 session/path 已有缓存时复用,不重复读文件;正在请求时也应去重。

资源路径经过 Markdown 编码规范化后作为预览映射键。query 和 fragment 在原生验证前已拒绝,避免同一文件绕过缓存形成多份 Blob。sessionId 隔离同名相对路径,不让两个文档互相引用内存对象。

缓存不是持久事实。磁盘图片外部变化时当前版本不会主动指纹轮询所有资源,重新渲染或重开才读取最新字节。对文档正文已有外部修改检测,图片资源监控仍是后续方向。

失败时保持 Markdown 不变

读取失败调用failAssetRead,只查找 src 与编码路径匹配的 img,设置data-local-asset-unavailable和 title。它不删除 Markdown 链接,也不把失败消息插入正文。

functionfailAssetRead(requestId:string,sessionId:string,relativePath:string,message:string):void{constpending=completePendingAssetRead(requestId);if(pending){releaseRequestedAssetRead(pending.sessionId,pending.relativePath);}if(!pending||pending.sessionId!==sessionId||pending.relativePath!==relativePath||sessionId!==activeSessionId){return;}constencodedPath=encodeMarkdownAssetPath(relativePath);preview.querySelectorAll<HTMLImageElement>('img').forEach((image)=>{if((image.getAttribute('src')??'')===encodedPath){image.dataset.localAssetUnavailable='true';image.title=message||getEditorMessages().localImageUnavailable;}});}

正文是用户事实,预览是派生状态。资源暂时缺失、权限变化或读取超时不应篡改正文。用户找回图片后链接仍在,下一次渲染可以恢复。

错误 title 随运行时语言设置更新默认文案,但底层详细 message 保留诊断信息。绝对路径不应进入 Web 消息,原生错误以受限相对路径为上下文。

真实重新打开截图

下面截图来自 HarmonyOS MateBook Pro 2in1 模拟器。图片先通过安全粘贴落入资源目录并插入相对链接,保存文档、关闭并重新打开后,预览再次显示同一图片。这次显示来自原生读取与分块 Blob 链路,而不是最初剪贴板临时 URL。

截图中源码保持标准 Markdown,相对资源可以随文档目录迁移。设备同时验证资源文件真实存在、字节可读和重启后预览恢复。它不能单独证明所有越界拒绝,因此还需要单元、ohosTest 与 Playwright。

自动化与设备测试

Playwright 覆盖持久预览请求、begin/append/finish 分块、失败不改正文、会话切换、Blob URL 缓存与回收。ArkTS 单元测试覆盖目录规则、路径和模式解析。ohosTest 在设备文件系统写入真实图片字节、执行重名与读取,最终 MateBook Pro 2in1 模拟器7/7

当前全量 Web 测试为30/30。最终 Debug HAP 大小 1,520,352 字节,SHA-256367ab8650479aa1fa8fe73bd1ebadd9a53f46659c850c2e388fc799d5cb88e5b;ohosTest HAP 大小 2,360,824 字节,SHA-256b7230037b51044fe16168d2c835fb891e1c70f675941a1046165bc895217592c。产物未签名。

浏览器自动化验证协议状态机,ohosTest 验证真实 fileIo,人工模拟器验证系统剪贴板、保存、重开和可见预览。10 MiB 极限、长时间多图缓存和真机内存压力仍需 G3 质量阶段测量。

性能与内存预算

Base64 会比原字节膨胀约三分之一,原生同时持有 ArrayBuffer 与 Base64,Web 接收 chunks 后再解码为 ArrayBuffer parts。10 MiB 上限控制单资源峰值,但多图并发仍可能放大内存,因此请求队列和 URL 回收重要。

64 KiB 字符块降低单次 runJavaScript 负担,不减少总传输。未来平台若提供更直接二进制通道,可替代 Base64;在当前架构中,分块协议比一次巨型脚本更可控。

预览只读取实际可见或渲染发现的受管理图片,不扫描整个工作区。大文档模式会限制预览能力,避免在超大文本中同时解析和加载大量资源。性能结论应以真机轨迹为准,当前只确认功能和边界。

安全威胁复盘

路径遍历由两段结构、dot 拒绝、反斜线/query/fragment 拒绝和目录白名单防护;符号链接由 NOFOLLOW 防护;大文件由 10 MiB stat 与 Base64 上限防护;协议注入由 request 格式、JSON.stringify 和字符白名单防护;跨标签污染由 session 双检防护;不完整流由字符数和字节数核对防护。

Web 无法选择 documentUri、parentUri 或任意文件 API。Blob URL 只代表已验证图片字节,CSP 与 DOMPurify 继续保护预览 DOM。图片解码器本身仍属于平台攻击面,严格 MIME、大小和受管理目录减少输入范围,但不能宣称消除所有恶意图片风险。

已知限制与后续演进

当前不监控磁盘图片外部变化,不解析 EXIF,不做缩略图,不压缩原图,不支持 SVG 等高风险类型,也没有跨文档全局缓存。10 MiB 是硬上限,真机多图峰值尚未形成性能报告。

Base64 Bridge 有内存开销,可探索 ArrayBuffer 通道或原生安全资源映射。缓存可增加总字节预算和 LRU,避免长会话打开许多大图。魔数检测与解码失败结构化错误也可增强。

这些方向都不能破坏现有不变量:正文只存相对链接,Web 不获文件权限,请求绑定 session,失败不改正文,URL 可回收,重开能够重新读取。

结论

OhMarkdown 持久图片预览把一次性 Blob 体验变成可重启本地资源链路:Web 发现受管理相对路径,ArkTS 验证两段目录和当前 session,以 NOFOLLOW 读取完整有限字节,64 KiB 分块传输,Web 再次验证声明、流长度与字节数并创建可回收 Blob URL。

真实模拟器已经证明保存并重新打开后图片仍显示,Playwright、ohosTest 与 HAP 构建覆盖协议和文件路径。它的产品优势不是“支持图片”,而是让图片与 Markdown 一起可迁移,同时不给 ArkWeb 任意文件读取能力,并把失败留在预览层而不是污染用户正文。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询