ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

编辑预览正常,导出却变了?排查 Canvas 尺寸与绘制顺序

编辑预览正常,导出却变了?排查 Canvas 尺寸与绘制顺序 图片编辑器里预览看起来没有问题下载后却出现文字位置不对、图层被遮住或透明区域变成白色。遇到这类现象我会先把“显示出来的画面”和“被编码的像素”拆开检查而不是立即怀疑 toBlob。本文以我维护的图片猫PicCat项目为例项目入口为www.piccat.cn。讨论对象是 webClientVue 的图片文字编辑器本地 Canvas 上叠加底图、文字、形状和图片图层不是服务端 AI 无痕改字。当前代码已经采用共享渲染函数和独立导出画布。下面沿这条链路说明它解决了什么、仍有哪些边界以及如何验证。分析以当前本地源码为准不将这些排查场景视为已确认的线上故障。1 先确定差异发生在哪一层ImageTextEditor.vue 的预览 Canvas 放在 .zoom-layer 中父层应用 useZoomPan 返回的 CSS transform下载时则创建一个没有加入页面 DOM 的普通 HTMLCanvasElement。两者共用 renderCanvas下载调用将 includeSelection 设为 false。这意味着最终图片应保留作品内容但不应带上选框、吸附参考线、界面棋盘格以及 CSS 提供的圆角与阴影。这些编辑辅助与界面样式本来就不是导出结果的一部分。观察到的现象优先检查不要急着归因于输出像素尺寸与预期不同canvas.width / height 的来源下载函数改变了分辨率拖动后位置不对应视口坐标到画布坐标的映射鼠标事件不准确文字或图层被覆盖对象数组顺序、后续背景绘制编码时丢失了文字透明区域变白JPEG 分支的铺底策略PNG 预览绘制错误字体变化或文字换行不同字体加载结果与测量时机只需要提高导出质量2 三种尺寸不能混用第一种是作品尺寸。项目上传底图后用 img.naturalWidth 和 img.naturalHeight 初始化 canvasWidth、canvasHeight创建空白作品时尺寸来自模板配置或自定义值。例如 superEditor.ts 定义了 1600 × 900 的横版封面模板。第二种是 Canvas 位图尺寸即 canvas.width 和 canvas.height。当前 renderCanvas 每次开始时都把它们设为作品尺寸因此对象坐标、字号和宽高使用同一套作品像素坐标。第三种是页面显示尺寸。组件的 max-width、max-height 和父层 CSS scale 决定它在屏幕上占多大它们不会自动把导出位图改成屏幕上看到的大小。当前实现也没有在这个渲染函数中按 devicePixelRatio 再乘一遍作品尺寸。例如1200 × 800 的作品在页面中显示为 600 × 400点击距左上角 150、100 的位置作品坐标应是 300、200。项目使用边界矩形计算这个比例而不是只除以缩放按钮显示的 scale。因为容器自适应也可能已经缩小了一次画布。项目代码摘录ImageTextEditor.vue · getCanvasPoint仅调整换行const rect canvas.getBoundingClientRect();return {x: ((event.clientX - rect.left) / rect.width)* canvas.width,y: ((event.clientY - rect.top) / rect.height)* canvas.height};该片段省略了 canvasRef 读取和空值返回。它适用于当前整张画布的平移、缩放布局如果以后给画布本身增加旋转或倾斜仅用轴对齐的边界矩形不够需要逆变换。隐藏容器导致 rect.width 或 rect.height 为零时也应先拒绝交互。DPR 应服务于显示清晰度策略而不是无条件决定文件像素。若以后改为“低分辨率预览原尺寸导出”应明确预览变换比例导出的尺寸仍由作品或导出规格决定不能直接拿 clientWidth 充当输出宽度。3 先设尺寸 再建立状态 最后绘制Canvas 的 width、height 不只是布局属性。赋值会清空位图并重置绘图状态即使赋的值没有变化也一样。因此先设置字体、缩放或透明度再重设尺寸会让前面的状态失效。[1]教学简化代码演示错误顺序不是项目代码ctx.fillStyle #2563eb;ctx.fillRect(0, 0, 100, 100);ctx.translate(20, 30);canvas.width canvas.width;// 已绘制像素被清空变换恢复默认值。// 后续必须重新建立状态并绘制。当前项目的顺序相反在 renderCanvas 开头设定尺寸再取得上下文、绘制内容drawObject 使用 save / restore 隔离每个对象的旋转、透明度、字体和阴影。这样不会把上一个对象的绘图状态带到下一个对象。项目代码摘录renderCanvas 的准备阶段省略函数签名与后续绘制const canvas targetCanvas;if (!canvas || !hasImage.value) return;canvas.width canvasWidth.value;canvas.height canvasHeight.value;const ctx canvas.getContext(2d);if (!ctx) return;ctx.clearRect(0, 0, canvas.width, canvas.height);这里的 clearRect 在尺寸重设之后对清空像素而言是冗余的但不能简单把整段“优化”为仅 clearRect清空像素不等于重置变换、裁剪区域和其他状态。如果以后只在尺寸变化时赋值需要另行保证每帧的状态基线不要让性能调整悄悄改变绘制语义。4 绘制顺序就是图层顺序Canvas 默认的 source-over 合成模式会把新绘制的内容叠在已有内容上。[2] 当前编辑器按“背景填充 → 底图 → objects 数组”绘制。后面的对象位于前面的对象之上bringForward 和 sendBackward 通过交换数组位置调整层级。命中检测则倒序遍历优先选中上层对象。项目代码摘录renderCanvas 的绘制阶段仅调整换行if (canvasBackgroundFill.value ! transparent) {ctx.fillStyle canvasBackgroundFill.value;ctx.fillRect(0, 0, canvas.width, canvas.height);}const img backgroundImage.value;if (img) ctx.drawImage(img, 0, 0, canvas.width, canvas.height);for (const object of objects.value) {drawObject(ctx, object);if (includeSelection object.id selectedObjectId.value)drawSelection(ctx, object);}if (includeSelection snapGuide.value)drawSnapGuide(ctx, snapGuide.value);这段代码还揭示了一个容易说错的细节当前选框是在对应对象之后立即绘制的并非统一放在所有对象之上参考线才在循环之后绘制。后续对象可能覆盖部分选框。若希望辅助线永远置顶可以单独做最后一轮辅助绘制当前实现尚未采用这一分层。图 2 使用同一张不透明商品底图。左侧先画底图再画标题标题可见右侧反过来后画的底图覆盖了标题。若预览和导出各自维护一套循环这类差异就很容易出现。当前项目共用 renderCanvas减少了两套逻辑逐渐分叉的风险。共享函数也有明确边界它保证调用同一套绘制规则不自动保证异步资源已经就绪更不意味着 PNG 与 JPEG 应逐像素相同。5 导出要重绘作品 而不是截取界面scheduleRender 使用 requestAnimationFrame 合并预览更新。下载入口没有等待这个预览帧而是在字体准备后直接向独立画布同步重绘因此不依赖“预览恰好已经刷出最后一帧”。项目代码摘录downloadResult 中的核心片段省略编码与下载调用await ensureFontsForObjects();const canvas document.createElement(canvas);renderCanvas(canvas, false);const exportCanvas exportFormat.value image/jpeg? flattenCanvas(canvas): canvas;const quality exportFormat.value image/png? undefined: exportQuality.value / 100;完整函数随后调用 exportCanvas.toBlob并检查回调结果是否为空再交给下载工具。片段省略了活动记录、文件名、下载反馈与异常提示不是可独立运行的示例。独立画布是普通 HTMLCanvasElement并不等同于 OffscreenCanvas 或 Worker 后台渲染。JPEG 分支中的 flattenCanvas 先创建同尺寸画布、填白色再 drawImage 原画布。于是“透明预览导出后变白”可能正是明确的格式策略。PNG 则不走这一铺底分支但如果作品本来设了白色背景导出 PNG 仍会保留那层白色。资源准备仍有需要完善的地方图片图层的普通上传路径会先 await loadImage再写入 imageObjectCache工程导入也会等待各图片图层载入。不能因为 drawImageObject 有异步加载就断言普通上传路径一定会漏图。不过drawImageObject 在缓存缺失时会启动加载、安排下一次预览重绘然后直接返回。同步 renderCanvas 并不会等待这条兜底路径。如果未来新增入口没有预加载图片导出画布就可能缺少该层。建议把图片就绪检查收拢到导出前而不是依赖每个入口自觉遵守。字体方面loadFontOption 会执行 FontFace.load、document.fonts.add并等待 document.fonts.ready但加载失败、用户取消下载或已处于 loading 状态等分支可能返回 false。当前 ensureFontsForObjects 只等待 Promise.all没有检查这些布尔值项目代码摘录ensureFontsForObjects 的末尾两句const fonts allFontOptions.value.filter((font) usedFonts.has(font.value));await Promise.all(fonts.map((font) loadFontOption(font)));因此“Promise 已完成”不能等同于“目标字体可用”。字体准备不足会影响 measureText、自动换行和文字宽度仅等待 document.fonts.ready也不能证明一个没有成功注册的目标字体存在。[3]建议改进代码替换上面等待语句的最小防线尚未写入业务代码const results await Promise.all(fonts.map((font) loadFontOption(font)));if (results.some((loaded) !loaded)) {throw new Error(字体尚未准备好请完成加载后重试);}这个局部方案只解决“返回 false 却继续导出”。它没有解决工程引用了字体列表中不存在的字体也没有把 loading 状态变成可共享的加载 Promise。更完整的方案需要校验所需字体能否解析并选择“等待成功”或“明确取消导出”不能静默换字体。另一个建议是在异步准备资源之前固定本次作品与导出设置并在准备期间锁定相关修改。当前下载函数在等待字体之后仍读取响应式状态尚未建立不可变的导出快照。6 验证要看文件像素 也要注明范围本次已经完成的隔离验证在 Chromium 147.0.7727.15 中从当前组件抽取 renderCanvas、getCanvasPoint、flattenCanvas、ensureFontsForObjects转译 TypeScript 后在最小页面运行。绘制对象、选框回调与字体加载器使用受控替身这不是完整 Vue 编辑器的端到端验收也没有覆盖远程字体、实际拖拽或移动端保存。验证内容本次观察结果1200 × 800 画布以 600 × 400 显示PNG 编码后重新解码仍为 1200 × 800显示位置距左上角 150、100getCanvasPoint 返回 300、200依次绘制红、蓝重叠矩形重叠位置采样为蓝色 RGBA(0,0,255,255)includeSelection false受控选框回调调用次数为 0绘制后同值重设 width像素变透明变换恢复单位矩阵透明画布经过 flattenCanvas空白位置采样为不透明白色字体加载替身返回 false当前 ensureFontsForObjects 未抛错这些结果验证了局部函数和浏览器语义不能推出“所有导出场景都正常”。建议改进代码尚未集成本文也没有给出修复上线或性能提升的结论。建议用于后续验收的清单1. 在不同窗口尺寸及预览缩放倍率下导出同一作品重新解码文件检查宽高不要用图片查看器的显示大小判断分辨率。2. 让文字、形状、图片相互重叠调整图层顺序并导出选框和参考线应被排除内容层级应一致。3. 在空白透明作品、白底作品中分别导出 PNG、JPEG、WebP核对背景策略。JPEG 有损压缩不适合用逐像素完全相等作验收标准。4. 覆盖字体下载中、取消、失败、工程引用缺失字体以及图片缓存未命中的情况检查是否停止导出或给出明确提示。5. 验证大图、零尺寸、非法工程尺寸与跨域图片。画布上限因环境而异工程导入需要独立校验不能只依赖创建画布时的输入限制。6. 导出后检查 blob.type 与文件扩展名。浏览器不支持请求格式时可能回退 PNGtoBlob 也可能返回 null 或因画布受到跨域污染而抛出 SecurityError。[4] 当前 loadImage 未配置跨域读取若扩展为远程图层入口需同时处理客户端加载方式与资源服务器的 CORS 响应。对内容正确性的比较应使用相同作品状态、相同像素尺寸且关闭辅助层的两次渲染。不要把带 CSS 缩放和选框的屏幕截图当成导出文件的逐像素基准。结语这次源码梳理给我的排查顺序很明确先确认作品尺寸与显示尺寸再核对状态重置和绘制顺序最后检查资源准备与格式分支。共享渲染函数和独立导出画布是当前项目已有的基础字体失败传播、资源就绪屏障和导出快照则是后续需要继续完善的边界。预览与导出的一致性不是“调用同一个下载 API”就能保证的。真正需要保持一致的是同一份作品、同一套坐标、确定的图层顺序以及已准备好的资源。源码定位与参考资料项目源码路径均相对于仓库根目录。以下列出本文核对的文件与函数方便按函数名定位实现。• webClientVue/src/components/ImageTextEditor.vuegetCanvasPointrenderCanvasdrawObjectdrawImageObjectbringForward / sendBackwardensureFontsForObjects / loadFontOptiondownloadResult / flattenCanvas。• webClientVue/src/composables/useZoomPan.tstransformStyle页面平移、缩放与变换原点。• webClientVue/src/config/superEditor.tsSUPER_EDITOR_CANVAS_TEMPLATES空白作品尺寸配置。[1] MDN · HTMLCanvasElement.width 与状态重置[2] MDN · globalCompositeOperation 与 source-over[3] MDN · FontFaceSet.ready[4] MDN · HTMLCanvasElement.toBlob
返回列表