Skip to main content
Source Link

  1. "Three may keep a secret, if two of them are dead."

  2. 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>