147 lines
5.4 KiB
JavaScript
147 lines
5.4 KiB
JavaScript
// Winziger Software-Rasterizer + PNG-Encoder (keine Abhaengigkeiten), fuer die Tasten-Icons.
|
|
import { deflateSync } from 'node:zlib';
|
|
|
|
const SUPERSAMPLE = 4;
|
|
|
|
let crcTable = null;
|
|
function crc32(buffer) {
|
|
if (!crcTable) {
|
|
crcTable = new Uint32Array(256);
|
|
for (let n = 0; n < 256; n += 1) {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
crcTable[n] = c >>> 0;
|
|
}
|
|
}
|
|
let crc = 0xffffffff;
|
|
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
return (crc ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function chunk(type, data) {
|
|
const head = Buffer.alloc(8);
|
|
head.writeUInt32BE(data.length, 0);
|
|
head.write(type, 4, 'ascii');
|
|
const tail = Buffer.alloc(4);
|
|
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
|
return Buffer.concat([head, data, tail]);
|
|
}
|
|
|
|
export function encodePng(width, height, rgba) {
|
|
const header = Buffer.alloc(13);
|
|
header.writeUInt32BE(width, 0);
|
|
header.writeUInt32BE(height, 4);
|
|
header[8] = 8; // Bit-Tiefe
|
|
header[9] = 6; // RGBA
|
|
const stride = width * 4 + 1;
|
|
const rows = Buffer.alloc(stride * height);
|
|
for (let y = 0; y < height; y += 1) {
|
|
Buffer.from(rgba.buffer, rgba.byteOffset + y * width * 4, width * 4).copy(rows, y * stride + 1);
|
|
}
|
|
return Buffer.concat([
|
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
chunk('IHDR', header),
|
|
chunk('IDAT', deflateSync(rows, { level: 9 })),
|
|
chunk('IEND', Buffer.alloc(0)),
|
|
]);
|
|
}
|
|
|
|
export function parseColor(hex) {
|
|
const value = hex.replace('#', '');
|
|
return [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16));
|
|
}
|
|
|
|
/** Leinwand mit Kantenglaettung; Formen werden per Punkt-in-Form-Test mit Supersampling gefuellt. */
|
|
export class Canvas {
|
|
constructor(size) {
|
|
this.size = size;
|
|
this.data = new Uint8ClampedArray(size * size * 4);
|
|
}
|
|
|
|
/** contains(x, y) -> boolean in Pixel-Koordinaten; bounds = [x0, y0, x1, y1] begrenzt die Suche. */
|
|
fill(contains, color, bounds = [0, 0, this.size, this.size]) {
|
|
// color: "#rrggbb", [r, g, b] oder Funktion (x, y) => [r, g, b] (Farbverlaeufe)
|
|
const colorAt = typeof color === 'function' ? color
|
|
: ((fixed) => () => fixed)(typeof color === 'string' ? parseColor(color) : color);
|
|
const clamp = (v) => Math.max(0, Math.min(this.size, v));
|
|
const x0 = clamp(Math.floor(bounds[0]));
|
|
const y0 = clamp(Math.floor(bounds[1]));
|
|
const x1 = clamp(Math.ceil(bounds[2]));
|
|
const y1 = clamp(Math.ceil(bounds[3]));
|
|
const total = SUPERSAMPLE * SUPERSAMPLE;
|
|
for (let y = y0; y < y1; y += 1) {
|
|
for (let x = x0; x < x1; x += 1) {
|
|
let hits = 0;
|
|
for (let sy = 0; sy < SUPERSAMPLE; sy += 1) {
|
|
for (let sx = 0; sx < SUPERSAMPLE; sx += 1) {
|
|
if (contains(x + (sx + 0.5) / SUPERSAMPLE, y + (sy + 0.5) / SUPERSAMPLE)) hits += 1;
|
|
}
|
|
}
|
|
if (hits) this.blend((y * this.size + x) * 4, colorAt(x + 0.5, y + 0.5), hits / total);
|
|
}
|
|
}
|
|
}
|
|
|
|
blend(index, [r, g, b], alpha) {
|
|
const dstA = this.data[index + 3] / 255;
|
|
const outA = alpha + dstA * (1 - alpha);
|
|
[r, g, b].forEach((value, offset) => {
|
|
this.data[index + offset] = (value * alpha + this.data[index + offset] * dstA * (1 - alpha)) / outA;
|
|
});
|
|
this.data[index + 3] = outA * 255;
|
|
}
|
|
|
|
roundRect(x, y, w, h, radius, color) {
|
|
this.fill((px, py) => {
|
|
const cx = Math.max(x + radius, Math.min(x + w - radius, px));
|
|
const cy = Math.max(y + radius, Math.min(y + h - radius, py));
|
|
return px >= x && px <= x + w && py >= y && py <= y + h && (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2;
|
|
}, color, [x, y, x + w, y + h]);
|
|
}
|
|
|
|
circle(cx, cy, radius, color) {
|
|
this.fill((px, py) => (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2, color,
|
|
[cx - radius, cy - radius, cx + radius, cy + radius]);
|
|
}
|
|
|
|
/** Kreisring bzw. Bogen (Winkel in Radiant, 0 = rechts, im Uhrzeigersinn). */
|
|
ring(cx, cy, outer, inner, color, from = 0, to = Math.PI * 2) {
|
|
this.fill((px, py) => {
|
|
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
|
if (d > outer ** 2 || d < inner ** 2) return false;
|
|
let angle = Math.atan2(py - cy, px - cx);
|
|
if (angle < 0) angle += Math.PI * 2;
|
|
return angle >= from && angle <= to;
|
|
}, color, [cx - outer, cy - outer, cx + outer, cy + outer]);
|
|
}
|
|
|
|
polygon(points, color) {
|
|
const xs = points.map((p) => p[0]);
|
|
const ys = points.map((p) => p[1]);
|
|
this.fill((px, py) => {
|
|
let inside = false;
|
|
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
|
const [xi, yi] = points[i];
|
|
const [xj, yj] = points[j];
|
|
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
|
|
}
|
|
return inside;
|
|
}, color, [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]);
|
|
}
|
|
|
|
/** Strecke mit Breite `width` und runden Enden. */
|
|
line(x1, y1, x2, y2, width, color) {
|
|
const half = width / 2;
|
|
const dx = x2 - x1;
|
|
const dy = y2 - y1;
|
|
this.fill((px, py) => {
|
|
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy || 1)));
|
|
return (px - (x1 + t * dx)) ** 2 + (py - (y1 + t * dy)) ** 2 <= half * half;
|
|
}, color, [Math.min(x1, x2) - half, Math.min(y1, y2) - half, Math.max(x1, x2) + half, Math.max(y1, y2) + half]);
|
|
}
|
|
|
|
png() {
|
|
return encodePng(this.size, this.size, this.data);
|
|
}
|
|
}
|