tower / cascadia / files / tools / png.mjs
Branch cascadia · default branch · all branches
png.mjs
| 1 | /** |
| 2 | * Minimal PNG encoder. Node's zlib is the only dependency. |
| 3 | * |
| 4 | * Supports what the terrain tool needs: 16-bit greyscale (height data, where 8 bits |
| 5 | * would quantise a 5,000 m range into 20 m steps) and 8-bit RGB (the colour map). |
| 6 | * Scanlines use the Up filter, which suits smooth terrain and costs three lines. |
| 7 | */ |
| 8 | import { deflateSync, inflateSync } from 'node:zlib' |
| 9 | |
| 10 | const CRC_TABLE = (() => { |
| 11 | const t = new Int32Array(256) |
| 12 | for (let n = 0; n < 256; n++) { |
| 13 | let c = n |
| 14 | for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 |
| 15 | t[n] = c |
| 16 | } |
| 17 | return t |
| 18 | })() |
| 19 | |
| 20 | function crc32(buf) { |
| 21 | let c = -1 |
| 22 | for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8) |
| 23 | return (c ^ -1) >>> 0 |
| 24 | } |
| 25 | |
| 26 | function chunk(type, data) { |
| 27 | const out = Buffer.alloc(data.length + 12) |
| 28 | out.writeUInt32BE(data.length, 0) |
| 29 | out.write(type, 4, 'ascii') |
| 30 | data.copy(out, 8) |
| 31 | const body = out.subarray(4, 8 + data.length) |
| 32 | out.writeUInt32BE(crc32(body), 8 + data.length) |
| 33 | return out |
| 34 | } |
| 35 | |
| 36 | /** Apply the Up filter in place, row by row, and prefix each row with its filter byte. */ |
| 37 | function filterRows(raw, width, height, bytesPerPixel) { |
| 38 | const stride = width * bytesPerPixel |
| 39 | const out = Buffer.alloc((stride + 1) * height) |
| 40 | for (let y = 0; y < height; y++) { |
| 41 | out[y * (stride + 1)] = 2 // Up |
| 42 | const row = y * stride |
| 43 | const prev = (y - 1) * stride |
| 44 | const dst = y * (stride + 1) + 1 |
| 45 | for (let x = 0; x < stride; x++) { |
| 46 | const up = y === 0 ? 0 : raw[prev + x] |
| 47 | out[dst + x] = (raw[row + x] - up) & 0xff |
| 48 | } |
| 49 | } |
| 50 | return out |
| 51 | } |
| 52 | |
| 53 | function encode(width, height, colorType, bitDepth, raw, bytesPerPixel, level) { |
| 54 | const ihdr = Buffer.alloc(13) |
| 55 | ihdr.writeUInt32BE(width, 0) |
| 56 | ihdr.writeUInt32BE(height, 4) |
| 57 | ihdr[8] = bitDepth |
| 58 | ihdr[9] = colorType |
| 59 | return Buffer.concat([ |
| 60 | Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), |
| 61 | chunk('IHDR', ihdr), |
| 62 | chunk('IDAT', deflateSync(filterRows(raw, width, height, bytesPerPixel), { level })), |
| 63 | chunk('IEND', Buffer.alloc(0)), |
| 64 | ]) |
| 65 | } |
| 66 | |
| 67 | /** |
| 68 | * 16-bit greyscale from normalised values in [0,1]. Level 9 is nearly free here: |
| 69 | * smooth terrain under the Up filter is almost all near-zero deltas. |
| 70 | */ |
| 71 | export function encodeGray16(width, height, values, level = 9) { |
| 72 | const raw = Buffer.alloc(width * height * 2) |
| 73 | for (let i = 0; i < width * height; i++) { |
| 74 | const v = Math.max(0, Math.min(1, values[i])) |
| 75 | raw.writeUInt16BE(Math.round(v * 65535), i * 2) |
| 76 | } |
| 77 | return encode(width, height, 0, 16, raw, 2, level) |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * 8-bit RGB from an interleaved Uint8Array of length width*height*3. |
| 82 | * Level 6 by default: hillshade noise compresses poorly, and level 9 spends |
| 83 | * several seconds per map for a couple of percent. Wrong trade for a dev tool. |
| 84 | */ |
| 85 | export function encodeRGB(width, height, rgb, level = 6) { |
| 86 | return encode(width, height, 2, 8, Buffer.from(rgb.buffer, rgb.byteOffset, rgb.length), 3, level) |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Decode an 8-bit PNG to interleaved RGB. The inverse of the above, and only as |
| 91 | * general as tools/cascadia.mjs needs: colour types 0, 2 and 6 at bit depth 8, |
| 92 | * which is what every elevation tile service emits. Interlaced files are rejected |
| 93 | * rather than mishandled, because a silently wrong height field is D-024's failure |
| 94 | * mode — it displaces terrain instead of breaking it. |
| 95 | */ |
| 96 | export function decodeRGB(buf) { |
| 97 | if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG') |
| 98 | let width = 0, height = 0, colorType = 0, bitDepth = 0 |
| 99 | const idat = [] |
| 100 | for (let p = 8; p < buf.length;) { |
| 101 | const len = buf.readUInt32BE(p) |
| 102 | const type = buf.toString('ascii', p + 4, p + 8) |
| 103 | const data = buf.subarray(p + 8, p + 8 + len) |
| 104 | if (type === 'IHDR') { |
| 105 | width = data.readUInt32BE(0) |
| 106 | height = data.readUInt32BE(4) |
| 107 | bitDepth = data[8] |
| 108 | colorType = data[9] |
| 109 | if (data[12] !== 0) throw new Error('interlaced PNG unsupported') |
| 110 | } else if (type === 'IDAT') idat.push(data) |
| 111 | else if (type === 'IEND') break |
| 112 | p += len + 12 |
| 113 | } |
| 114 | if (bitDepth !== 8) throw new Error(`bit depth ${bitDepth} unsupported`) |
| 115 | const channels = { 0: 1, 2: 3, 6: 4 }[colorType] |
| 116 | if (!channels) throw new Error(`colour type ${colorType} unsupported`) |
| 117 | |
| 118 | const raw = inflateSync(Buffer.concat(idat)) |
| 119 | const stride = width * channels |
| 120 | const out = Buffer.alloc(stride * height) |
| 121 | // Undo the per-scanline filter. Paeth is the only one that needs a helper. |
| 122 | for (let y = 0; y < height; y++) { |
| 123 | const filter = raw[y * (stride + 1)] |
| 124 | const src = y * (stride + 1) + 1 |
| 125 | const dst = y * stride |
| 126 | const up = (y - 1) * stride |
| 127 | for (let x = 0; x < stride; x++) { |
| 128 | const a = x >= channels ? out[dst + x - channels] : 0 |
| 129 | const b = y > 0 ? out[up + x] : 0 |
| 130 | const c = x >= channels && y > 0 ? out[up + x - channels] : 0 |
| 131 | let v = raw[src + x] |
| 132 | if (filter === 1) v += a |
| 133 | else if (filter === 2) v += b |
| 134 | else if (filter === 3) v += (a + b) >> 1 |
| 135 | else if (filter === 4) { |
| 136 | const q = a + b - c |
| 137 | const pa = Math.abs(q - a), pb = Math.abs(q - b), pc = Math.abs(q - c) |
| 138 | v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c |
| 139 | } |
| 140 | out[dst + x] = v & 0xff |
| 141 | } |
| 142 | } |
| 143 | return { width, height, channels, data: out } |
| 144 | } |