课设要做一个在网页里裁图的小工具。思路很普通:用户选了图就先塞进 <img> 显示出来再按原尺寸画进一张 canvas 裁完导出。我拿一张 20000×20000 的大图去试。<img> 显示得好好的,naturalWidth 也是 20000。可画进 canvas 再导出拿到的是空白。当时我脑子里的逻辑是「浏览器都把图解出来了,画一下总该没问题」。这个假设是错的。解码能解多大和画布能画多大是两回事,两条线在不同的地方断。
大图能显示为什么画进canvas是空白
为了看清到底在哪断,我用脚本造了 10 张大图。从 1 亿像素到 6.25 亿像素全是渐变加色块加噪声的合成图。每张图我跑了三条路:<img> 画成 256×256 的缩略图;createImageBitmap 画成缩略图;按原尺寸画进画布再 toBlob。内核用的是三个构建版本:Chromium 149 开源构建、WebKit 26.5 构建和 Firefox 151 构建。它们分别不等于我们平时装的 Chrome、Safari 和正式版 Firefox。机器是一台 M4 Mac。1 亿像素那一档我另外拿图映 ImgIng(imging.cn/)的压缩转换对照过。1... 的 JPEG 转 WebP 和 JPG 在三个内核上都走完了,全程非 GET 请求 0 次。

表里最该看的是 16384×16384 和 16384×16400 这相邻两行。前者三条路三个内核全部成功。后者只多了 16 行像素。Chromium 和 WebKit 构建的第三列就成了空白,toBlob 回调 null。我当时有点懵。原因是这两个内核的画布面积上限都是 268435456 像素。16384×16384 刚好卡在线上,16384×16385 就过线了。20000×20000 的 4 亿像素离这条线更远。可缩略图那两列在这几行都是绿的。<img> 的解码显然不归这条线管。

这张是表里 16384×16400 那一行。我把它放进自己写的复现小页面,在 WebKit 26.5 构建里点了一遍。左边预览区图是完整的,下面标着 16384 × 16400、25.3 MB。中间缩到 380×380 的那张画布上图也一点没少。右边点 Export JPEG 按原尺寸导出后红框里只剩一个带问号的破图图标。下面那行写着 export.jpg 只有 4 bytes,这是回调拿到的 null 被包成了文件。这张图的面积是 268,697,600 px,只比 2^28 多出 16 行。
img和canvas的上限不是同一条线
Chromium 149 里 <img> 能解到 23170×23170,23171×23171 才触发 onerror,界线落在 2^29 像素附近。createImageBitmap 早一点,23170×23170 就 reject 了。画布在 2^28 就停了;中间差了整整一倍。我的裁图工具正好掉在这个空档里。
Firefox 151 构建是反过来的:它的画布能画到 23168×23168。20000×20000 原尺寸画进去能成功导出。到了 23170×23170 它的 <img> 照样触发 load 并给出真实的 naturalWidth,画出来却全是透明的。只看 load 事件会以为一切正常。WebKit 26.5 构建又是另一种样子:它的 <img> 一直到 25000×25000 都正常。createImageBitmap(blob) 在 23170×23170 以上会返回一个尺寸正确的位图。它不报错而内容全透明。三家在同一个地方能差这么多,是我做之前完全没想到的。我是一格一格对着表看的。

这张截的是同一个复现小页面在 Firefox 151 构建里的那一格。左边预览区能看到图,尺寸读出来是 23170 × 23170、13.4 MB,load 照常触发了。中间把同一张图缩到 380×380 画进画布。红框里只有棋盘格而一个像素都没画上。drawImage 并没有抛错。右边原尺寸导出失败了。页面上直接写着 Export failed。控制台是空的。
大图裁剪怎么避开画布上限
想通以后改法就不难了。缩略图那条路一直是绿的。问题不在「图太大」,在「目标画布太大」。我现在先按原图尺寸算一遍面积和单边。超过 Chromium 与 WebKit 构建测到的面积就不按原尺寸画:预览用缩小后的画布;真正导出时只按用户框出来的那一块去画。裁剪区一般远小于整张图,画布也就不会过线。整张图非要原尺寸处理的时候我能想到的只有分块。这个我还没写过,也不知道拼回去会不会有接缝。
表里 70000×1500 那张 PNG 只有 1.05 亿像素。面积远没过线而 Chromium 构建照样空白。这跟它 65535 px 的单边上限对得上。WebKit 构建能把它画上去,中心像素也对,只是导出 JPEG 时回调 null。它在我另一组测试里能写出 65535 px 宽的 JPEG,70000 是不是正好超了它的线我没单独测。
如果你的页面也是「先显示再画」,可以拿一张 16384×16400 的图试一次。先看 <img> 显示正不正常。再看画进原尺寸画布后 toBlob 回调是不是 null。两个结果放在一起就知道代码有没有掉进这个空档。