Javascript(或伪代码)使用 arraybuffers/canvas 正确附加两个 PNG
Javascript (or pseudocode) to properly append two PNGs using arraybuffers/canvas
我有两张图片(合并后会有 1:1 width:height 的比例)。如果我在 unix 上使用 convert a.png b.png -append c.png
组合它们,它会完美地工作。我正在努力在 javascript 中实现这一点。我将数组缓冲区(包含 img 数据)添加在一起,因为在 canvas 中绘制它们似乎不会产生相同的图像。如果我简单地附加每个数组缓冲区,则图像比例为 2:1;有谁知道如何正确附加数组缓冲区,类似于 convert
所做的?
编辑:详细说明,简单地堆叠在 canvas 上 是行不通的(我试过)。这可能是由于低级 canvas 代码,我怀疑这是由于 canvas 如何连接两个图像之间边界处的像素。它需要是数组缓冲区。
如果您出于某种原因不想通过 Image
加载图像,那么唯一的选择是手动解析和解压缩文件。由于 ICC/gamma 支持,浏览器确实可以更改图像。不过,这不会发生在 canvas 步骤中,而是在图像加载和转换为 RGBA 数据期间发生。
话虽这么说,getImageDate()
/putImageData()
期间的过程也可能由于 (un-)pre-multiplying 和舍入误差而改变像素值。
使用 canvas 将两个 PNG 图像合并为一个图像的示例:
var ctx = c.getContext("2d"), // canvas 2d context
img1 = new Image, // create two image
img2 = new Image, // elements
count = 2; // Track for loader
// load images
img1.onload = img2.onload = function() { // make sure images are
if (!--count) append(); // loaded first
};
img1.crossOrigin = img2.crossOrigin = ""; // need this for this demo
img1.src = "http://i.imgur.com/hlHEhUhb.jpg"; // random images...
img2.src = "http://i.imgur.com/ynzkv40b.jpg";
// process images
function append() {
// use width to sum the images
c.width = img1.width + img2.width; // set total height
c.height = Math.max(img1.height,img2.height); // set max height
ctx.drawImage(img1, 0, 0); // draw in image 1
ctx.drawImage(img2, img1.width, 0) ; // draw in image 2
console.log(c.toDataURL()); // extract, send to server
}
<canvas id=c></canvas>
您不能在不先解码 PNG 数据的情况下简单地将它们相互合并。这是因为图像数据块被压缩(缩小)并且 PNG 文件中的每个 scan-line 都使用描述正在使用的 line-filter 的初始字节。
简单地合并它们可能会使缩小的数据在垂直方向上无效,而在水平方向上它会使行数据与长度无效,因为会引入额外的过滤字节。每行的过滤器也可能不同。
因此无法解析、解压缩和解码源 PNG 文件。但是为了解析 PNG 文件,您必须知道文件格式是如何构建的。
PNG 文件格式
PNG 文件的主要文件结构是:
-Signature- 8 bytes
IHDR chunk required (width, height, depth, mode etc.)
[PLTE chunk] required for indexed color mode
[Misc chunks] optional ancillary and private chunks
IDAT chunk required, can be multiple
IEND chunk required, last chunk (data-less)
在这种情况下可以忽略任何其他块,除非您使用索引调色板,在这种情况下您还需要考虑 PLTE 块。
如果当前块未知或不需要,块允许您跳到下一个块。块的结构使用 8 个字节,后跟数据,然后是 4 个字节的 CRC-32 校验和(不需要数据,就像 IEND 块一样):
0x00 SIZE (4 bytes)
0x04 FOURCC (4 bytes)
0x08 DATA (variable, can be 0)
0x?? CRC-32 (4 bytes)
大小仅代表数据。该名称将是块名称的 ASCII 表示,始终为四个字节(“IDAT”、“IEND”、...)。
如果您不想验证数据,可以忽略 CRC-32 校验和,但在生成新的 PNG 文件时不能忽略,因为大多数 PNG viewers/parser 使用此值并且它包括区块名称。
所有值均按 big-endian 字节顺序无符号。
读取块
读取分块数据文件(例如 PNG)的典型方法是在第一个块处初始化起始偏移量。然后遍历同时读取和移动文件光标,检查块名称。
例如:
var pos = 8; // first chunk position
var dv = new DataView(arraybuffer); // use a DataView
制作一些辅助函数来读取和移动位置:
function getUint32() { // and for Uint16 etc.
var data = dv.getUint32(pos); // use big-endian byte-order
pos += 4;
return data
}
// decode chunk name to string (from pngtoy)
function getFourCC() {
var v = getUint32(),
c = String.fromCharCode;
return c((v & 0xff000000)>>>24) + c((v & 0xff0000)>>>16) +
c((v & 0xff00)>>>8) + c((v & 0xff)>>>0)
}
现在允许我们按预期使用文件缓冲区:
// repeated actions:
var size = getUint32();
var name = getFourCC();
var data, crc;
if (name === "IHDR") { // check chunk type
data = new Uint8Array(dv.buffer, pos, size); // get data section from chunk
pos += size; // next chunk or the end
crc = getUint32(); // read CRC-32 checksum
// validate CRC-32 here
}
else pos += size + 4; // skip data and crc
提示:即使跳过块,也可以根据 CRC 校验和验证数据以发现文件损坏的早期迹象。
IDAT 块 总是 包含压缩数据,因为这是格式规范中唯一有效的存储形式,必须先进行压缩。对于这个过程,我会(一如既往)推荐 Pako implementation of the zlib library.
读取过程
每个输入图像的读取过程变为(需要使用 DataView
):
- 检查魔法header/signature。有 8 个字节,应始终为以下序列:
0x89504E47 0x0D0A1A0A
(big-endian).
- 如果确定,第一个块 (IHDR) 将在文件中的位置 8 处找到。您需要解析此 header 的内容以找到宽度、高度以及位图深度(16、8、4、1)和类型(RGB、RGBA、灰度、位图等)以及是否图片是否隔行。
- 获得这些数据后,您就可以扫描IDAT块了。注意复数形式——通常只有一个 IDAT 块,但拥有多个 IDAT 块也是完全有效的。当您到达 IEND 块时,就没有更多数据了。当位图被分割成几个 IDAT 块时,有效的 PNG 文件在 IDAT 和 IEND 块之间不会有任何其他块。
- 通过inflate传递数据来解压。 (提示:使用 Inflate 实例而不是静态函数可以获取每个单独的 IDAT 块数据,然后解压缩到单个缓冲区)。
- 现在您将得到一个原始但未过滤 PNG 位图
我们仍然无法合并文件,因为我们需要使用过滤器字节对每个 scan-line 进行解码。 PNG 中有五种不同的 line-filters,其中 0 表示不需要过滤,直到更复杂的 4 Paeth 过滤器。
此外,图像可以隔行扫描 (Adam-7),由于渐进式需要不同的方法。
当您解码每个 scan-line(和 de-interlaced,如果需要),您将得到一个不受浏览器 ICC/gamma 影响的原始位图。
需要采取额外的步骤来检查两个图像是否属于同一类型(例如 RGB、RGBA 等)。如果不是,则必须另外将一个图像转换为另一种格式,通常通过“升级" 少 information/quality 的那个。如果格式和深度相同祝你好运。
如果大小不同会在最终结果中留下某种空白,可能需要填充以填充没有覆盖的空白像素,具体取决于格式以及是否不想要这样的东西作为透明度等
现在您可以水平或垂直合并两个位图。
合并两个位图
您提到要水平合并位图 -
- 设置一个新缓冲区,其大小为图像 1 宽度 + 图像 2 宽度乘以单个像素的大小(RGB 为 3,RGBA 为 4 等)
- 将高度定义为两个高度中的最大值
- 判断你是否need/want使用padding/zero-fill(身高1 !==身高2)
为新缓冲区设置一个主循环,然后在每个 scan-line 图像 1 和图像 2 之间交替,以便前两个 scan-line 被作为一个单独的复制到新缓冲区中。
编写过程
再次保存图片的逆过程是:
- 设置签名
- 添加 IHDR 块并更新为新的尺寸、格式、深度
- 添加 IDAT 块
- 对每个扫描线进行编码(为简单起见,您可以使用过滤器 0,但它会增加尺寸)
- 使用 zlib 缩小数据并添加。使用压缩大小
更新块的大小
- Calculate CRC-32 checksums
- 添加 IEND 块
我的策略是使用普通数组分部分构建文件,以保存每个类型化数组部分(签名)和块 + 数据。然后将数组传递给 Blob,Blob 会将这些部分连接到单个二进制缓冲区。
例如:
var arr = [];
arr.push(taSig); // ta* = typed array
arr.push(taIHDR);
arr.push(taIDAT);
arr.push(taIEND);
然后将数组传入Blob:
var blob = new Blob(arr, {type: "image/png"});
可以找到完整的 PNG 文件格式规范 here.
我建议您查看我的 pngtoy(PNG 解析器和解码器,MIT lic.)以了解详细信息。它执行与上述类似的步骤以获得原始解码位图。
我有两张图片(合并后会有 1:1 width:height 的比例)。如果我在 unix 上使用 convert a.png b.png -append c.png
组合它们,它会完美地工作。我正在努力在 javascript 中实现这一点。我将数组缓冲区(包含 img 数据)添加在一起,因为在 canvas 中绘制它们似乎不会产生相同的图像。如果我简单地附加每个数组缓冲区,则图像比例为 2:1;有谁知道如何正确附加数组缓冲区,类似于 convert
所做的?
编辑:详细说明,简单地堆叠在 canvas 上 是行不通的(我试过)。这可能是由于低级 canvas 代码,我怀疑这是由于 canvas 如何连接两个图像之间边界处的像素。它需要是数组缓冲区。
如果您出于某种原因不想通过 Image
加载图像,那么唯一的选择是手动解析和解压缩文件。由于 ICC/gamma 支持,浏览器确实可以更改图像。不过,这不会发生在 canvas 步骤中,而是在图像加载和转换为 RGBA 数据期间发生。
话虽这么说,getImageDate()
/putImageData()
期间的过程也可能由于 (un-)pre-multiplying 和舍入误差而改变像素值。
使用 canvas 将两个 PNG 图像合并为一个图像的示例:
var ctx = c.getContext("2d"), // canvas 2d context
img1 = new Image, // create two image
img2 = new Image, // elements
count = 2; // Track for loader
// load images
img1.onload = img2.onload = function() { // make sure images are
if (!--count) append(); // loaded first
};
img1.crossOrigin = img2.crossOrigin = ""; // need this for this demo
img1.src = "http://i.imgur.com/hlHEhUhb.jpg"; // random images...
img2.src = "http://i.imgur.com/ynzkv40b.jpg";
// process images
function append() {
// use width to sum the images
c.width = img1.width + img2.width; // set total height
c.height = Math.max(img1.height,img2.height); // set max height
ctx.drawImage(img1, 0, 0); // draw in image 1
ctx.drawImage(img2, img1.width, 0) ; // draw in image 2
console.log(c.toDataURL()); // extract, send to server
}
<canvas id=c></canvas>
您不能在不先解码 PNG 数据的情况下简单地将它们相互合并。这是因为图像数据块被压缩(缩小)并且 PNG 文件中的每个 scan-line 都使用描述正在使用的 line-filter 的初始字节。
简单地合并它们可能会使缩小的数据在垂直方向上无效,而在水平方向上它会使行数据与长度无效,因为会引入额外的过滤字节。每行的过滤器也可能不同。
因此无法解析、解压缩和解码源 PNG 文件。但是为了解析 PNG 文件,您必须知道文件格式是如何构建的。
PNG 文件格式
PNG 文件的主要文件结构是:
-Signature- 8 bytes
IHDR chunk required (width, height, depth, mode etc.)
[PLTE chunk] required for indexed color mode
[Misc chunks] optional ancillary and private chunks
IDAT chunk required, can be multiple
IEND chunk required, last chunk (data-less)
在这种情况下可以忽略任何其他块,除非您使用索引调色板,在这种情况下您还需要考虑 PLTE 块。
如果当前块未知或不需要,块允许您跳到下一个块。块的结构使用 8 个字节,后跟数据,然后是 4 个字节的 CRC-32 校验和(不需要数据,就像 IEND 块一样):
0x00 SIZE (4 bytes)
0x04 FOURCC (4 bytes)
0x08 DATA (variable, can be 0)
0x?? CRC-32 (4 bytes)
大小仅代表数据。该名称将是块名称的 ASCII 表示,始终为四个字节(“IDAT”、“IEND”、...)。
如果您不想验证数据,可以忽略 CRC-32 校验和,但在生成新的 PNG 文件时不能忽略,因为大多数 PNG viewers/parser 使用此值并且它包括区块名称。
所有值均按 big-endian 字节顺序无符号。
读取块
读取分块数据文件(例如 PNG)的典型方法是在第一个块处初始化起始偏移量。然后遍历同时读取和移动文件光标,检查块名称。
例如:
var pos = 8; // first chunk position
var dv = new DataView(arraybuffer); // use a DataView
制作一些辅助函数来读取和移动位置:
function getUint32() { // and for Uint16 etc.
var data = dv.getUint32(pos); // use big-endian byte-order
pos += 4;
return data
}
// decode chunk name to string (from pngtoy)
function getFourCC() {
var v = getUint32(),
c = String.fromCharCode;
return c((v & 0xff000000)>>>24) + c((v & 0xff0000)>>>16) +
c((v & 0xff00)>>>8) + c((v & 0xff)>>>0)
}
现在允许我们按预期使用文件缓冲区:
// repeated actions:
var size = getUint32();
var name = getFourCC();
var data, crc;
if (name === "IHDR") { // check chunk type
data = new Uint8Array(dv.buffer, pos, size); // get data section from chunk
pos += size; // next chunk or the end
crc = getUint32(); // read CRC-32 checksum
// validate CRC-32 here
}
else pos += size + 4; // skip data and crc
提示:即使跳过块,也可以根据 CRC 校验和验证数据以发现文件损坏的早期迹象。
IDAT 块 总是 包含压缩数据,因为这是格式规范中唯一有效的存储形式,必须先进行压缩。对于这个过程,我会(一如既往)推荐 Pako implementation of the zlib library.
读取过程
每个输入图像的读取过程变为(需要使用 DataView
):
- 检查魔法header/signature。有 8 个字节,应始终为以下序列:
0x89504E47 0x0D0A1A0A
(big-endian). - 如果确定,第一个块 (IHDR) 将在文件中的位置 8 处找到。您需要解析此 header 的内容以找到宽度、高度以及位图深度(16、8、4、1)和类型(RGB、RGBA、灰度、位图等)以及是否图片是否隔行。
- 获得这些数据后,您就可以扫描IDAT块了。注意复数形式——通常只有一个 IDAT 块,但拥有多个 IDAT 块也是完全有效的。当您到达 IEND 块时,就没有更多数据了。当位图被分割成几个 IDAT 块时,有效的 PNG 文件在 IDAT 和 IEND 块之间不会有任何其他块。
- 通过inflate传递数据来解压。 (提示:使用 Inflate 实例而不是静态函数可以获取每个单独的 IDAT 块数据,然后解压缩到单个缓冲区)。
- 现在您将得到一个原始但未过滤 PNG 位图
我们仍然无法合并文件,因为我们需要使用过滤器字节对每个 scan-line 进行解码。 PNG 中有五种不同的 line-filters,其中 0 表示不需要过滤,直到更复杂的 4 Paeth 过滤器。
此外,图像可以隔行扫描 (Adam-7),由于渐进式需要不同的方法。
当您解码每个 scan-line(和 de-interlaced,如果需要),您将得到一个不受浏览器 ICC/gamma 影响的原始位图。
需要采取额外的步骤来检查两个图像是否属于同一类型(例如 RGB、RGBA 等)。如果不是,则必须另外将一个图像转换为另一种格式,通常通过“升级" 少 information/quality 的那个。如果格式和深度相同祝你好运。
如果大小不同会在最终结果中留下某种空白,可能需要填充以填充没有覆盖的空白像素,具体取决于格式以及是否不想要这样的东西作为透明度等
现在您可以水平或垂直合并两个位图。
合并两个位图
您提到要水平合并位图 -
- 设置一个新缓冲区,其大小为图像 1 宽度 + 图像 2 宽度乘以单个像素的大小(RGB 为 3,RGBA 为 4 等)
- 将高度定义为两个高度中的最大值
- 判断你是否need/want使用padding/zero-fill(身高1 !==身高2)
为新缓冲区设置一个主循环,然后在每个 scan-line 图像 1 和图像 2 之间交替,以便前两个 scan-line 被作为一个单独的复制到新缓冲区中。
编写过程
再次保存图片的逆过程是:
- 设置签名
- 添加 IHDR 块并更新为新的尺寸、格式、深度
- 添加 IDAT 块
- 对每个扫描线进行编码(为简单起见,您可以使用过滤器 0,但它会增加尺寸)
- 使用 zlib 缩小数据并添加。使用压缩大小 更新块的大小
- Calculate CRC-32 checksums
- 添加 IEND 块
我的策略是使用普通数组分部分构建文件,以保存每个类型化数组部分(签名)和块 + 数据。然后将数组传递给 Blob,Blob 会将这些部分连接到单个二进制缓冲区。
例如:
var arr = [];
arr.push(taSig); // ta* = typed array
arr.push(taIHDR);
arr.push(taIDAT);
arr.push(taIEND);
然后将数组传入Blob:
var blob = new Blob(arr, {type: "image/png"});
可以找到完整的 PNG 文件格式规范 here.
我建议您查看我的 pngtoy(PNG 解析器和解码器,MIT lic.)以了解详细信息。它执行与上述类似的步骤以获得原始解码位图。