Files
WRNexusJS/packages/captcha/src/challenges/png.ts
T
2026-07-27 12:42:18 +05:30

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))}`;
}