Answer

JavaScript

const fs = require('fs');
const zlib = require('zlib');

// Based on the PNG file format specification: https://www.w3.org/TR/PNG/
class PNG {
    buffer = null;
    width = 0;
    height = 0;
    bitDepth = 0;
    colorType = 0;
    compressionMethod = 0;
    filterMethod = 0;
    interlaceMethod = 0;

    chunks = [];
    data = [];

    constructor(imageBuffer) {
        if (!imageBuffer || imageBuffer.length < 8) {
            throw new Error('Invalid PNG file');
        }

        this.buffer = imageBuffer;
        this.parse();
    }

    parseIHDR() {
        const ihdr = this.chunks.find(chunk => chunk.type === 'IHDR');
        if (!ihdr) {
            throw new Error('IHDR chunk not found, This is not a valid PNG file');
        }

        this.width = ihdr.data.readUInt32BE(0);
        this.height = ihdr.data.readUInt32BE(4);
        this.bitDepth = ihdr.data.readUInt8(8);
        this.colorType = ihdr.data.readUInt8(9);
        this.compressionMethod = ihdr.data.readUInt8(10);
        this.filterMethod = ihdr.data.readUInt8(11);
        this.interlaceMethod = ihdr.data.readUInt8(12);
    }

    parseHeader() {
        const header = this.buffer.slice(0, 8);

        if (header.toString('hex') !== '89504e470d0a1a0a') {
            throw new Error('Invalid PNG signature');
        }
    }

    chunker() {
        let i = 8; // Skip the header
        while (i < this.buffer.length) {
            const length = this.buffer.readUInt32BE(i);
            const type = this.buffer.slice(i + 4, i + 8).toString('ascii');
            const data = this.buffer.slice(i + 8, i + 8 + length);
            const crc = this.buffer.readUInt32BE(i + 8 + length);
            this.chunks.push({ length, type, data, crc });
            i += 12 + length;
        }
    }

    // Start to parse the image data chuck!
    parseIDAT() {
        // As image data can be split into multiple IDAT chunks
        // we need to concatenate them all
        const idatChunks = this.chunks.filter(chunk => chunk.type === 'IDAT');
        const idatData = Buffer.concat(idatChunks.map(chunk => chunk.data));

        if (!idatData) {
            throw new Error('IDAT chunk not found, This is not a valid PNG file');
        }

        if (this.colorType !== 6) {
            throw new Error('Only RGBA color type is supported currently.');
        }

        const decompressedData = zlib.inflateSync(idatData);

        const data = [];
        let i = 0;
        for (let y = 0; y < this.height; y++) {
            const filterType = decompressedData.readUInt8(i);
            i++;
            for (let x = 0; x < this.width; x++) {
                let red = decompressedData.readUInt8(i);
                let green = decompressedData.readUInt8(i + 1);
                let blue = decompressedData.readUInt8(i + 2);
                let alpha = decompressedData.readUInt8(i + 3);

                if (x !== 0 || y !== 0) {
                    let left = x !== 0 ? data[data.length - 1] : { red: 0, green: 0, blue: 0, alpha: 0 };
                    let above = y !== 0 ? data[data.length - this.width] : { red: 0, green: 0, blue: 0, alpha: 0 };
                    let aboveLeft = x !== 0 && y !== 0 ? data[data.length - this.width - 1] : { red: 0, green: 0, blue: 0, alpha: 0 };

                    if (filterType === 4) {
                        console.log(filterType);
                    }
                    switch (filterType) {
                        case 0: // None
                            break;
                        case 1: // Sub
                            red += left.red;
                            green += left.green;
                            blue += left.blue;
                            alpha += left.alpha;
                            break;
                        case 2: // Up
                            red += above.red;
                            green += above.green;
                            blue += above.blue;
                            alpha += above.alpha;
                            break;
                        case 3: // Average
                            red += Math.floor((left.red + above.red) / 2);
                            green += Math.floor((left.green + above.green) / 2);
                            blue += Math.floor((left.blue + above.blue) / 2);
                            alpha += Math.floor((left.alpha + above.alpha) / 2);
                            break;
                        case 4: // Paeth
                            red += this.paethPredictor(left.red, above.red, aboveLeft.red);
                            green += this.paethPredictor(left.green, above.green, aboveLeft.green);
                            blue += this.paethPredictor(left.blue, above.blue, aboveLeft.blue);
                            alpha += this.paethPredictor(left.alpha, above.alpha, aboveLeft.alpha);
                            break;
                    }
                }

                data.push({ red: red & 0xFF, green: green & 0xFF, blue: blue & 0xFF, alpha: alpha & 0xFF });
                i += 4;
            }
        }

        this.data = data;
    }

    paethPredictor(a, b, c) {
        const p = a + b - c;
        const pa = Math.abs(p - a);
        const pb = Math.abs(p - b);
        const pc = Math.abs(p - c);

        if (pa <= pb && pa <= pc) return a;
        else if (pb <= pc) return b;
        else return c;
    }

    parse() {
        this.parseHeader();
        this.chunker();
        this.parseIHDR();
        console.log(this.width, this.height, this.bitDepth, this.colorType, this.compressionMethod, this.filterMethod, this.interlaceMethod);

        if (this.interlaceMethod !== 0) {
            throw new Error('Interlaced images are not yet supported');
        }

        this.parseIDAT();

        return this.data;
    }

    // given a width and height, resize the image using nearest neighbor interpolation
    resize(width, height) {
        let resizedData = [];
        let xRatio = this.width / width;
        let yRatio = this.height / height;

        for (let y = 0; y < height; y++) {
            for (let x = 0; x < width; x++) {
                let pixel = this.data[Math.floor(y * yRatio) * this.width + Math.floor(x * xRatio)];
                resizedData.push(pixel);
            }
        }

        this.width = width;
        this.height = height;
        this.data = resizedData;
    }
}

function createSizedPNGType(imagePath, maxWidth = 30, maxHeight = 20) {
    const imageBuffer = fs.readFileSync(imagePath);
    const png = new PNG(imageBuffer);

    if (png.height > maxHeight) {
        const ratio = maxHeight / png.height;
        png.resize(Math.floor(png.width * ratio), maxHeight);
    }

    if (png.width > maxWidth) {
        const ratio = maxWidth / png.width;
        png.resize(maxWidth, Math.floor(png.height * ratio));
    }

    return png;
}

function printPNGImagePathAsAscii(imagePath) {
    const png = createSizedPNGType(imagePath);
    const { data, width, height } = png;
    const chars = ['.', '+', '*', '#', '@'];
    
    let ascii = '';
    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            const pixel = data[y * width + x];

            // represent it as a range from 0 to chars.length
            // i got this formula from https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color
            const luminance = ((0.299 * pixel.red + 0.587 * pixel.green + 0.114 * pixel.blue) / 255) * chars.length; 
    

            if (pixel.alpha === 0) {
                ascii += ' ';
            } else {
                ascii += chars[Math.floor(luminance)];
            }
        }
        ascii += '\n';
    }
    console.log(ascii);
}

#printPNGImagePathAsAscii('image.png');