/** * Based on: https://github.com/shaozilee/bmp-js/blob/db2c466ca1869ddc09e4b2143404eb03ecd490db/lib/encoder.js * @author shaozilee * @author 1j01 * @license MIT * * BMP format encoder, encodes 24bit, 8bit, 4bit, and 1bit BMP files. * Doesn't support compression. * */ function encodeBMP(imgData, bitsPerPixel = 24) { if (![1, 4, 8, 24/*, 32*/].includes(bitsPerPixel)) { throw new Error(`not supported: ${bitsPerPixel} bits per pixel`) } const bytesPerPixel = bitsPerPixel / 8; // can be more or less than one // Rows are always a multiple of 4 bytes long. const pixelRowSize = Math.ceil(imgData.width * bytesPerPixel / 4) * 4; const pixelDataSize = imgData.height * pixelRowSize; const headerInfoSize = 40; const indexed = bitsPerPixel <= 8; const maxColorCount = 2 ** bitsPerPixel; let rgbTable; let indices; let colorCount = 0; if (indexed) { // rgbTable = []; // for (const color of palette.slice(0, maxColorCount)) { // const [r, g, b] = get_rgba_from_color(color); // rgbTable.push([r, g, b]); // } const res = UPNG.quantize(imgData.data, maxColorCount); indices = res.inds; rgbTable = res.plte.map((color_entry) => color_entry.est.q.map((component) => Math.round(component * 255))); colorCount = rgbTable.length; } const flag = "BM"; const planes = 1; const compressionType = 0;// bitsPerPixel === 32 ? 3 : 0 const hr = 0; const vr = 0; const importantColorCount = 0; // 0 means all colors are important const colorTableSize = colorCount * 4; const offsetToPixelData = 54 + colorTableSize; const fileSize = pixelDataSize + offsetToPixelData; const fileArrayBuffer = new ArrayBuffer(fileSize); const view = new DataView(fileArrayBuffer); let pos = 0; // BMP header view.setUint8(pos, flag.charCodeAt(0)); pos += 1; view.setUint8(pos, flag.charCodeAt(1)); pos += 1; view.setUint32(pos, fileSize, true); pos += 4; pos += 4; // reserved / application-specific view.setUint32(pos, offsetToPixelData, true); pos += 4; // DIB header view.setUint32(pos, headerInfoSize, true); pos += 4; view.setInt32(pos, imgData.width, true); pos += 4; view.setInt32(pos, -imgData.height, true); pos += 4; // negative indicates rows are stored top to bottom view.setUint16(pos, planes, true); pos += 2; view.setUint16(pos, bitsPerPixel, true); pos += 2; view.setUint32(pos, compressionType, true); pos += 4; view.setUint32(pos, pixelDataSize, true); pos += 4; view.setInt32(pos, hr, true); pos += 4; view.setInt32(pos, vr, true); pos += 4; view.setUint32(pos, colorCount, true); pos += 4; view.setUint32(pos, importantColorCount, true); pos += 4; if (rgbTable) { for (const [r, g, b] of rgbTable) { view.setUint8(pos, b); pos += 1; view.setUint8(pos, g); pos += 1; view.setUint8(pos, r); pos += 1; pos += 1; } } const getColorIndex = (imgDataIndex) => { return indices[imgDataIndex / 4]; }; let i = 0; for (let y = 0; y < imgData.height; y += 1) { for (let x = 0; x < imgData.width;) { const pixelGroupPos = pos + y * pixelRowSize + x * bytesPerPixel; if (bitsPerPixel === 1) { let byte = 0; for (let j = 0; j < 8 && x + j < imgData.width; j += 1) { byte |= getColorIndex(i) << (7 - j); i += 4; } view.setUint8(pixelGroupPos, byte); x += 8; } else if (bitsPerPixel === 4) { let byte = 0; for (let j = 0; j < 2 && x + j < imgData.width; j += 1) { byte |= getColorIndex(i) << (4 * (1 - j)); i += 4; } view.setUint8(pixelGroupPos, byte); x += 2; } else if (bitsPerPixel === 8) { view.setUint8(pixelGroupPos, getColorIndex(i)); i += 4; x += 1; } else { view.setUint8(pixelGroupPos + 2, imgData.data[i + 0]); // red view.setUint8(pixelGroupPos + 1, imgData.data[i + 1]); // green view.setUint8(pixelGroupPos + 0, imgData.data[i + 2]); // blue // if (bitsPerPixel === 32) { // view.setUint8(pixelGroupPos + 3, imgData.data[i + 3]); // alpha // } i += 4; x += 1; } } } return view.buffer; } /** * Based on https://github.com/ericandrewlewis/bitmap-js/blob/c33a6137829b18e3420a763ef20bffae874610b3/index.js * @author ericandrewlewis * @license MIT */ /* function decodeBMP(arrayBuffer) { function readBitmapFileHeader(view) { if (view.getUint8(0) !== "B".charCodeAt(0) || view.getUint8(1) !== "M".charCodeAt(0)) { throw new Error("not a BMP file"); // Note: exact error message is checked for to detect this case } return { filesize: view.getUint32(2, true), imageDataOffset: view.getUint32(10, true) }; } const dibHeaderLengthToVersionMap = { 12: "BITMAPCOREHEADER", 16: "OS22XBITMAPHEADER", 40: "BITMAPINFOHEADER", 52: "BITMAPV2INFOHEADER", 56: "BITMAPV3INFOHEADER", 64: "OS22XBITMAPHEADER", 108: "BITMAPV4HEADER", 124: "BITMAPV5HEADER" }; function readDibHeader(view) { const dibHeaderLength = view.getUint32(14, true); const header = {}; header.headerLength = dibHeaderLength; header.headerType = dibHeaderLengthToVersionMap[dibHeaderLength]; header.width = view.getInt32(18, true); header.height = view.getInt32(22, true); // Note: negative is used to mean rows go top to bottom instead of bottom to top if (header.headerType == "BITMAPCOREHEADER") { return header; } header.bitsPerPixel = view.getUint16(28, true); header.compressionType = view.getUint32(30, true); if (header.headerType == "OS22XBITMAPHEADER") { return header; } header.bitmapDataSize = view.getUint32(34, true); header.numberOfColorsInPalette = view.getUint32(46, true); header.numberOfImportantColors = view.getUint32(50, true); if (header.headerType == "BITMAPINFOHEADER") { return header; } // There are more data fields in later versions of the dib header. // I hear that BITMAPINFOHEADER is the most widely supported // header type, so I'm not going to implement them yet. return header; } function readColorTable(view) { const dibHeader = readDibHeader(view); const colorTable = []; const sourceStart = 14 + dibHeader.headerLength; const numberOfColorsInPalette = dibHeader.numberOfColorsInPalette || (dibHeader.bitsPerPixel <= 8 ? 2 ** dibHeader.bitsPerPixel : 0); for (let i = 0; i < numberOfColorsInPalette; i += 1) { colorTable.push({ r: view.getUint8(sourceStart + i * 4 + 2), g: view.getUint8(sourceStart + i * 4 + 1), b: view.getUint8(sourceStart + i * 4 + 0), }); } return colorTable; } const view = new DataView(arrayBuffer); const fileHeader = readBitmapFileHeader(view); const dibHeader = readDibHeader(view); // const imageDataLength = dibHeader.bitmapDataSize; // const imageDataOffset = fileHeader.imageDataOffset; const colorTable = readColorTable(view); // view.copy(imageData, 0, imageDataOffset); const width = Math.abs(fileHeader.width); const height = Math.abs(fileHeader.height); // negative is used to mean rows go top to bottom instead of bottom to top // const imageData = new ImageData(width, height); // const pixelRowSize = Math.ceil(width * dibHeader.bitsPerPixel / 8 / 4) * 4; // for (let y = 0; y < height; y += 1) { // for (let x = 0; x < width; x += 1) { // const byte = view.readUint8(y * pixelRowSize + x * dibHeader.bitsPerPixel / 8); // imageData.data[y * height * 4 + 0,1,2,3] = ...; // } // } return { // width, // height, // fileHeader, // dibHeader, // imageData, colorTable, bitsPerPixel: dibHeader.bitsPerPixel, }; } */ function decodeBMP(arrayBuffer) { const decoder = new BmpDecoder(arrayBuffer, {toRGBA: true}); return { bitsPerPixel: decoder.bitsPerPixel, colorTable: decoder.palette ? decoder.palette.map(({red, green, blue})=> ({r: red, g: green, b: blue})) : [], imageData: new ImageData(decoder.data, decoder.width, decoder.height), }; } /** * Based on: https://github.com/hipstersmoothie/bmp-ts * @license MIT */ const HeaderTypes = {}; HeaderTypes[HeaderTypes["BITMAP_INFO_HEADER"] = 40] = "BITMAP_INFO_HEADER"; HeaderTypes[HeaderTypes["BITMAP_V2_INFO_HEADER"] = 52] = "BITMAP_V2_INFO_HEADER"; HeaderTypes[HeaderTypes["BITMAP_V3_INFO_HEADER"] = 56] = "BITMAP_V3_INFO_HEADER"; HeaderTypes[HeaderTypes["BITMAP_V4_HEADER"] = 108] = "BITMAP_V4_HEADER"; HeaderTypes[HeaderTypes["BITMAP_V5_HEADER"] = 124] = "BITMAP_V5_HEADER"; Object.freeze(HeaderTypes); // We have these: // // const sample = 0101 0101 0101 0101 // const mask = 0111 1100 0000 0000 // 256 === 0000 0001 0000 0000 // // We want to take the sample and turn it into an 8-bit value. // // 1. We extract the last bit of the mask: // // 0000 0100 0000 0000 // ^ // // Like so: // // const a = ~mask = 1000 0011 1111 1111 // const b = a + 1 = 1000 0100 0000 0000 // const c = b & mask = 0000 0100 0000 0000 // // 2. We shift it to the right and extract the bit before the first: // // 0000 0000 0010 0000 // ^ // // Like so: // // const d = mask / c = 0000 0000 0001 1111 // const e = mask + 1 = 0000 0000 0010 0000 // // 3. We apply the mask and the two values above to a sample: // // const f = sample & mask = 0101 0100 0000 0000 // const g = f / c = 0000 0000 0001 0101 // const h = 256 / e = 0000 0000 0000 0100 // const i = g * h = 0000 0000 1010 1000 // ^^^^ ^ // // Voila, we have extracted a sample and "stretched" it to 8 bits. For samples // which are already 8-bit, h === 1 and g === i. function maskColor(maskRed, maskGreen, maskBlue, maskAlpha) { const maskRedR = (~maskRed + 1) & maskRed; const maskGreenR = (~maskGreen + 1) & maskGreen; const maskBlueR = (~maskBlue + 1) & maskBlue; const maskAlphaR = (~maskAlpha + 1) & maskAlpha; const shiftedMaskRedL = maskRed / maskRedR + 1; const shiftedMaskGreenL = maskGreen / maskGreenR + 1; const shiftedMaskBlueL = maskBlue / maskBlueR + 1; const shiftedMaskAlphaL = maskAlpha / maskAlphaR + 1; return { shiftRed: (x) => (((x & maskRed) / maskRedR) * 0x100) / shiftedMaskRedL, shiftGreen: (x) => (((x & maskGreen) / maskGreenR) * 0x100) / shiftedMaskGreenL, shiftBlue: (x) => (((x & maskBlue) / maskBlueR) * 0x100) / shiftedMaskBlueL, shiftAlpha: maskAlpha !== 0 ? (x) => (((x & maskAlpha) / maskAlphaR) * 0x100) / shiftedMaskAlphaL : () => 255 }; } class BmpDecoder { constructor(arrayBuffer, { toRGBA } = { toRGBA: false }) { this.view = new DataView(arrayBuffer); this.toRGBA = !!toRGBA; this.pos = 0; this.bottomUp = true; this.flag = String.fromCharCode(this.view.getUint8(0)) + String.fromCharCode(this.view.getUint8(1)); this.pos += 2; if (this.flag !== 'BM') { throw new Error('Invalid BMP File'); } this.locRed = this.toRGBA ? 0 : 3; this.locGreen = this.toRGBA ? 1 : 2; this.locBlue = this.toRGBA ? 2 : 1; this.locAlpha = this.toRGBA ? 3 : 0; this.parseHeader(); this.parseRGBA(); } parseHeader() { this.fileSize = this.view.getUint32(this.pos, true); this.pos += 4; this.reserved1 = this.view.getUint16(this.pos, true); this.pos += 2; this.reserved2 = this.view.getUint16(this.pos, true); this.pos += 2; this.offset = this.view.getUint32(this.pos, true); this.pos += 4; // End of BITMAP_FILE_HEADER this.headerSize = this.view.getUint32(this.pos, true); this.pos += 4; if (!(this.headerSize in HeaderTypes)) { throw new Error(`Unsupported BMP header size ${this.headerSize}`); } this.width = this.view.getInt32(this.pos, true); this.pos += 4; this.height = this.view.getInt32(this.pos, true); this.pos += 4; if (this.height < 0) { this.height *= -1; this.bottomUp = false; } this.planes = this.view.getUint16(this.pos, true); this.pos += 2; this.bitsPerPixel = this.view.getUint16(this.pos, true); this.pos += 2; this.compression = this.view.getUint32(this.pos, true); this.pos += 4; this.rawSize = this.view.getUint32(this.pos, true); this.pos += 4; this.hr = this.view.getInt32(this.pos, true); this.pos += 4; this.vr = this.view.getInt32(this.pos, true); this.pos += 4; this.colors = this.view.getUint32(this.pos, true); this.pos += 4; this.importantColors = this.view.getUint32(this.pos, true); this.pos += 4; // De facto defaults if (this.bitsPerPixel === 32) { this.maskAlpha = 0; this.maskRed = 0x00ff0000; this.maskGreen = 0x0000ff00; this.maskBlue = 0x000000ff; } else if (this.bitsPerPixel === 16) { this.maskAlpha = 0; this.maskRed = 0x7c00; this.maskGreen = 0x03e0; this.maskBlue = 0x001f; } // End of BITMAP_INFO_HEADER if (this.headerSize > HeaderTypes.BITMAP_INFO_HEADER || this.compression === 3 /* BI_BIT_FIELDS */ || this.compression === 6 /* BI_ALPHA_BIT_FIELDS */) { this.maskRed = this.view.getUint32(this.pos, true); this.pos += 4; this.maskGreen = this.view.getUint32(this.pos, true); this.pos += 4; this.maskBlue = this.view.getUint32(this.pos, true); this.pos += 4; } // End of BITMAP_V2_INFO_HEADER if (this.headerSize > HeaderTypes.BITMAP_V2_INFO_HEADER || this.compression === 6 /* BI_ALPHA_BIT_FIELDS */) { this.maskAlpha = this.view.getUint32(this.pos, true); this.pos += 4; } // End of BITMAP_V3_INFO_HEADER if (this.headerSize > HeaderTypes.BITMAP_V3_INFO_HEADER) { this.pos += HeaderTypes.BITMAP_V4_HEADER - HeaderTypes.BITMAP_V3_INFO_HEADER; } // End of BITMAP_V4_HEADER if (this.headerSize > HeaderTypes.BITMAP_V4_HEADER) { this.pos += HeaderTypes.BITMAP_V5_HEADER - HeaderTypes.BITMAP_V4_HEADER; } // End of BITMAP_V5_HEADER if (this.bitsPerPixel <= 8 || this.colors > 0) { const len = this.colors === 0 ? 1 << this.bitsPerPixel : this.colors; this.palette = new Array(len); for (let i = 0; i < len; i++) { const blue = this.view.getUint8(this.pos); this.pos += 1; const green = this.view.getUint8(this.pos); this.pos += 1; const red = this.view.getUint8(this.pos); this.pos += 1; const quad = this.view.getUint8(this.pos); this.pos += 1; this.palette[i] = { red, green, blue, quad }; } } // End of color table const coloShift = maskColor(this.maskRed, this.maskGreen, this.maskBlue, this.maskAlpha); this.shiftRed = coloShift.shiftRed; this.shiftGreen = coloShift.shiftGreen; this.shiftBlue = coloShift.shiftBlue; this.shiftAlpha = coloShift.shiftAlpha; } parseRGBA() { this.data = new Uint8ClampedArray(this.width * this.height * 4); switch (this.bitsPerPixel) { case 1: this.parse1bpp(); break; case 4: this.parse4bpp(); break; case 8: this.parse8bpp(); break; case 16: this.parse16bpp(); break; case 24: this.parse24bpp(); break; default: this.parse32bpp(); } } parse1bpp() { const xLen = Math.ceil(this.width / 8); const mode = xLen % 4; const padding = mode !== 0 ? 4 - mode : 0; let lastLine; this.scanImage(padding, xLen, (x, line) => { if (line !== lastLine) { lastLine = line; } const b = this.view.getUint8(this.pos); this.pos += 1; const location = line * this.width * 4 + x * 8 * 4; for (let i = 0; i < 8; i++) { if (x * 8 + i < this.width) { // @TODO: use setPixelData? const rgb = this.palette[(b >> (7 - i)) & 0x1]; this.data[location + i * 4 + this.locAlpha] = 255; this.data[location + i * 4 + this.locBlue] = rgb.blue; this.data[location + i * 4 + this.locGreen] = rgb.green; this.data[location + i * 4 + this.locRed] = rgb.red; } else { break; } } }); } parse4bpp() { if (this.compression === 2 /* BI_RLE4 */) { let lowNibble = false; //for all count of pixel let lines = this.bottomUp ? this.height - 1 : 0; let location = 0; while (location < this.data.length) { const a = this.view.getUint8(this.pos); this.pos += 1; const b = this.view.getUint8(this.pos); this.pos += 1; //absolute mode if (a === 0) { if (b === 0) { //line end lines += this.bottomUp ? -1 : 1; location = lines * this.width * 4; lowNibble = false; continue; } if (b === 1) { // image end break; } if (b === 2) { // offset x, y const x = this.view.getUint8(this.pos); this.pos += 1; const y = this.view.getUint8(this.pos); this.pos += 1; lines += this.bottomUp ? -y : y; location += y * this.width * 4 + x * 4; } else { let c = this.view.getUint8(this.pos); this.pos += 1; for (let i = 0; i < b; i++) { location = this.setPixelData(location, lowNibble ? c & 0x0f : (c & 0xf0) >> 4); if (i & 1 && i + 1 < b) { c = this.view.getUint8(this.pos); this.pos += 1; } lowNibble = !lowNibble; } if ((((b + 1) >> 1) & 1) === 1) { this.pos += 1; } } } else { //encoded mode for (let i = 0; i < a; i++) { location = this.setPixelData(location, lowNibble ? b & 0x0f : (b & 0xf0) >> 4); lowNibble = !lowNibble; } } } } else { const xLen = Math.ceil(this.width / 2); const mode = xLen % 4; const padding = mode !== 0 ? 4 - mode : 0; this.scanImage(padding, xLen, (x, line) => { const b = this.view.getUint8(this.pos); this.pos += 1; const location = line * this.width * 4 + x * 2 * 4; const first4 = b >> 4; // @TODO: use setPixelData? let rgb = this.palette[first4]; this.data[location + this.locAlpha] = 255; this.data[location + this.locBlue] = rgb.blue; this.data[location + this.locGreen] = rgb.green; this.data[location + this.locRed] = rgb.red; if (x * 2 + 1 >= this.width) { // throw new Error('Something'); return false; } const last4 = b & 0x0f; // @TODO: use setPixelData? rgb = this.palette[last4]; this.data[location + 4 + this.locAlpha] = 255; this.data[location + 4 + this.locBlue] = rgb.blue; this.data[location + 4 + this.locGreen] = rgb.green; this.data[location + 4 + this.locRed] = rgb.red; }); } } parse8bpp() { if (this.compression === 1 /* BI_RLE8 */) { let lines = this.bottomUp ? this.height - 1 : 0; let location = 0; while (location < this.data.length) { const a = this.view.getUint8(this.pos); this.pos += 1; const b = this.view.getUint8(this.pos); this.pos += 1; //absolute mode if (a === 0) { if (b === 0) { //line end lines += this.bottomUp ? -1 : 1; location = lines * this.width * 4; continue; } if (b === 1) { //image end break; } if (b === 2) { //offset x,y const x = this.view.getUint8(this.pos); this.pos += 1; const y = this.view.getUint8(this.pos); this.pos += 1; lines += this.bottomUp ? -y : y; location += y * this.width * 4 + x * 4; } else { for (let i = 0; i < b; i++) { const c = this.view.getUint8(this.pos); this.pos += 1; location = this.setPixelData(location, c); } // why 1 === 1??? // eslint-disable-next-line no-self-compare const shouldIncrement = b & (1 === 1); if (shouldIncrement) { this.pos += 1; } } } else { //encoded mode for (let i = 0; i < a; i++) { location = this.setPixelData(location, b); } } } } else { const mode = this.width % 4; const padding = mode !== 0 ? 4 - mode : 0; this.scanImage(padding, this.width, (x, line) => { const b = this.view.getUint8(this.pos); this.pos += 1; const location = line * this.width * 4 + x * 4; // @TODO: use setPixelData? if (b < this.palette.length) { const rgb = this.palette[b]; this.data[location + this.locRed] = rgb.red; this.data[location + this.locGreen] = rgb.green; this.data[location + this.locBlue] = rgb.blue; this.data[location + this.locAlpha] = 0xff; } else { this.data[location] = 0xff; this.data[location + 1] = 0xff; this.data[location + 2] = 0xff; this.data[location + 3] = 0xff; } }); } } parse16bpp() { const padding = (this.width % 2) * 2; this.scanImage(padding, this.width, (x, line) => { const loc = line * this.width * 4 + x * 4; const px = this.view.getUint16(this.pos, true); this.pos += 2; this.data[loc + this.locRed] = this.shiftRed(px); this.data[loc + this.locGreen] = this.shiftGreen(px); this.data[loc + this.locBlue] = this.shiftBlue(px); this.data[loc + this.locAlpha] = 255; //this.shiftAlpha(px); // @TODO?? }); } parse24bpp() { const padding = this.width % 4; this.scanImage(padding, this.width, (x, line) => { const loc = line * this.width * 4 + x * 4; const blue = this.view.getUint8(this.pos); this.pos += 1; const green = this.view.getUint8(this.pos); this.pos += 1; const red = this.view.getUint8(this.pos); this.pos += 1; this.data[loc + this.locRed] = red; this.data[loc + this.locGreen] = green; this.data[loc + this.locBlue] = blue; this.data[loc + this.locAlpha] = 255; }); } parse32bpp() { this.scanImage(0, this.width, (x, line) => { const loc = line * this.width * 4 + x * 4; const px = this.view.getUint32(this.pos, true); this.pos += 4; this.data[loc + this.locRed] = this.shiftRed(px); this.data[loc + this.locGreen] = this.shiftGreen(px); this.data[loc + this.locBlue] = this.shiftBlue(px); this.data[loc + this.locAlpha] = this.shiftAlpha(px); }); } scanImage(padding = 0, width = this.width, processPixel) { for (let y = this.height - 1; y >= 0; y--) { const line = this.bottomUp ? y : this.height - 1 - y; for (let x = 0; x < width; x++) { const result = processPixel.call(this, x, line); if (result === false) { return; } } this.pos += padding; } } setPixelData(location, rgbIndex) { const { blue, green, red } = this.palette[rgbIndex]; this.data[location + this.locAlpha] = 255; this.data[location + this.locBlue] = blue; this.data[location + this.locGreen] = green; this.data[location + this.locRed] = red; return location + 4; } }