test/ppm.test.ts (3002 bytes)
1 import fc from 'fast-check'; 2 import { describe, expect, it } from 'vitest'; 3 import { imageToPpm, type RgbaImage } from '../src/ppm'; 4 5 /** Reads a binary PPM file: the header fields and the RGB bytes after the header. */ 6 function decodePpm(ppm: Uint8Array): { width: number; height: number; max: number; rgb: number[] } { 7 // The header is ASCII: "P6", then width, height and maximum value, each after white space, 8 // then one white space character. 9 const head = new TextDecoder('latin1').decode(ppm.subarray(0, 64)); 10 const match = /^P6\s+(\d+)\s+(\d+)\s+(\d+)\s/.exec(head); 11 if (!match) throw new Error(`No PPM header: ${JSON.stringify(head)}`); 12 return { 13 width: Number(match[1]), 14 height: Number(match[2]), 15 max: Number(match[3]), 16 rgb: Array.from(ppm.subarray(match[0].length)), 17 }; 18 } 19 20 /** The RGB bytes of RGBA pixels. */ 21 function rgbOf(data: Uint8ClampedArray): number[] { 22 return Array.from(data).filter((_, i) => i % 4 !== 3); 23 } 24 25 describe('imageToPpm', () => { 26 it('writes the header, then the RGB of each pixel row by row, without the alpha', () => { 27 // 3 x 2 pixels. Each byte holds its own index, so the order shows. 28 const data = new Uint8ClampedArray(Array.from({ length: 24 }, (_, i) => i)); 29 const ppm = imageToPpm({ width: 3, height: 2, data }); 30 const header = 'P6\n3 2\n255\n'; 31 expect(new TextDecoder('latin1').decode(ppm.subarray(0, header.length))).toBe(header); 32 expect(Array.from(ppm.subarray(header.length))).toEqual([ 33 0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 16, 17, 18, 20, 21, 22, 34 ]); 35 }); 36 37 it('has 3 bytes for each pixel of a page canvas of the app, after the header', () => { 38 const width = 1600; 39 const height = 2263; 40 const ppm = imageToPpm({ width, height, data: new Uint8ClampedArray(width * height * 4) }); 41 const header = 'P6\n1600 2263\n255\n'; 42 expect(new TextDecoder('latin1').decode(ppm.subarray(0, header.length))).toBe(header); 43 expect(ppm.length).toBe(header.length + width * height * 3); 44 }); 45 46 it('refuses pixel data of the wrong length', () => { 47 const data = new Uint8ClampedArray(4 * 5); 48 expect(() => imageToPpm({ width: 2, height: 2, data })).toThrow(RangeError); 49 expect(() => imageToPpm({ width: 3, height: 2, data })).toThrow(RangeError); 50 }); 51 52 it('gives back the size and the RGB of each pixel when the file is read', () => { 53 const image: fc.Arbitrary<RgbaImage> = fc 54 .record({ width: fc.integer({ min: 1, max: 12 }), height: fc.integer({ min: 1, max: 12 }) }) 55 .chain(({ width, height }) => 56 fc 57 .uint8Array({ minLength: width * height * 4, maxLength: width * height * 4 }) 58 .map((bytes) => ({ width, height, data: new Uint8ClampedArray(bytes) })), 59 ); 60 fc.assert( 61 fc.property(image, (input) => { 62 expect(decodePpm(imageToPpm(input))).toEqual({ 63 width: input.width, 64 height: input.height, 65 max: 255, 66 rgb: rgbOf(input.data), 67 }); 68 }), 69 ); 70 }); 71 });