import { BITMAP_FONT } from "./bitmap.ts"; export interface RgbaImage { width: number; height: number; data: Uint8Array; } export type Rgba = readonly [number, number, number, number?]; export function createImage( width: number, height: number, background: Rgba = [255, 255, 255, 255], ): RgbaImage { const data = new Uint8Array(width * height * 4); const alpha = background[3] ?? 255; for (let index = 0; index < data.length; index += 4) { data[index] = background[0]; data[index + 1] = background[1]; data[index + 2] = background[2]; data[index + 3] = alpha; } return { width, height, data }; } export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): void { const px = Math.round(x); const py = Math.round(y); if (px < 0 || py < 0 || px >= image.width || py >= image.height) return; const index = (py * image.width + px) * 4; const alpha = (color[3] ?? 255) / 255; const inverse = 1 - alpha; image.data[index] = Math.round(color[0] * alpha + image.data[index] * inverse); image.data[index + 1] = Math.round(color[1] * alpha + image.data[index + 1] * inverse); image.data[index + 2] = Math.round(color[2] * alpha + image.data[index + 2] * inverse); image.data[index + 3] = 255; } export function fillRect( image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba, ): void { for (let py = Math.floor(y); py < Math.ceil(y + height); py++) { for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color); } } export function drawLine( image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1, ): void { let x = Math.round(x0); let y = Math.round(y0); const targetX = Math.round(x1); const targetY = Math.round(y1); const dx = Math.abs(targetX - x); const dy = -Math.abs(targetY - y); const sx = x < targetX ? 1 : -1; const sy = y < targetY ? 1 : -1; let error = dx + dy; while (true) { fillRect( image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color, ); if (x === targetX && y === targetY) break; const twice = 2 * error; if (twice >= dy) { error += dy; x += sx; } if (twice <= dx) { error += dx; y += sy; } } } export function fillCircle( image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba, ): void { const r2 = radius * radius; for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) { for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) { const dx = x - centerX; const dy = y - centerY; if (dx * dx + dy * dy <= r2) setPixel(image, x, y, color); } } } function pointInPolygon( x: number, y: number, points: readonly (readonly [number, number])[], ): boolean { let inside = false; for (let i = 0, j = points.length - 1; i < points.length; j = i++) { const xi = points[i]![0]; const yi = points[i]![1]; const xj = points[j]![0]; const yj = points[j]![1]; const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi || 1) + xi; if (intersects) inside = !inside; } return inside; } export function fillPolygon( image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba, ): void { const minX = Math.floor(Math.min(...points.map((point) => point[0]))); const maxX = Math.ceil(Math.max(...points.map((point) => point[0]))); const minY = Math.floor(Math.min(...points.map((point) => point[1]))); const maxY = Math.ceil(Math.max(...points.map((point) => point[1]))); for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color); } } export function drawGlyph( image: RgbaImage, character: string, x: number, y: number, scale: number, color: Rgba, shear = 0, ): void { const glyph = BITMAP_FONT[character.toUpperCase()] ?? BITMAP_FONT["?"]!; for (let row = 0; row < glyph.length; row++) { const line = glyph[row]!; for (let column = 0; column < line.length; column++) { if (line[column] !== "1") continue; const offset = Math.round((glyph.length - row) * shear); fillRect(image, x + column * scale + offset, y + row * scale, scale, scale, color); } } } export function drawText( image: RgbaImage, text: string, options: { x: number; y: number; scale: number; color: Rgba; spacing?: number; jitter?: (index: number) => { x: number; y: number; shear: number }; }, ): void { const spacing = options.spacing ?? options.scale * 2; let cursor = options.x; for (let index = 0; index < text.length; index++) { const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 }; drawGlyph( image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear, ); cursor += options.scale * 5 + spacing; } } const CRC32_TABLE = (() => { const table = new Uint32Array(256); for (let index = 0; index < table.length; index++) { let value = index; for (let bit = 0; bit < 8; bit++) { value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0); } table[index] = value >>> 0; } return table; })(); function crc32(bytes: Uint8Array): number { let crc = 0xffffffff; for (const byte of bytes) { crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 255]!; } return (crc ^ 0xffffffff) >>> 0; } function adler32(bytes: Uint8Array): number { let a = 1; let b = 0; for (const byte of bytes) { a = (a + byte) % 65521; b = (b + a) % 65521; } return ((b << 16) | a) >>> 0; } function u32(value: number): Uint8Array { return Uint8Array.of( (value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255, ); } function concat(parts: readonly Uint8Array[]): Uint8Array { const length = parts.reduce((sum, part) => sum + part.length, 0); const out = new Uint8Array(length); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.length; } return out; } function chunk(type: string, data: Uint8Array): Uint8Array { const typeBytes = new TextEncoder().encode(type); return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]); } function deflateStored(data: Uint8Array): Uint8Array { const blocks: Uint8Array[] = [Uint8Array.of(0x78, 0x01)]; for (let offset = 0; offset < data.length; offset += 65535) { const size = Math.min(65535, data.length - offset); const final = offset + size >= data.length; const length = size; const inverse = ~length & 0xffff; blocks.push( Uint8Array.of( final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255, ), data.slice(offset, offset + size), ); } blocks.push(u32(adler32(data))); return concat(blocks); } export function encodePng(image: RgbaImage): Uint8Array { const stride = image.width * 4; const scanlines = new Uint8Array((stride + 1) * image.height); for (let y = 0; y < image.height; y++) { const target = y * (stride + 1); scanlines[target] = 0; scanlines.set(image.data.slice(y * stride, (y + 1) * stride), target + 1); } const header = new Uint8Array(13); header.set(u32(image.width), 0); header.set(u32(image.height), 4); header[8] = 8; header[9] = 6; return concat([ Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10), chunk("IHDR", header), chunk("IDAT", deflateStored(scanlines)), chunk("IEND", new Uint8Array()), ]); } export function bytesToBase64(bytes: Uint8Array): string { let binary = ""; const size = 0x8000; for (let offset = 0; offset < bytes.length; offset += size) { binary += String.fromCharCode(...bytes.subarray(offset, Math.min(bytes.length, offset + size))); } return btoa(binary); } export function pngDataUri(image: RgbaImage): string { return `data:image/png;base64,${bytesToBase64(encodePng(image))}`; }