301 lines
8.1 KiB
TypeScript
301 lines
8.1 KiB
TypeScript
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))}`;
|
|
}
|