586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { inflateSync } from 'node:zlib'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { writeFileSync, unlinkSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { encodeIndexedPng, encodeRgbaPng, type IndexedImage, type RgbaImage } from '../scripts/png'
|
|
|
|
// --- Independent PNG Specification Decoder & Oracle ---
|
|
|
|
interface DecodedPng {
|
|
readonly width: number
|
|
readonly height: number
|
|
readonly bitDepth: number
|
|
readonly colorType: number
|
|
readonly compressionMethod: number
|
|
readonly filterMethod: number
|
|
readonly interlaceMethod: number
|
|
readonly palette?: Uint8Array | undefined
|
|
readonly transparentIndex?: number | undefined
|
|
readonly pixels: Uint8Array
|
|
readonly scanlineFilterTypes: number[]
|
|
}
|
|
|
|
const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
|
|
const CRC_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
|
|
CRC_TABLE[n] = c >>> 0
|
|
}
|
|
|
|
function verifyCrc32(bytes: Uint8Array, expected: number): boolean {
|
|
let c = 0xffffffff
|
|
for (const byte of bytes) c = (CRC_TABLE[(c ^ byte) & 0xff]! ^ (c >>> 8)) >>> 0
|
|
return ((c ^ 0xffffffff) >>> 0) === expected
|
|
}
|
|
|
|
/**
|
|
* Strictly decodes a PNG byte stream according to W3C PNG Specification.
|
|
* Validates header, chunks, CRCs, decompress IDAT, and unfilters scanlines.
|
|
*/
|
|
export function decodePng(bytes: Uint8Array): DecodedPng {
|
|
if (bytes.length < 8) throw new Error('PNG too short for signature')
|
|
for (let i = 0; i < 8; i += 1) {
|
|
if (bytes[i] !== PNG_SIGNATURE[i]) {
|
|
throw new Error(`Invalid PNG signature byte at ${i}: expected ${PNG_SIGNATURE[i]}, got ${bytes[i]}`)
|
|
}
|
|
}
|
|
|
|
let offset = 8
|
|
let ihdr: { width: number; height: number; bitDepth: number; colorType: number; comp: number; filter: number; interlace: number } | null = null
|
|
let palette: Uint8Array | undefined
|
|
let transparentIndex: number | undefined
|
|
const idatParts: Uint8Array[] = []
|
|
|
|
while (offset < bytes.length) {
|
|
if (offset + 12 > bytes.length) throw new Error(`Truncated chunk header at offset ${offset}`)
|
|
const view = new DataView(bytes.buffer, bytes.byteOffset + offset, 8)
|
|
const length = view.getUint32(0)
|
|
const type = String.fromCharCode(bytes[offset + 4]!, bytes[offset + 5]!, bytes[offset + 6]!, bytes[offset + 7]!)
|
|
|
|
if (offset + 12 + length > bytes.length) {
|
|
throw new Error(`Truncated chunk payload for ${type} at offset ${offset}`)
|
|
}
|
|
|
|
const payload = bytes.subarray(offset + 8, offset + 8 + length)
|
|
const crcView = new DataView(bytes.buffer, bytes.byteOffset + offset + 8 + length, 4)
|
|
const expectedCrc = crcView.getUint32(0)
|
|
|
|
const chunkDataForCrc = bytes.subarray(offset + 4, offset + 8 + length)
|
|
if (!verifyCrc32(chunkDataForCrc, expectedCrc)) {
|
|
throw new Error(`CRC32 mismatch for chunk ${type} at offset ${offset}`)
|
|
}
|
|
|
|
if (type === 'IHDR') {
|
|
const ihdrView = new DataView(payload.buffer, payload.byteOffset, 13)
|
|
ihdr = {
|
|
width: ihdrView.getUint32(0),
|
|
height: ihdrView.getUint32(4),
|
|
bitDepth: payload[8]!,
|
|
colorType: payload[9]!,
|
|
comp: payload[10]!,
|
|
filter: payload[11]!,
|
|
interlace: payload[12]!,
|
|
}
|
|
} else if (type === 'PLTE') {
|
|
palette = new Uint8Array(payload)
|
|
} else if (type === 'tRNS') {
|
|
for (let i = 0; i < payload.length; i += 1) {
|
|
if (payload[i] === 0) {
|
|
transparentIndex = i
|
|
break
|
|
}
|
|
}
|
|
} else if (type === 'IDAT') {
|
|
idatParts.push(payload)
|
|
} else if (type === 'IEND') {
|
|
break
|
|
}
|
|
|
|
offset += 12 + length
|
|
}
|
|
|
|
if (!ihdr) throw new Error('Missing IHDR chunk')
|
|
if (ihdr.bitDepth !== 8) throw new Error(`Unsupported bit depth: ${ihdr.bitDepth}`)
|
|
if (ihdr.comp !== 0 || ihdr.filter !== 0 || ihdr.interlace !== 0) {
|
|
throw new Error('Unsupported compression, filter, or interlace method')
|
|
}
|
|
|
|
const stride = ihdr.colorType === 3 ? 1 : ihdr.colorType === 6 ? 4 : 0
|
|
if (stride === 0) throw new Error(`Unsupported color type: ${ihdr.colorType}`)
|
|
|
|
const totalIdatLen = idatParts.reduce((acc, p) => acc + p.length, 0)
|
|
const mergedIdat = new Uint8Array(totalIdatLen)
|
|
let idatOff = 0
|
|
for (const part of idatParts) {
|
|
mergedIdat.set(part, idatOff)
|
|
idatOff += part.length
|
|
}
|
|
|
|
const decompressed = new Uint8Array(inflateSync(mergedIdat))
|
|
const rowBytes = ihdr.width * stride
|
|
const expectedRawLen = (rowBytes + 1) * ihdr.height
|
|
if (decompressed.length !== expectedRawLen) {
|
|
throw new Error(`Decompressed length mismatch: expected ${expectedRawLen}, got ${decompressed.length}`)
|
|
}
|
|
|
|
const pixels = new Uint8Array(ihdr.width * ihdr.height * stride)
|
|
const scanlineFilterTypes: number[] = []
|
|
let at = 0
|
|
|
|
for (let y = 0; y < ihdr.height; y += 1) {
|
|
const filterType = decompressed[at]!
|
|
scanlineFilterTypes.push(filterType)
|
|
at += 1
|
|
|
|
const row = decompressed.subarray(at, at + rowBytes)
|
|
const outRow = pixels.subarray(y * rowBytes, (y + 1) * rowBytes)
|
|
const priorRow = y === 0 ? null : pixels.subarray((y - 1) * rowBytes, y * rowBytes)
|
|
|
|
if (filterType === 0) {
|
|
// None
|
|
outRow.set(row)
|
|
} else if (filterType === 1) {
|
|
// Sub
|
|
for (let x = 0; x < stride; x += 1) {
|
|
outRow[x] = row[x]!
|
|
}
|
|
for (let x = stride; x < rowBytes; x += 1) {
|
|
outRow[x] = (row[x]! + outRow[x - stride]!) & 0xff
|
|
}
|
|
} else if (filterType === 2) {
|
|
// Up
|
|
for (let x = 0; x < rowBytes; x += 1) {
|
|
const up = priorRow ? priorRow[x]! : 0
|
|
outRow[x] = (row[x]! + up) & 0xff
|
|
}
|
|
} else if (filterType === 3) {
|
|
// Average
|
|
for (let x = 0; x < rowBytes; x += 1) {
|
|
const left = x >= stride ? outRow[x - stride]! : 0
|
|
const up = priorRow ? priorRow[x]! : 0
|
|
outRow[x] = (row[x]! + Math.floor((left + up) / 2)) & 0xff
|
|
}
|
|
} else if (filterType === 4) {
|
|
// Paeth
|
|
for (let x = 0; x < rowBytes; x += 1) {
|
|
const a = x >= stride ? outRow[x - stride]! : 0
|
|
const b = priorRow ? priorRow[x]! : 0
|
|
const c = x >= stride && priorRow ? priorRow[x - stride]! : 0
|
|
const p = a + b - c
|
|
const pa = Math.abs(p - a)
|
|
const pb = Math.abs(p - b)
|
|
const pc = Math.abs(p - c)
|
|
let pr = c
|
|
if (pa <= pb && pa <= pc) pr = a
|
|
else if (pb <= pc) pr = b
|
|
outRow[x] = (row[x]! + pr) & 0xff
|
|
}
|
|
} else {
|
|
throw new Error(`Invalid filter type: ${filterType} at row ${y}`)
|
|
}
|
|
|
|
at += rowBytes
|
|
}
|
|
|
|
return {
|
|
width: ihdr.width,
|
|
height: ihdr.height,
|
|
bitDepth: ihdr.bitDepth,
|
|
colorType: ihdr.colorType,
|
|
compressionMethod: ihdr.comp,
|
|
filterMethod: ihdr.filter,
|
|
interlaceMethod: ihdr.interlace,
|
|
palette,
|
|
transparentIndex,
|
|
pixels,
|
|
scanlineFilterTypes,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Asserts 100% bit-for-bit identity between original pixels and decoded pixels.
|
|
*/
|
|
function assertPixelParity(original: Uint8Array, decoded: Uint8Array, label: string) {
|
|
expect(decoded.length).toBe(original.length)
|
|
let mismatches = 0
|
|
let firstMismatch = -1
|
|
for (let i = 0; i < original.length; i += 1) {
|
|
if (original[i] !== decoded[i]) {
|
|
mismatches += 1
|
|
if (firstMismatch === -1) firstMismatch = i
|
|
}
|
|
}
|
|
if (mismatches > 0) {
|
|
throw new Error(`${label}: ${mismatches} byte mismatches! First at byte ${firstMismatch} (expected ${original[firstMismatch]}, got ${decoded[firstMismatch]})`)
|
|
}
|
|
}
|
|
|
|
/** PRNG generator for deterministic reproducible high-entropy noise */
|
|
function createPrng(seed: number) {
|
|
let s = seed >>> 0
|
|
return () => {
|
|
s = (s * 1664525 + 1013904223) >>> 0
|
|
return s >>> 24
|
|
}
|
|
}
|
|
|
|
// Generate default 768-byte palette
|
|
const TEST_PALETTE = new Uint8Array(768)
|
|
for (let i = 0; i < 256; i += 1) {
|
|
TEST_PALETTE[i * 3 + 0] = (i * 7) & 0xff
|
|
TEST_PALETTE[i * 3 + 1] = (i * 13) & 0xff
|
|
TEST_PALETTE[i * 3 + 2] = (i * 29) & 0xff
|
|
}
|
|
|
|
describe('PNG Encoder Adversarial Stress Suite', () => {
|
|
describe('Dimension & Stride Alignment Spectrum', () => {
|
|
const testDimensions = [
|
|
[1, 1],
|
|
[1, 2],
|
|
[2, 1],
|
|
[3, 3],
|
|
[5, 7],
|
|
[7, 5],
|
|
[13, 11],
|
|
[15, 17],
|
|
[17, 23],
|
|
[31, 33],
|
|
[135, 77],
|
|
[255, 127],
|
|
[256, 256],
|
|
[512, 384],
|
|
]
|
|
|
|
for (const [w, h] of testDimensions) {
|
|
it(`Indexed PNG round-trips correctly on unaligned dimensions ${w}x${h}`, () => {
|
|
const prng = createPrng(w * 1000 + h)
|
|
const pixels = new Uint8Array(w * h)
|
|
for (let i = 0; i < pixels.length; i += 1) pixels[i] = prng()
|
|
|
|
const encoded = encodeIndexedPng({
|
|
width: w,
|
|
height: h,
|
|
pixels,
|
|
palette: TEST_PALETTE,
|
|
transparentIndex: 0,
|
|
})
|
|
const decoded = decodePng(encoded)
|
|
expect(decoded.width).toBe(w)
|
|
expect(decoded.height).toBe(h)
|
|
expect(decoded.colorType).toBe(3)
|
|
assertPixelParity(pixels, decoded.pixels, `Indexed ${w}x${h}`)
|
|
})
|
|
|
|
it(`RGBA PNG round-trips correctly on unaligned dimensions ${w}x${h}`, () => {
|
|
const prng = createPrng(w * 2000 + h)
|
|
const pixels = new Uint8Array(w * h * 4)
|
|
for (let i = 0; i < pixels.length; i += 1) pixels[i] = prng()
|
|
|
|
const encoded = encodeRgbaPng({
|
|
width: w,
|
|
height: h,
|
|
pixels,
|
|
})
|
|
const decoded = decodePng(encoded)
|
|
expect(decoded.width).toBe(w)
|
|
expect(decoded.height).toBe(h)
|
|
expect(decoded.colorType).toBe(6)
|
|
assertPixelParity(pixels, decoded.pixels, `RGBA ${w}x${h}`)
|
|
})
|
|
}
|
|
})
|
|
|
|
describe('Adversarial Synthetic Patterns', () => {
|
|
const W = 135 // unaligned width
|
|
const H = 77 // unaligned height
|
|
|
|
it('All-zero / transparent rows trigger Fast-Path 1 (Filter 0) with 100% parity', () => {
|
|
// Indexed
|
|
const indexedPixels = new Uint8Array(W * H) // all zeros
|
|
const encIndexed = encodeIndexedPng({ width: W, height: H, pixels: indexedPixels, palette: TEST_PALETTE })
|
|
const decIndexed = decodePng(encIndexed)
|
|
assertPixelParity(indexedPixels, decIndexed.pixels, 'All zeros indexed')
|
|
// All rows should select Filter 0
|
|
expect(decIndexed.scanlineFilterTypes.every((f) => f === 0)).toBe(true)
|
|
|
|
// RGBA
|
|
const rgbaPixels = new Uint8Array(W * H * 4) // all zeros
|
|
const encRgba = encodeRgbaPng({ width: W, height: H, pixels: rgbaPixels })
|
|
const decRgba = decodePng(encRgba)
|
|
assertPixelParity(rgbaPixels, decRgba.pixels, 'All zeros RGBA')
|
|
expect(decRgba.scanlineFilterTypes.every((f) => f === 0)).toBe(true)
|
|
})
|
|
|
|
it('Solid colors & identical rows trigger Fast-Path 2 (Filter 2) with 100% parity', () => {
|
|
// Indexed: row 0 non-zero, rows 1..H-1 identical to row 0
|
|
const indexedPixels = new Uint8Array(W * H)
|
|
for (let x = 0; x < W; x += 1) indexedPixels[x] = (x + 1) % 255
|
|
for (let y = 1; y < H; y += 1) {
|
|
indexedPixels.set(indexedPixels.subarray(0, W), y * W)
|
|
}
|
|
const encIndexed = encodeIndexedPng({ width: W, height: H, pixels: indexedPixels, palette: TEST_PALETTE })
|
|
const decIndexed = decodePng(encIndexed)
|
|
assertPixelParity(indexedPixels, decIndexed.pixels, 'Identical rows indexed')
|
|
// Row 0 is not all zero; rows 1..H-1 must choose Filter 2 (Up)
|
|
expect(decIndexed.scanlineFilterTypes[0]).not.toBe(2)
|
|
for (let y = 1; y < H; y += 1) {
|
|
expect(decIndexed.scanlineFilterTypes[y]).toBe(2)
|
|
}
|
|
|
|
// RGBA: row 0 non-zero, rows 1..H-1 identical
|
|
const rgbaPixels = new Uint8Array(W * H * 4)
|
|
for (let x = 0; x < W * 4; x += 1) rgbaPixels[x] = (x * 37 + 13) & 0xff
|
|
for (let y = 1; y < H; y += 1) {
|
|
rgbaPixels.set(rgbaPixels.subarray(0, W * 4), y * W * 4)
|
|
}
|
|
const encRgba = encodeRgbaPng({ width: W, height: H, pixels: rgbaPixels })
|
|
const decRgba = decodePng(encRgba)
|
|
assertPixelParity(rgbaPixels, decRgba.pixels, 'Identical rows RGBA')
|
|
for (let y = 1; y < H; y += 1) {
|
|
expect(decRgba.scanlineFilterTypes[y]).toBe(2)
|
|
}
|
|
})
|
|
|
|
it('Horizontal gradients trigger Filter 1 (Sub) optimality with 100% parity', () => {
|
|
// Horizontal ramp: each pixel increases by 1
|
|
const indexedPixels = new Uint8Array(W * H)
|
|
for (let y = 0; y < H; y += 1) {
|
|
for (let x = 0; x < W; x += 1) {
|
|
indexedPixels[y * W + x] = x & 0xff
|
|
}
|
|
}
|
|
const encIndexed = encodeIndexedPng({ width: W, height: H, pixels: indexedPixels, palette: TEST_PALETTE })
|
|
const decIndexed = decodePng(encIndexed)
|
|
assertPixelParity(indexedPixels, decIndexed.pixels, 'Horizontal gradient indexed')
|
|
|
|
// Row 0 has constant difference 1, so Filter 1 (Sub) should dominate
|
|
expect(decIndexed.scanlineFilterTypes[0]).toBe(1)
|
|
})
|
|
|
|
it('Vertical patterns trigger Filter 2 (Up) with 100% parity', () => {
|
|
// High horizontal variation (high Filter 1 score), but small vertical delta (low Filter 2 score)
|
|
const indexedPixels = new Uint8Array(W * H)
|
|
for (let y = 0; y < H; y += 1) {
|
|
for (let x = 0; x < W; x += 1) {
|
|
indexedPixels[y * W + x] = ((x * 17) + y) & 0xff
|
|
}
|
|
}
|
|
const encIndexed = encodeIndexedPng({ width: W, height: H, pixels: indexedPixels, palette: TEST_PALETTE })
|
|
const decIndexed = decodePng(encIndexed)
|
|
assertPixelParity(indexedPixels, decIndexed.pixels, 'Vertical gradient indexed')
|
|
|
|
// Rows > 0 have constant difference 1 from previous row, so Filter 2 should dominate
|
|
for (let y = 1; y < H; y += 1) {
|
|
expect(decIndexed.scanlineFilterTypes[y]).toBe(2)
|
|
}
|
|
})
|
|
|
|
it('Checkerboard and sparse noise patterns preserve exact parity', () => {
|
|
// Checkerboard 2x2
|
|
const checkPixels = new Uint8Array(W * H)
|
|
for (let y = 0; y < H; y += 1) {
|
|
for (let x = 0; x < W; x += 1) {
|
|
checkPixels[y * W + x] = (((x >>> 1) ^ (y >>> 1)) & 1) ? 255 : 0
|
|
}
|
|
}
|
|
const encCheck = encodeIndexedPng({ width: W, height: H, pixels: checkPixels, palette: TEST_PALETTE })
|
|
const decCheck = decodePng(encCheck)
|
|
assertPixelParity(checkPixels, decCheck.pixels, 'Checkerboard indexed')
|
|
|
|
// Sparse pattern: 99% zero with single non-zero byte at unaligned boundaries
|
|
const sparsePixels = new Uint8Array(W * H)
|
|
sparsePixels[0] = 42
|
|
sparsePixels[W - 1] = 99 // unaligned end of row 0
|
|
sparsePixels[W * 2 + 3] = 150
|
|
sparsePixels[W * H - 1] = 250 // very last byte
|
|
const encSparse = encodeIndexedPng({ width: W, height: H, pixels: sparsePixels, palette: TEST_PALETTE })
|
|
const decSparse = decodePng(encSparse)
|
|
assertPixelParity(sparsePixels, decSparse.pixels, 'Sparse pattern indexed')
|
|
})
|
|
})
|
|
|
|
describe('Non-Zero ByteOffset Subarray Buffers (Alignment Strees)', () => {
|
|
it('Handles Uint8Array with unaligned byteOffset without throwing RangeError', () => {
|
|
const W = 33
|
|
const H = 19
|
|
// Allocate an unaligned backing buffer (e.g. byteOffset = 3)
|
|
const rawBuffer = new ArrayBuffer(W * H + 16)
|
|
const unalignedPixels = new Uint8Array(rawBuffer, 3, W * H)
|
|
unalignedPixels.fill(0)
|
|
// Set a few non-zeros
|
|
unalignedPixels[10] = 77
|
|
unalignedPixels[W * 3 + 2] = 88
|
|
|
|
// Encoding must NOT throw RangeError due to Uint32Array alignment
|
|
expect(() => {
|
|
const enc = encodeIndexedPng({
|
|
width: W,
|
|
height: H,
|
|
pixels: unalignedPixels,
|
|
palette: TEST_PALETTE,
|
|
})
|
|
const dec = decodePng(enc)
|
|
assertPixelParity(unalignedPixels, dec.pixels, 'Unaligned buffer byteOffset=3')
|
|
}).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('Max Constraint & Game Atlas Scale Stress', () => {
|
|
it('Encodes 2048x1808 game sprite atlas within tight timing bounds and 100% parity', () => {
|
|
const W = 2048
|
|
const H = 1808
|
|
const pixels = new Uint8Array(W * H)
|
|
// Populate with realistic atlas structure: large transparent regions, isolated sprite islands
|
|
for (let y = 100; y < 300; y += 1) {
|
|
for (let x = 100; x < 300; x += 1) {
|
|
pixels[y * W + x] = ((x ^ y) & 0xff) || 1
|
|
}
|
|
}
|
|
for (let y = 600; y < 800; y += 1) {
|
|
for (let x = 500; x < 900; x += 1) {
|
|
pixels[y * W + x] = (x & 0xff) || 2
|
|
}
|
|
}
|
|
|
|
const start = performance.now()
|
|
const encoded = encodeIndexedPng({
|
|
width: W,
|
|
height: H,
|
|
pixels,
|
|
palette: TEST_PALETTE,
|
|
transparentIndex: 0,
|
|
compressionLevel: 6,
|
|
})
|
|
const elapsed = performance.now() - start
|
|
|
|
expect(encoded.length).toBeGreaterThan(1000)
|
|
expect(elapsed).toBeLessThan(1000) // Should be well under 1 second (target ~55ms)
|
|
|
|
const decoded = decodePng(encoded)
|
|
expect(decoded.width).toBe(W)
|
|
expect(decoded.height).toBe(H)
|
|
assertPixelParity(pixels, decoded.pixels, '2048x1808 Game Atlas')
|
|
})
|
|
})
|
|
|
|
describe('Options & Compression Level Sweeps', () => {
|
|
const W = 64
|
|
const H = 64
|
|
const pixels = new Uint8Array(W * H)
|
|
for (let i = 0; i < pixels.length; i += 1) pixels[i] = (i * 31) & 0xff
|
|
|
|
for (const lvl of [0, 1, 3, 6, 9]) {
|
|
it(`Encodes with compressionLevel=${lvl} and preserves bit-for-bit parity`, () => {
|
|
const enc = encodeIndexedPng({
|
|
width: W,
|
|
height: H,
|
|
pixels,
|
|
palette: TEST_PALETTE,
|
|
compressionLevel: lvl,
|
|
})
|
|
const dec = decodePng(enc)
|
|
assertPixelParity(pixels, dec.pixels, `Compression level ${lvl}`)
|
|
})
|
|
}
|
|
|
|
it('Correctly handles palette sizes from 3 bytes to 768 bytes', () => {
|
|
for (const palSize of [3, 6, 48, 255, 768]) {
|
|
const palette = new Uint8Array(palSize)
|
|
for (let i = 0; i < palSize; i += 1) palette[i] = (i * 17) & 0xff
|
|
const enc = encodeIndexedPng({
|
|
width: 8,
|
|
height: 8,
|
|
pixels: new Uint8Array(64),
|
|
palette,
|
|
})
|
|
const dec = decodePng(enc)
|
|
expect(dec.palette?.length).toBe(palSize)
|
|
assertPixelParity(palette, dec.palette!, `Palette size ${palSize}`)
|
|
}
|
|
})
|
|
|
|
it('Correctly handles transparentIndex omitted vs specified', () => {
|
|
// Omitted
|
|
const encNoTrns = encodeIndexedPng({ width: 4, height: 4, pixels: new Uint8Array(16), palette: TEST_PALETTE })
|
|
const decNoTrns = decodePng(encNoTrns)
|
|
expect(decNoTrns.transparentIndex).toBeUndefined()
|
|
|
|
// Specified = 0
|
|
const encTrns0 = encodeIndexedPng({ width: 4, height: 4, pixels: new Uint8Array(16), palette: TEST_PALETTE, transparentIndex: 0 })
|
|
const decTrns0 = decodePng(encTrns0)
|
|
expect(decTrns0.transparentIndex).toBe(0)
|
|
|
|
// Specified = 127
|
|
const encTrns127 = encodeIndexedPng({ width: 4, height: 4, pixels: new Uint8Array(16), palette: TEST_PALETTE, transparentIndex: 127 })
|
|
const decTrns127 = decodePng(encTrns127)
|
|
expect(decTrns127.transparentIndex).toBe(127)
|
|
})
|
|
})
|
|
|
|
describe('Contract Validation & Error Invariants', () => {
|
|
it('Throws when pixel buffer length does not match width * height', () => {
|
|
expect(() => {
|
|
encodeIndexedPng({ width: 10, height: 10, pixels: new Uint8Array(99), palette: TEST_PALETTE })
|
|
}).toThrow('pixel buffer is 99 bytes for 10x10')
|
|
|
|
expect(() => {
|
|
encodeRgbaPng({ width: 10, height: 10, pixels: new Uint8Array(399) })
|
|
}).toThrow('pixel buffer is 399 bytes for 10x10 RGBA')
|
|
})
|
|
|
|
it('Throws on invalid palette length', () => {
|
|
expect(() => {
|
|
encodeIndexedPng({ width: 2, height: 2, pixels: new Uint8Array(4), palette: new Uint8Array(0) })
|
|
}).toThrow(/palette must be 3\.\.768 bytes/)
|
|
|
|
expect(() => {
|
|
encodeIndexedPng({ width: 2, height: 2, pixels: new Uint8Array(4), palette: new Uint8Array(4) }) // not % 3
|
|
}).toThrow(/palette must be 3\.\.768 bytes/)
|
|
|
|
expect(() => {
|
|
encodeIndexedPng({ width: 2, height: 2, pixels: new Uint8Array(4), palette: new Uint8Array(771) }) // > 768
|
|
}).toThrow(/palette must be 3\.\.768 bytes/)
|
|
})
|
|
})
|
|
|
|
describe('External Oracle Parity (ImageMagick identify)', () => {
|
|
it('ImageMagick successfully parses and validates generated Indexed and RGBA PNGs', () => {
|
|
const W = 67
|
|
const H = 43
|
|
const pixels = new Uint8Array(W * H)
|
|
for (let i = 0; i < pixels.length; i += 1) pixels[i] = (i * 23) & 0xff
|
|
|
|
const encIndexed = encodeIndexedPng({ width: W, height: H, pixels, palette: TEST_PALETTE, transparentIndex: 0 })
|
|
const tmpPathIndexed = join(tmpdir(), `test_indexed_${Date.now()}.png`)
|
|
writeFileSync(tmpPathIndexed, encIndexed)
|
|
|
|
try {
|
|
const output = execFileSync('/usr/bin/identify', ['-verbose', tmpPathIndexed], { encoding: 'utf-8' })
|
|
expect(output).toContain('Format: PNG')
|
|
expect(output).toContain('Geometry: 67x43')
|
|
expect(output).toContain('Type: Palette')
|
|
} finally {
|
|
unlinkSync(tmpPathIndexed)
|
|
}
|
|
|
|
const rgbaPixels = new Uint8Array(W * H * 4)
|
|
for (let i = 0; i < rgbaPixels.length; i += 1) rgbaPixels[i] = (i * 19) & 0xff
|
|
const encRgba = encodeRgbaPng({ width: W, height: H, pixels: rgbaPixels })
|
|
const tmpPathRgba = join(tmpdir(), `test_rgba_${Date.now()}.png`)
|
|
writeFileSync(tmpPathRgba, encRgba)
|
|
|
|
try {
|
|
const output = execFileSync('/usr/bin/identify', ['-verbose', tmpPathRgba], { encoding: 'utf-8' })
|
|
expect(output).toContain('Format: PNG')
|
|
expect(output).toContain('Geometry: 67x43')
|
|
} finally {
|
|
unlinkSync(tmpPathRgba)
|
|
}
|
|
})
|
|
})
|
|
})
|