tower / cascadia / files / tools / png.mjs
Branch cascadia · default branch · all branches
png.mjs
1de9dc7c 2026-09-07 Pavel Safronov | 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 | */ | |
1affced2 2026-09-08 Pavel Safronov | 8 | import { deflateSync, inflateSync } from 'node:zlib' |
1de9dc7c 2026-09-07 Pavel Safronov | 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 | } | |
1affced2 2026-09-08 Pavel Safronov | 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 | } |