diablo2-web/scripts/png.ts

173 lines
6.0 KiB
TypeScript

/**
* Indexed-colour PNG writer for the offline asset packs.
*
* Diablo II's art is palette-indexed, so PNG's indexed colour type is the right
* target rather than RGBA: the browser decodes it natively, the file is roughly
* a quarter of the size, and the palette travels in the same file. Index 0 is
* made transparent through `tRNS`, which is how the decoders already mark
* "no pixel".
*
* Only what the packer needs is implemented — no interlacing, no 16-bit, no
* colour types other than 3 — because a half-implemented encoder that silently
* produces a file another tool misreads is worse than a small one.
*
* Node's `zlib` does the deflate; everything else is written here.
*/
import { deflateSync } from 'node:zlib'
/** PNG's 8-byte signature. */
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
/** CRC-32 table, built once. */
const CRC_TABLE: Uint32Array = (() => {
const table = new Uint32Array(256)
for (let n = 0; n < 256; n += 1) {
let c = n
for (let k = 0; k < 8; k += 1) c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) >>> 0 : c >>> 1
table[n] = c >>> 0
}
return table
})()
/**
* CRC-32 of a byte range, as PNG chunks require.
*
* @param bytes - the bytes.
* @returns the checksum.
*/
function crc32(bytes: Uint8Array): number {
let c = 0xffffffff
for (const byte of bytes) c = (CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8)) >>> 0
return (c ^ 0xffffffff) >>> 0
}
/**
* One PNG chunk: length, type, payload, CRC.
*
* @param type - four-character chunk type.
* @param payload - chunk body.
* @returns the serialised chunk.
*/
function chunk(type: string, payload: Uint8Array): Uint8Array {
const out = new Uint8Array(12 + payload.byteLength)
const view = new DataView(out.buffer)
view.setUint32(0, payload.byteLength)
for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i)
out.set(payload, 8)
view.setUint32(8 + payload.byteLength, crc32(out.subarray(4, 8 + payload.byteLength)))
return out
}
/** An indexed image ready to encode. */
export interface IndexedImage {
/** Width in pixels. */
readonly width: number
/** Height in pixels. */
readonly height: number
/** Palette indices, row-major, `width * height` bytes. */
readonly pixels: Uint8Array
/** Palette as 768 RGB bytes. */
readonly palette: Uint8Array
/** Index treated as fully transparent, if any (usually 0). */
readonly transparentIndex?: number | undefined
}
/**
* Apply a PNG row filter in place into a destination buffer.
*
* @param type - filter type (0 none, 1 sub, 2 up).
* @param row - the row's bytes.
* @param previous - the row above, or null for the first row.
* @param stride - bytes per pixel (1 for indexed).
* @param out - destination, `row.byteLength` bytes.
*/
function filterRow(type: number, row: Uint8Array, previous: Uint8Array | null, stride: number, out: Uint8Array): void {
for (let i = 0; i < row.byteLength; i += 1) {
const raw = row[i]!
const left = i >= stride ? row[i - stride]! : 0
const up = previous === null ? 0 : previous[i]!
const value = type === 0 ? raw : type === 1 ? raw - left : raw - up
out[i] = value & 0xff
}
}
/**
* Sum of absolute differences after treating bytes as signed, the standard
* filter-choice heuristic: the filter that leaves the flattest rows compresses
* best.
*
* @param bytes - filtered row.
* @returns the score.
*/
function score(bytes: Uint8Array): number {
let total = 0
for (const byte of bytes) total += byte < 128 ? byte : 256 - byte
return total
}
/**
* Encode an indexed image as a PNG.
*
* Each row picks its own filter (none / sub / up) by the usual minimum-sum-of-
* absolute-differences rule. For tile art — large flat regions with sharp edges
* — that is worth a solid fraction of the file size over "no filter everywhere",
* and it costs one pass over the data.
*
* @param image - the image.
* @returns the PNG bytes.
*/
export function encodeIndexedPng(image: IndexedImage): Uint8Array {
const { width, height, pixels, palette } = image
if (pixels.byteLength !== width * height) {
throw new Error(`pixel buffer is ${String(pixels.byteLength)} bytes for ${String(width)}x${String(height)}`)
}
if (palette.byteLength % 3 !== 0 || palette.byteLength === 0 || palette.byteLength > 768) {
throw new Error(`palette must be 3..768 bytes of RGB triples, got ${String(palette.byteLength)}`)
}
const ihdr = new Uint8Array(13)
const ihdrView = new DataView(ihdr.buffer)
ihdrView.setUint32(0, width)
ihdrView.setUint32(4, height)
ihdr[8] = 8 // bit depth
ihdr[9] = 3 // colour type: indexed
ihdr[10] = 0 // deflate
ihdr[11] = 0 // adaptive filtering
ihdr[12] = 0 // no interlace
const plte = Uint8Array.from(palette)
const trns = new Uint8Array((image.transparentIndex ?? 0) + 1)
trns.fill(255)
if (image.transparentIndex !== undefined) trns[image.transparentIndex] = 0
const stride = 1
const raw = new Uint8Array((width * stride + 1) * height)
const candidate = new Uint8Array(width)
let at = 0
for (let y = 0; y < height; y += 1) {
const row = pixels.subarray(y * width, (y + 1) * width)
const previous = y === 0 ? null : pixels.subarray((y - 1) * width, y * width)
let bestType = 0
let bestScore = Number.POSITIVE_INFINITY
for (const type of [0, 1, 2]) {
filterRow(type, row, previous, stride, candidate)
const value = score(candidate)
if (value < bestScore) { bestScore = value; bestType = type }
}
filterRow(bestType, row, previous, stride, candidate)
raw[at] = bestType
raw.set(candidate, at + 1)
at += width + 1
}
const idat = new Uint8Array(deflateSync(raw, { level: 9 }))
const parts = [SIGNATURE, chunk('IHDR', ihdr), chunk('PLTE', plte)]
if (image.transparentIndex !== undefined) parts.push(chunk('tRNS', trns))
parts.push(chunk('IDAT', idat), chunk('IEND', new Uint8Array(0)))
const size = parts.reduce((sum, part) => sum + part.byteLength, 0)
const out = new Uint8Array(size)
let offset = 0
for (const part of parts) { out.set(part, offset); offset += part.byteLength }
return out
}