图片编辑器里,预览看起来没有问题,下载后却出现文字位置不对、图层被遮住,或透明区域变成白色。遇到这类现象,我会先把“显示出来的画面”和“被编码的像素”拆开检查,而不是立即怀疑 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、100 | getCanvasPoint 返回 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 与文件扩展名。浏览器不支持请求格式时可能回退 PNG;toBlob 也可能返回 null 或因画布受到跨域污染而抛出 SecurityError。[4] 当前 loadImage 未配置跨域读取,若扩展为远程图层入口,需同时处理客户端加载方式与资源服务器的 CORS 响应。
对内容正确性的比较,应使用相同作品状态、相同像素尺寸且关闭辅助层的两次渲染。不要把带 CSS 缩放和选框的屏幕截图,当成导出文件的逐像素基准。
结语
这次源码梳理给我的排查顺序很明确:先确认作品尺寸与显示尺寸,再核对状态重置和绘制顺序,最后检查资源准备与格式分支。共享渲染函数和独立导出画布是当前项目已有的基础;字体失败传播、资源就绪屏障和导出快照,则是后续需要继续完善的边界。
预览与导出的一致性不是“调用同一个下载 API”就能保证的。真正需要保持一致的是:同一份作品、同一套坐标、确定的图层顺序,以及已准备好的资源。
源码定位与参考资料
项目源码路径均相对于仓库根目录。以下列出本文核对的文件与函数,方便按函数名定位实现。
• webClientVue/src/components/ImageTextEditor.vue
getCanvasPoint;renderCanvas;drawObject;drawImageObject;bringForward / sendBackward;ensureFontsForObjects / loadFontOption;downloadResult / flattenCanvas。
• webClientVue/src/composables/useZoomPan.ts
transformStyle:页面平移、缩放与变换原点。
• webClientVue/src/config/superEditor.ts
SUPER_EDITOR_CANVAS_TEMPLATES:空白作品尺寸配置。
[1] MDN · HTMLCanvasElement.width 与状态重置
[2] MDN · globalCompositeOperation 与 source-over
[3] MDN · FontFaceSet.ready
[4] MDN · HTMLCanvasElement.toBlob