"Three may keep a secret, if two of them are dead."
A key and a kite
I already completed the tasks in bash but was surprised to see no JavaScript submissions.
Now I know why...
There is no easy way to read the PNG image-data unaltered in JavaScript, and since we are looking at single bits small errors get big quick. I was super confused and when googling I didn't find anyone else with this issue. Even ChatGPT told me I was wrong and that I had a bug somewhere in my code (though it couldn't point out where (since it wasn't in my code)). Lastly I found a github repo made by someone with the same issue as me, but instead of copying it I decided to read the PNG specification and learn something new :)
Code:
png_reader.js
function getUncompressedPNG(dataBuffer) {
let UintArr = new Uint8ClampedArray(dataBuffer)
// Check if PNG signature exists
if (JSON.stringify(Array(...UintArr.slice(0, 8))) != '[137,80,78,71,13,10,26,10]') {
console.error("PNG header is not present, malformed or wrong data");
return;
}
UintArr = UintArr.slice(8); // Remove header bytes
// Parse chunks
let width, height, chunkLength, chunkType, chunkData, CRC, longBin = '', compressedData = [];
while (true) {
chunkLength = parseInt(Array(...UintArr.slice(0, 4)).map(x => x.toString(16).padStart(2, "0")).join(""), 16);
chunkType = Array(...UintArr.slice(4, 8)).map(b => String.fromCharCode(b)).join("");
chunkData = UintArr.slice(8, 8 + chunkLength);
// I Don't use CRC in my code, just added this so people know why I slice on 12+length
CRC = UintArr.slice(8 + chunkLength, 12 + chunkLength);
UintArr = UintArr.slice(12 + chunkLength);
// I only implemented the chunks present in the task images
switch (chunkType) {
case "IHDR":
width = parseInt(Array(...chunkData.slice(0, 4)).map(x => x.toString(16).padStart(2, "0")).join(""), 16)
height = parseInt(Array(...chunkData.slice(4, 8)).map(x => x.toString(16).padStart(2, "0")).join(""), 16)
break;
case "IDAT":
compressedData.push(...chunkData);
break;
case "IEND":
return {
height,
width,
data: pako.inflate(compressedData),
}
}
}
}
let p, pa, pb, pc;
function paethPredictor(a, b, c) {
p = a + b - c;
pa = Math.abs(p - a);
pb = Math.abs(p - b);
pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) {
return a;
}
if (pb <= pc) {
return b;
}
return c;
}
function pngReader(dataBuffer) {
const { width, height, data } = getUncompressedPNG(dataBuffer);
const scanLines = [];
// Loop through each filtered scanline
let filteredLine, tempLine;
for (let i = 0; i < height; i++) {
filteredLine = Array(...data.slice(1 + i + (i * width * 3), (i+1) * width * 3 + 1 + i));
switch(data[i + (i * width * 3)]) { // First byte in line determines filtertype
case 0: // None
scanLines.push(filteredLine);
break;
case 1: // Sub
tempLine = [filteredLine[0], filteredLine[1], filteredLine[2]];
for (let j = 3; j < width * 3; j++) {
tempLine.push((filteredLine[j] + tempLine[j - 3]) % 256);
}
scanLines.push(tempLine);
break;
case 2: // Up
tempLine = [];
for (let j = 0; j < width * 3; j++) {
tempLine.push((filteredLine[j] + scanLines[i - 1][j]) % 256);
}
scanLines.push(tempLine);
break;
case 3: // Avg
console.error("Filtertype 3 'average' not implemented:");
break;
case 4: // Paeth
tempLine = [];
let raw;
for (let j = 0; j < width * 3; j++) {
if (j - 3 >= 0 && i >= 1) {
raw = filteredLine[j] + paethPredictor(tempLine[j - 3], scanLines[i - 1][j], scanLines[i - 1][j - 3]);
} else if (j - 3 >= 0) {
raw = filteredLine[j] + paethPredictor(tempLine[j - 3], 0, 0);
} else if (i >= 1) {
raw = filteredLine[j] + paethPredictor(0, scanLines[i - 1][j], 0);
} else {
raw = filteredLine[j];
}
tempLine.push(raw % 256)
}
scanLines.push(tempLine);
break;
default:
console.error("Unknown filter type:", data[i + (i * width * 3)])
}
}
return new Uint8Array(scanLines.flat())
}
index.html
<!-- zlib inflate from https://raw.githubusercontent.com/nodeca/pako/refs/heads/master/dist/pako_inflate.min.js -->
<script src="pako_inflate.min.js"></script>
<script src="png_reader.js"></script>
<script defer>
function extractText(d, header) {
const headerIndex = d.findIndex((value, index, arr) => {return (arr[index] & 1) == header[0] && (arr[index + 1] & 1) == header[1]});
let data = d.slice(headerIndex + header.length);
let outputString = '';
const contentLength = parseInt(data.slice(0, 16).map(x => (x & 1)).join(""), 2)
const longBin = data.slice(16, contentLength + 16).map(x => x & 1).join("")
longBin.match(/.{1,8}/g).map(x => parseInt(x, 2)).forEach(charCode => {
outputString += String.fromCharCode(charCode)
});
return outputString;
}
function printImage(ctx, d, header) {
const headerIndex = d.findIndex((value, index, arr) => {return (arr[index] & 1) == header[0] && (arr[index + 1] & 1) == header[1]});
let data = d.slice(headerIndex + header.length);
const width = parseInt(data.slice(0, 16).map(x => (x & 1)).join(""), 2);
const height = parseInt(data.slice(16, 32).map(x => x & 1).join(""), 2);
ctx.canvas.width = width;
ctx.canvas.height = height;
ctx.fillStyle = "rgb(30, 30, 30)";
data = data.slice(32);
for (let i = 0; i < width * height; i++) {
if ((data[i] & 1) != 1) {
ctx.fillRect(i % width, Math.floor(i / height), 1, 1);
}
}
}
function openFile(event) {
const file = event.target.files[0];
const reader = new FileReader();
reader.onload = function(e) {
const isImage = document.querySelector('input#is-image-checkbox').checked;
if (!isImage) {
document.querySelector('pre#text-output').textContent = extractText(pngReader(e.target.result), [0, 0]);
} else {
const canvas = document.querySelector("canvas#image-output");
const ctx = canvas.getContext("2d");
printImage(ctx, pngReader(e.target.result), [1, 0])
}
}
reader.readAsArrayBuffer(file);
}
</script>
<body>
<label for="is-image">decode image:</label>
<input type="checkbox" name="is-image" id="is-image-checkbox">
<br>
<input type='file' accept='image/*' onchange='openFile(event)'><br>
<pre id="text-output"></pre>
<canvas id="image-output"></canvas>
</body>