import { describe, expect, it } from 'vitest'; import { encodeBmp, encodePng, toBitmap, type Bitmap } from './encode'; /** Builds RGBA pixels from a picture drawn with '#' for ink and '.' for paper. */ function pixels(rows: string[]): { rgba: Buffer; width: number; height: number } { const height = rows.length; const width = rows[0]?.length ?? 0; const rgba = Buffer.alloc(width * height * 4); rows.forEach((row, y) => { [...row].forEach((cell, x) => { const value = cell === '#' ? 0 : 255; const offset = (y * width + x) * 4; rgba[offset] = value; rgba[offset + 1] = value; rgba[offset + 2] = value; rgba[offset + 3] = 255; }); }); return { rgba, width, height }; } describe('toBitmap', () => { it('packs eight pixels into one byte, most significant bit first', () => { const { rgba, width, height } = pixels(['#.......']); const bitmap = toBitmap(rgba, width, height); // Black at x=0, white elsewhere: 0111 1111. expect(bitmap.bits[0]).toBe(0b0111_1111); }); it('pads the final byte of a row that is not a multiple of eight', () => { const { rgba, width, height } = pixels(['###']); const bitmap = toBitmap(rgba, width, height); expect(bitmap.bits).toHaveLength(1); expect(bitmap.bits[0]).toBe(0); }); it('treats mid-grey either side of the threshold', () => { const rgba = Buffer.from([126, 126, 126, 255, 128, 128, 128, 255]); const bitmap = toBitmap(rgba, 2, 1); // The darker pixel becomes ink, the lighter one paper. expect(bitmap.bits[0]).toBe(0b0100_0000); }); it('treats a transparent pixel as paper, not as ink', () => { const rgba = Buffer.from([0, 0, 0, 0]); expect(toBitmap(rgba, 1, 1).bits[0]).toBe(0b1000_0000); }); it('inverts on request', () => { const { rgba, width, height } = pixels(['#.......']); expect(toBitmap(rgba, width, height, { invert: true }).bits[0]).toBe(0b1000_0000); }); it('honours a custom threshold', () => { const rgba = Buffer.from([200, 200, 200, 255]); expect(toBitmap(rgba, 1, 1, { threshold: 220 }).bits[0]).toBe(0); }); }); describe('encodeBmp', () => { const bitmap: Bitmap = toBitmap(...(() => { const { rgba, width, height } = pixels(['#.......', '.......#']); return [rgba, width, height] as const; })()); it('writes a BMP signature and a 62-byte pixel offset', () => { const bmp = encodeBmp(bitmap); expect(bmp.subarray(0, 2).toString('ascii')).toBe('BM'); // 14-byte file header + 40-byte info header + two palette entries. expect(bmp.readUInt32LE(10)).toBe(62); }); it('declares one bit per pixel and no compression', () => { const bmp = encodeBmp(bitmap); expect(bmp.readUInt32LE(14)).toBe(40); // info header size expect(bmp.readInt32LE(18)).toBe(8); // width expect(bmp.readInt32LE(22)).toBe(2); // height expect(bmp.readUInt16LE(26)).toBe(1); // planes expect(bmp.readUInt16LE(28)).toBe(1); // bits per pixel expect(bmp.readUInt32LE(30)).toBe(0); // BI_RGB expect(bmp.readUInt32LE(46)).toBe(2); // palette entries }); it('pads every row to four bytes', () => { const bmp = encodeBmp(bitmap); // Two rows of one byte each, padded to four: eight bytes of pixel data. expect(bmp.readUInt32LE(34)).toBe(8); expect(bmp.length).toBe(62 + 8); expect(bmp.readUInt32LE(2)).toBe(bmp.length); }); it('stores rows bottom-up, as the format requires', () => { const bmp = encodeBmp(bitmap); // The last source row lands first in the file. expect(bmp[62]).toBe(0b1111_1110); expect(bmp[66]).toBe(0b0111_1111); }); it('pads a 800-pixel row to exactly 100 bytes', () => { const wide = toBitmap(Buffer.alloc(800 * 4, 255), 800, 1); const bmp = encodeBmp(wide); // 800 bits = 100 bytes, already a multiple of four. expect(bmp.readUInt32LE(34)).toBe(100); }); }); describe('encodePng', () => { const bitmap = toBitmap(...(() => { const { rgba, width, height } = pixels(['#.......', '.......#']); return [rgba, width, height] as const; })()); it('writes the PNG signature', () => { const png = encodePng(bitmap); expect([...png.subarray(0, 8)]).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); }); it('declares a one-bit greyscale image of the right size', () => { const png = encodePng(bitmap); expect(png.subarray(12, 16).toString('ascii')).toBe('IHDR'); expect(png.readUInt32BE(16)).toBe(8); // width expect(png.readUInt32BE(20)).toBe(2); // height expect(png.readUInt8(24)).toBe(1); // bit depth expect(png.readUInt8(25)).toBe(0); // greyscale }); it('ends with an IEND chunk', () => { const png = encodePng(bitmap); expect(png.subarray(png.length - 8, png.length - 4).toString('ascii')).toBe('IEND'); }); it('writes a CRC a decoder will accept', () => { // An independent CRC-32 implementation: a decoder rejects the file if ours // is wrong, and a device rejecting the image is expensive to debug. const reference = (data: Buffer): number => { let crc = 0xffffffff; for (const byte of data) { crc ^= byte; for (let bit = 0; bit < 8; bit += 1) { crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; } } return (crc ^ 0xffffffff) >>> 0; }; const png = encodePng(bitmap); const length = png.readUInt32BE(8); // Chunk body is the type plus the data; the CRC follows it. const body = png.subarray(12, 12 + 4 + length); expect(png.readUInt32BE(12 + 4 + length)).toBe(reference(body)); }); });