1// Enough PNG to cut one icon out of a marker sprite sheet.2//3// Some games publish category icons as a single strip that CSS windows into4// with background-position, rather than as a font. Slicing that strip is the5// only raster work the icon step needs, and Node's zlib covers it, so it stays6// dependency-free rather than pulling in an image library for one operation.7//8// Only what MapGenie actually serves is handled: 8-bit non-interlaced images.910import zlib from "node:zlib";1112const SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);1314const crcTable = (() => {15 const table = new Int32Array(256);16 for (let index = 0; index < 256; index += 1) {17 let value = index;18 for (let bit = 0; bit < 8; bit += 1) {19 value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;20 }21 table[index] = value;22 }23 return table;24})();2526const crc32 = (buffer) => {27 let value = -1;28 for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);29 return (value ^ -1) >>> 0;30};3132const chunk = (type, body) => {33 const header = Buffer.alloc(8);34 header.writeUInt32BE(body.length, 0);35 header.write(type, 4, "ascii");36 const trailer = Buffer.alloc(4);37 trailer.writeUInt32BE(crc32(Buffer.concat([header.subarray(4), body])), 0);38 return Buffer.concat([header, body, trailer]);39};4041const CHANNELS = { 0: 1, 2: 3, 4: 2, 6: 4 };4243// Each row is filtered against the one above it, so decoding is sequential.44const unfilter = (raw, width, height, bytesPerPixel) => {45 const stride = width * bytesPerPixel;46 const out = Buffer.alloc(height * stride);47 let source = 0;48 for (let y = 0; y < height; y += 1) {49 const filter = raw[source];50 source += 1;51 const row = y * stride;52 const above = row - stride;53 for (let index = 0; index < stride; index += 1) {54 const value = raw[source + index];55 const left = index >= bytesPerPixel ? out[row + index - bytesPerPixel] : 0;56 const up = y > 0 ? out[above + index] : 0;57 const upLeft =58 y > 0 && index >= bytesPerPixel ? out[above + index - bytesPerPixel] : 0;59 let restored;60 switch (filter) {61 case 0:62 restored = value;63 break;64 case 1:65 restored = value + left;66 break;67 case 2:68 restored = value + up;69 break;70 case 3:71 restored = value + ((left + up) >> 1);72 break;73 case 4: {74 const estimate = left + up - upLeft;75 const dLeft = Math.abs(estimate - left);76 const dUp = Math.abs(estimate - up);77 const dUpLeft = Math.abs(estimate - upLeft);78 restored =79 value +80 (dLeft <= dUp && dLeft <= dUpLeft ? left : dUp <= dUpLeft ? up : upLeft);81 break;82 }83 default:84 throw new Error(`unsupported PNG row filter ${filter}`);85 }86 out[row + index] = restored & 0xff;87 }88 source += stride;89 }90 return out;91};9293export const decodePNG = (buffer) => {94 if (!buffer.subarray(0, 8).equals(SIGNATURE)) throw new Error("not a PNG");95 let offset = 8;96 let header = null;97 let palette = null;98 let alpha = null;99 const data = [];100101 while (offset < buffer.length) {102 const length = buffer.readUInt32BE(offset);103 const type = buffer.toString("ascii", offset + 4, offset + 8);104 const body = buffer.subarray(offset + 8, offset + 8 + length);105 offset += 12 + length;106107 if (type === "IHDR") {108 header = {109 width: body.readUInt32BE(0),110 height: body.readUInt32BE(4),111 depth: body[8],112 colorType: body[9],113 interlace: body[12],114 };115 } else if (type === "PLTE") palette = Buffer.from(body);116 else if (type === "tRNS") alpha = Buffer.from(body);117 else if (type === "IDAT") data.push(Buffer.from(body));118 else if (type === "IEND") break;119 }120121 if (!header) throw new Error("PNG has no header");122 if (header.depth !== 8) throw new Error(`unsupported PNG bit depth ${header.depth}`);123 if (header.interlace !== 0) throw new Error("interlaced PNG is not supported");124125 const { width, height, colorType } = header;126 const raw = zlib.inflateSync(Buffer.concat(data));127128 // Everything is normalised to RGBA so the caller only deals with one layout.129 const rgba = Buffer.alloc(width * height * 4);130 if (colorType === 3) {131 if (!palette) throw new Error("indexed PNG has no palette");132 const indices = unfilter(raw, width, height, 1);133 for (let pixel = 0; pixel < width * height; pixel += 1) {134 const entry = indices[pixel];135 rgba[pixel * 4] = palette[entry * 3];136 rgba[pixel * 4 + 1] = palette[entry * 3 + 1];137 rgba[pixel * 4 + 2] = palette[entry * 3 + 2];138 rgba[pixel * 4 + 3] = alpha && entry < alpha.length ? alpha[entry] : 255;139 }140 return { width, height, rgba };141 }142143 const channels = CHANNELS[colorType];144 if (!channels) throw new Error(`unsupported PNG colour type ${colorType}`);145 const pixels = unfilter(raw, width, height, channels);146 for (let pixel = 0; pixel < width * height; pixel += 1) {147 const source = pixel * channels;148 const target = pixel * 4;149 if (colorType === 0 || colorType === 4) {150 const grey = pixels[source];151 rgba[target] = grey;152 rgba[target + 1] = grey;153 rgba[target + 2] = grey;154 rgba[target + 3] = colorType === 4 ? pixels[source + 1] : 255;155 } else {156 rgba[target] = pixels[source];157 rgba[target + 1] = pixels[source + 1];158 rgba[target + 2] = pixels[source + 2];159 rgba[target + 3] = colorType === 6 ? pixels[source + 3] : 255;160 }161 }162 return { width, height, rgba };163};164165export const encodePNG = ({ width, height, rgba }) => {166 const stride = width * 4;167 const raw = Buffer.alloc(height * (stride + 1));168 for (let y = 0; y < height; y += 1) {169 raw[y * (stride + 1)] = 0; // no filtering; these images are tiny170 rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);171 }172 const header = Buffer.alloc(13);173 header.writeUInt32BE(width, 0);174 header.writeUInt32BE(height, 4);175 header[8] = 8; // bit depth176 header[9] = 6; // RGBA177 return Buffer.concat([178 SIGNATURE,179 chunk("IHDR", header),180 chunk("IDAT", zlib.deflateSync(raw, { level: 9 })),181 chunk("IEND", Buffer.alloc(0)),182 ]);183};184185export const cropPNG = (image, x, y, width, height) => {186 const rgba = Buffer.alloc(width * height * 4);187 for (let row = 0; row < height; row += 1) {188 const source = ((y + row) * image.width + x) * 4;189 image.rgba.copy(rgba, row * width * 4, source, source + width * 4);190 }191 return { width, height, rgba };192};193194// A slice that is entirely transparent is a gap in the sprite, not an icon.195export const isBlank = ({ rgba }) => {196 for (let index = 3; index < rgba.length; index += 4) {197 if (rgba[index] !== 0) return false;198 }199 return true;200};