Utilities for generating and handling ICO data.
Follows Kevin Mårtensson’s to-ico. See the ICO File Format specification on Wikipedia for reference.
The ICO file header block is six bytes, or three 16-bit integers written in little-endian order.
const bufferAlloc = require('buffer-alloc');
function createHeader(numImages) {
const buf = bufferAlloc(6);
buf.writeUInt16LE(0, 0);
buf.writeUInt16LE(1, 2);
buf.writeUInt16LE(numImages, 4);
return buf;
}
Each bitmap in the ICO file requires an ICONDIRENTRY block. These are 16 bytes long and consist of the following entries in order.
function createDirectoryEntry(png, offset) {
const buf = bufferAlloc(16);
const width = png.width === 256 ? 0 : png.width;
const height = png.height === 256 ? 0 : png.height;
const colourPlanes = 1;
const bitsPerPixel = 32;
const size = 40 + png.data.length;
buf.writeUInt8(width, 0);
buf.writeUInt8(height, 1);
buf.writeUInt8(0, 2);
buf.writeUInt8(0, 3);
buf.writeUInt16LE(colourPlanes, 4);
buf.writeUInt16LE(bitsPerPixel, 6);
buf.writeUInt32LE(size, 8);
buf.writeUInt32LE(offset, 12);
return buf;
}
The image bitmap data is stored in Windows BMP format. The file header block is not required and the device-independent bitmap header used is the 40-byte Windows BITMAPINFOHEADER version. (See DIB Headers from Wikipedia. Note the 40-byte variant is the second table in that second.)
Note
The header needs a doubled height for Windows BMP format images. See ICO Icon resource structure information.
function createBitmap(png) {
const buf = bufferAlloc(40 + png.data.length);
const colourPlanes = 1;
const bytesPerPixel = 4;
const bitsPerPixel = bytesPerPixel * 8;
buf.writeUInt32LE(40, 0);
buf.writeInt32LE(png.width, 4);
buf.writeInt32LE(2 * png.height, 8);
buf.writeUInt16LE(colourPlanes, 12);
buf.writeUInt16LE(bitsPerPixel, 14);
buf.writeUInt32LE(0, 16);
buf.writeUInt32LE(png.data.length, 20);
buf.writeInt32LE(0, 24);
buf.writeInt32LE(0, 28);
buf.writeUInt32LE(0, 32);
buf.writeUInt32LE(0, 36);
BMP pixel storage is by row arrays.
const cols = png.width * bytesPerPixel;
const rows = png.height * cols;
const end = rows - cols;
for (let row = 0; row < rows; row += cols) {
for (let col = 0; col < cols; col += bytesPerPixel) {
let pos = row + col;
const r = png.data[pos];
const g = png.data[pos + 1];
const b = png.data[pos + 2];
const a = png.data[pos + 3];
The pixels are stored in a reverse order to normal image raster scan. They start in the lower left corner, go from left to right, and then row by row from the bottom to the top of the image.
The pos
value calculated here expands to (rows - row) - (cols - col)
.
Note that the row
increment is png.width * bytesPerPixel
and the
col
increment is bytesPerPixel
so this pos
value lines up correctly.
The output buffer is preallocated so out-of-order writing is supported with no performance penalty.
pos = (end - row) + col;
buf.writeUInt8(b, 40 + pos);
buf.writeUInt8(g, 40 + pos + 1);
buf.writeUInt8(r, 40 + pos + 2);
buf.writeUInt8(a, 40 + pos + 3);
}
}
return buf;
}
The output ICO file is constructed as a set of buffers corresponding to blocks in the ICO file format. It saves a pass of the output buffer later if we also track the size of the output buffer as we go.
export function fromPngs(pngs) {
const buffers = [ ];
let length = 0;
The first ICO file format block is the header.
const header = createHeader(pngs.length);
buffers.push(header);
length += header.length;
Each image in the ICO output file requires a seperate directory entry in the listing that follows the ICO header. The image data is included later in the file.
Since the directory entry record needs a pointer to the image data for that record, it is necessary to perform the offset calculation while preparing the directory entry records. The directory entries themselves are 16 bytes, so the first image data location will be the length of the header block plus 16 bytes for each image directory entry.
let offset = length + (16 * pngs.length);
Create an ICO directory entry buffer for each image output size.
for (const png of pngs) {
const dir = createDirectoryEntry(png, offset);
buffers.push(dir);
length += dir.length;
Update the image data offset. The next image will start at the point in the file further along by the number of bytes in the image for the current directory entry. An extra 40 bytes is also needed for the bitmap data block header.
offset += 40 + png.data.length;
}
Create buffer blocks for the image data bitmaps.
for (const png of pngs) {
const bitmap = createBitmap(png);
buffers.push(bitmap);
length += bitmap.length;
}
And concatenate the ICO file block buffers to get the final ICO file data.
return Buffer.concat(buffers, length);
}