New Captcha Package added
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
export const BITMAP_FONT: Record<string, readonly string[]> = {
|
||||
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
|
||||
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
|
||||
"2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
|
||||
"3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
|
||||
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
|
||||
"5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
|
||||
"6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
|
||||
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
|
||||
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
|
||||
"9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
|
||||
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
|
||||
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
|
||||
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
|
||||
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
||||
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
|
||||
G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"],
|
||||
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||
I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
|
||||
J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
|
||||
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
|
||||
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
|
||||
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
|
||||
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
|
||||
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
|
||||
Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
|
||||
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
|
||||
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
|
||||
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
|
||||
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
|
||||
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
|
||||
W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
|
||||
X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
|
||||
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
|
||||
Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
|
||||
"+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"],
|
||||
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
|
||||
"*": ["00000", "10001", "01010", "00100", "01010", "10001", "00000"],
|
||||
"/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"],
|
||||
"=": ["00000", "11111", "00000", "11111", "00000", "00000", "00000"],
|
||||
"?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"],
|
||||
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaGeneratorContext,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { renderTextChallenge } from "./visual.ts";
|
||||
|
||||
const OPERATOR_WORDS: Record<string, string[]> = {
|
||||
"+": ["plus"],
|
||||
"-": ["minus"],
|
||||
"*": ["times"],
|
||||
"/": ["divided", "by"],
|
||||
};
|
||||
|
||||
function numberTokens(value: number): string[] {
|
||||
const text = String(Math.abs(value));
|
||||
return value < 0 ? ["minus", ...text] : [...text];
|
||||
}
|
||||
|
||||
export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type = "calculation" as const;
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const max = context.difficulty === "easy" ? 9 : context.difficulty === "hard" ? 30 : 15;
|
||||
const operations = context.difficulty === "easy" ? ["+", "-"] : ["+", "-", "*", "/"];
|
||||
const operator = operations[context.randomInt(0, operations.length - 1)]!;
|
||||
let left = context.randomInt(2, max);
|
||||
let right = context.randomInt(1, max);
|
||||
let answer: number;
|
||||
|
||||
if (operator === "+") answer = left + right;
|
||||
else if (operator === "-") {
|
||||
if (context.difficulty !== "hard" && right > left) [left, right] = [right, left];
|
||||
answer = left - right;
|
||||
} else if (operator === "*") {
|
||||
right = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
left = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
answer = left * right;
|
||||
} else {
|
||||
right = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
answer = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
|
||||
left = right * answer;
|
||||
}
|
||||
|
||||
const expression = `${left} ${operator} ${right} = ?`;
|
||||
return {
|
||||
type: "calculation",
|
||||
presentation: "visual",
|
||||
prompt: "Solve the calculation",
|
||||
answer: String(answer),
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(expression, context),
|
||||
inputMode: "numeric",
|
||||
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
|
||||
metadata: { operator, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const calculationCaptchaGenerator = new CalculationCaptchaGenerator();
|
||||
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaGeneratorContext,
|
||||
CaptchaImageItem,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
|
||||
|
||||
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
|
||||
type Shape = (typeof SHAPES)[number];
|
||||
const COLORS = [
|
||||
[37, 99, 235, 255],
|
||||
[22, 163, 74, 255],
|
||||
[220, 38, 38, 255],
|
||||
[147, 51, 234, 255],
|
||||
[234, 88, 12, 255],
|
||||
] as const;
|
||||
|
||||
function starPoints(cx: number, cy: number, outer: number, inner: number): Array<[number, number]> {
|
||||
const points: Array<[number, number]> = [];
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const radius = index % 2 === 0 ? outer : inner;
|
||||
const angle = -Math.PI / 2 + (index * Math.PI) / 5;
|
||||
points.push([cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function disturbanceRatio(context: CaptchaGeneratorContext): number {
|
||||
return Math.max(0, Math.min(1, (context.disturbance - 25) / 50));
|
||||
}
|
||||
|
||||
function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
|
||||
const image = createImage(96, 72, [248, 250, 252, 255]);
|
||||
const color = COLORS[context.randomInt(0, COLORS.length - 1)]!;
|
||||
const cx = 48 + context.randomInt(-5, 5);
|
||||
const cy = 36 + context.randomInt(-4, 4);
|
||||
const size = context.randomInt(19, 25);
|
||||
if (shape === "circle") fillCircle(image, cx, cy, size, color);
|
||||
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
|
||||
else if (shape === "triangle") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
|
||||
} else if (shape === "diamond") {
|
||||
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
|
||||
} else {
|
||||
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
|
||||
}
|
||||
|
||||
const ratio = disturbanceRatio(context);
|
||||
const dots = Math.round(35 + ratio * 150);
|
||||
for (let index = 0; index < dots; index++) {
|
||||
setPixel(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
|
||||
);
|
||||
}
|
||||
|
||||
const lines = Math.round(1 + ratio * 4);
|
||||
for (let index = 0; index < lines; index++) {
|
||||
drawLine(
|
||||
image,
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
context.randomInt(0, 95),
|
||||
context.randomInt(0, 71),
|
||||
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
|
||||
ratio > 0.75 ? 2 : 1,
|
||||
);
|
||||
}
|
||||
return pngDataUri(image);
|
||||
}
|
||||
|
||||
function shuffle<T>(items: T[], context: CaptchaGeneratorContext): T[] {
|
||||
for (let index = items.length - 1; index > 0; index--) {
|
||||
const other = context.randomInt(0, index);
|
||||
[items[index], items[other]] = [items[other]!, items[index]!];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type = "image" as const;
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const target = SHAPES[context.randomInt(0, SHAPES.length - 1)]!;
|
||||
const count = context.difficulty === "easy" ? 6 : 9;
|
||||
const matches = context.difficulty === "hard" ? 2 : 3;
|
||||
const entries: Array<{ id: string; shape: Shape; item: CaptchaImageItem }> = [];
|
||||
|
||||
for (let index = 0; index < count; index++) {
|
||||
let shape: Shape;
|
||||
if (index < matches) shape = target;
|
||||
else {
|
||||
do shape = SHAPES[context.randomInt(0, SHAPES.length - 1)]!;
|
||||
while (shape === target);
|
||||
}
|
||||
const id = context.randomId(9);
|
||||
entries.push({
|
||||
id,
|
||||
shape,
|
||||
item: { id, image: shapeImage(shape, context), alt: `Challenge tile ${index + 1}` },
|
||||
});
|
||||
}
|
||||
|
||||
shuffle(entries, context);
|
||||
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
|
||||
return {
|
||||
type: "image",
|
||||
presentation: "visual",
|
||||
prompt: `Select every ${target}`,
|
||||
answer,
|
||||
answerKind: "selections",
|
||||
items: entries.map((entry) => entry.item),
|
||||
minSelections: matches,
|
||||
maxSelections: matches,
|
||||
inputMode: "none",
|
||||
metadata: {
|
||||
target,
|
||||
count,
|
||||
matches,
|
||||
disturbance: context.disturbance,
|
||||
accessibleAlternative: "Request an audio challenge",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const imageCaptchaGenerator = new ImageCaptchaGenerator();
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { CaptchaChallengeGenerator } from "../types.ts";
|
||||
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
|
||||
import { calculationCaptchaGenerator } from "./calculation.ts";
|
||||
import { imageCaptchaGenerator } from "./image.ts";
|
||||
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
|
||||
|
||||
export * from "./text.ts";
|
||||
export * from "./calculation.ts";
|
||||
export * from "./image.ts";
|
||||
export * from "./invisible.ts";
|
||||
export * from "./png.ts";
|
||||
export * from "./styles.ts";
|
||||
|
||||
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
|
||||
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
|
||||
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
|
||||
return generator;
|
||||
}
|
||||
|
||||
export function defaultCaptchaGenerators(): CaptchaChallengeGenerator[] {
|
||||
return [
|
||||
numberCaptchaGenerator,
|
||||
alphaCaptchaGenerator,
|
||||
alphanumericCaptchaGenerator,
|
||||
calculationCaptchaGenerator,
|
||||
imageCaptchaGenerator,
|
||||
honeypotCaptchaGenerator,
|
||||
timingCaptchaGenerator,
|
||||
notRobotCaptchaGenerator,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaGeneratorContext,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
|
||||
type InvisibleCaptchaType = "honeypot" | "timing" | "not-robot";
|
||||
|
||||
export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type: InvisibleCaptchaType;
|
||||
|
||||
constructor(type: InvisibleCaptchaType) {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const honeypotField = `website_${context.randomId(5)}`;
|
||||
const timingToken = context.randomId(18);
|
||||
return {
|
||||
type: this.type,
|
||||
presentation: "invisible",
|
||||
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
|
||||
answer: JSON.stringify({ honeypot: "", timingToken }),
|
||||
answerKind: "invisible",
|
||||
inputMode: "none",
|
||||
metadata: {
|
||||
honeypotField,
|
||||
timingToken,
|
||||
minCompletionMs: context.minCompletionMs,
|
||||
interaction: this.type === "not-robot" ? "checkbox" : "automatic",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const honeypotCaptchaGenerator = new InvisibleCaptchaGenerator("honeypot");
|
||||
export const timingCaptchaGenerator = new InvisibleCaptchaGenerator("timing");
|
||||
export const notRobotCaptchaGenerator = new InvisibleCaptchaGenerator("not-robot");
|
||||
@@ -0,0 +1,240 @@
|
||||
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))}`;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaImageStyle,
|
||||
} from "../types.ts";
|
||||
|
||||
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
|
||||
"classic",
|
||||
"collision",
|
||||
"snow",
|
||||
"corrosion",
|
||||
"spiderweb",
|
||||
"cross-shadow",
|
||||
"split",
|
||||
"split2",
|
||||
"cut",
|
||||
"darts",
|
||||
"distortion",
|
||||
"stitch",
|
||||
"striped",
|
||||
"wave",
|
||||
"grid-noise",
|
||||
"scribble",
|
||||
"pixel",
|
||||
"broken-lines",
|
||||
] as const satisfies readonly CaptchaConcreteImageStyle[];
|
||||
|
||||
export const CAPTCHA_IMAGE_STYLES = [
|
||||
"random",
|
||||
...CAPTCHA_CONCRETE_IMAGE_STYLES,
|
||||
] as const satisfies readonly CaptchaImageStyle[];
|
||||
|
||||
const CONCRETE_STYLE_SET = new Set<string>(CAPTCHA_CONCRETE_IMAGE_STYLES);
|
||||
const STYLE_SET = new Set<string>(CAPTCHA_IMAGE_STYLES);
|
||||
|
||||
function styleValues(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.flatMap((item) => styleValues(item));
|
||||
if (typeof value !== "string") return [];
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isCaptchaImageStyle(value: unknown): value is CaptchaImageStyle {
|
||||
return typeof value === "string" && STYLE_SET.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export function normalizeCaptchaImageStyle(
|
||||
value: unknown,
|
||||
fallback: CaptchaImageStyle = "random",
|
||||
): CaptchaImageStyle {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
if (!STYLE_SET.has(normalized)) {
|
||||
throw new RangeError(
|
||||
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return normalized as CaptchaImageStyle;
|
||||
}
|
||||
|
||||
export function normalizeCaptchaImageStyleList(
|
||||
value: unknown,
|
||||
name: "allowedStyles" | "excludedStyles",
|
||||
): CaptchaConcreteImageStyle[] {
|
||||
const styles: CaptchaConcreteImageStyle[] = [];
|
||||
for (const entry of styleValues(value)) {
|
||||
if (entry === "random") continue;
|
||||
if (!CONCRETE_STYLE_SET.has(entry)) {
|
||||
throw new RangeError(
|
||||
`${name} contains an unknown image style: ${entry}`,
|
||||
);
|
||||
}
|
||||
const style = entry as CaptchaConcreteImageStyle;
|
||||
if (!styles.includes(style)) styles.push(style);
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
export interface ResolveCaptchaImageStyleOptions {
|
||||
imageStyle?: unknown;
|
||||
allowedStyles?: unknown;
|
||||
excludedStyles?: unknown;
|
||||
randomizeStyle?: boolean;
|
||||
randomInt(min: number, max: number): number;
|
||||
}
|
||||
|
||||
export interface ResolvedCaptchaImageStyle {
|
||||
requested: CaptchaImageStyle;
|
||||
resolved: CaptchaConcreteImageStyle;
|
||||
pool: CaptchaConcreteImageStyle[];
|
||||
}
|
||||
|
||||
export function resolveCaptchaImageStyle(
|
||||
options: ResolveCaptchaImageStyleOptions,
|
||||
): ResolvedCaptchaImageStyle {
|
||||
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
|
||||
const allowed = normalizeCaptchaImageStyleList(
|
||||
options.allowedStyles,
|
||||
"allowedStyles",
|
||||
);
|
||||
const excluded = new Set(
|
||||
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
|
||||
);
|
||||
|
||||
const source = allowed.length
|
||||
? allowed
|
||||
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
|
||||
const pool = source.filter((style) => !excluded.has(style));
|
||||
|
||||
if (!pool.length) {
|
||||
throw new RangeError(
|
||||
"No CAPTCHA image styles remain after applying allowedStyles and excludedStyles",
|
||||
);
|
||||
}
|
||||
|
||||
if (!options.randomizeStyle && requested !== "random") {
|
||||
if (!pool.includes(requested)) {
|
||||
throw new RangeError(
|
||||
`imageStyle ${requested} is not available in the configured style pool`,
|
||||
);
|
||||
}
|
||||
return { requested, resolved: requested, pool };
|
||||
}
|
||||
|
||||
return {
|
||||
requested,
|
||||
resolved: pool[options.randomInt(0, pool.length - 1)]!,
|
||||
pool,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type {
|
||||
CaptchaChallengeGenerator,
|
||||
CaptchaChallengeType,
|
||||
CaptchaGeneratorContext,
|
||||
GeneratedCaptchaChallenge,
|
||||
} from "../types.ts";
|
||||
import { renderTextChallenge } from "./visual.ts";
|
||||
|
||||
const NUMBERS = "23456789";
|
||||
const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
|
||||
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
|
||||
|
||||
function defaultLength(context: CaptchaGeneratorContext): number {
|
||||
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
|
||||
}
|
||||
|
||||
function charset(type: CaptchaChallengeType): string {
|
||||
if (type === "number") return NUMBERS;
|
||||
if (type === "alpha") return ALPHA;
|
||||
return ALPHANUMERIC;
|
||||
}
|
||||
|
||||
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
const source = charset(type);
|
||||
const length = Math.max(3, Math.min(10, defaultLength(context)));
|
||||
let answer = "";
|
||||
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
|
||||
return {
|
||||
type,
|
||||
presentation: "visual",
|
||||
prompt: type === "number" ? "Enter the numbers shown" : "Enter the characters shown",
|
||||
answer,
|
||||
answerKind: "text",
|
||||
image: renderTextChallenge(answer, context),
|
||||
inputMode: type === "number" ? "numeric" : "text",
|
||||
audioSequence: [...answer.toLowerCase()],
|
||||
metadata: { length, excludedAmbiguousCharacters: true, imageStyle: context.imageStyle },
|
||||
};
|
||||
}
|
||||
|
||||
export class TextCaptchaGenerator implements CaptchaChallengeGenerator {
|
||||
readonly type: "number" | "alpha" | "alphanumeric";
|
||||
|
||||
constructor(type: "number" | "alpha" | "alphanumeric") {
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
|
||||
return generateText(this.type, context);
|
||||
}
|
||||
}
|
||||
|
||||
export const numberCaptchaGenerator = new TextCaptchaGenerator("number");
|
||||
export const alphaCaptchaGenerator = new TextCaptchaGenerator("alpha");
|
||||
export const alphanumericCaptchaGenerator = new TextCaptchaGenerator("alphanumeric");
|
||||
@@ -0,0 +1,690 @@
|
||||
import type {
|
||||
CaptchaConcreteImageStyle,
|
||||
CaptchaGeneratorContext,
|
||||
} from "../types.ts";
|
||||
import {
|
||||
createImage,
|
||||
drawGlyph,
|
||||
drawLine,
|
||||
fillCircle,
|
||||
fillRect,
|
||||
pngDataUri,
|
||||
setPixel,
|
||||
type Rgba,
|
||||
type RgbaImage,
|
||||
} from "./png.ts";
|
||||
|
||||
const LIGHT_BACKGROUND: Rgba = [248, 250, 252, 255];
|
||||
const ALT_BACKGROUND: Rgba = [241, 245, 249, 255];
|
||||
const INK: Rgba = [15, 23, 42, 255];
|
||||
const ACCENTS: readonly Rgba[] = [
|
||||
[30, 64, 175, 235],
|
||||
[126, 34, 206, 235],
|
||||
[190, 24, 93, 230],
|
||||
[15, 118, 110, 230],
|
||||
[194, 65, 12, 230],
|
||||
];
|
||||
|
||||
interface TextLayout {
|
||||
scale: number;
|
||||
spacing: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
textWidth: number;
|
||||
textHeight: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
|
||||
function disturbanceRatio(context: CaptchaGeneratorContext): number {
|
||||
return clamp((context.disturbance - 25) / 50, 0, 1);
|
||||
}
|
||||
|
||||
function randomColor(
|
||||
context: CaptchaGeneratorContext,
|
||||
minimum = 70,
|
||||
maximum = 205,
|
||||
alpha = 100,
|
||||
): Rgba {
|
||||
return [
|
||||
context.randomInt(minimum, maximum),
|
||||
context.randomInt(minimum, maximum),
|
||||
context.randomInt(minimum, maximum),
|
||||
alpha,
|
||||
];
|
||||
}
|
||||
|
||||
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
|
||||
const px = clamp(Math.round(x), 0, image.width - 1);
|
||||
const py = clamp(Math.round(y), 0, image.height - 1);
|
||||
const index = (py * image.width + px) * 4;
|
||||
return [
|
||||
image.data[index]!,
|
||||
image.data[index + 1]!,
|
||||
image.data[index + 2]!,
|
||||
image.data[index + 3]!,
|
||||
];
|
||||
}
|
||||
|
||||
function putRawPixel(
|
||||
image: RgbaImage,
|
||||
x: number,
|
||||
y: number,
|
||||
color: readonly [number, number, number, number],
|
||||
): 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;
|
||||
image.data[index] = color[0];
|
||||
image.data[index + 1] = color[1];
|
||||
image.data[index + 2] = color[2];
|
||||
image.data[index + 3] = color[3];
|
||||
}
|
||||
|
||||
function cloneImage(image: RgbaImage): RgbaImage {
|
||||
return { width: image.width, height: image.height, data: image.data.slice() };
|
||||
}
|
||||
|
||||
function replaceImage(target: RgbaImage, source: RgbaImage): void {
|
||||
target.data.set(source.data);
|
||||
}
|
||||
|
||||
function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
|
||||
const width = 300;
|
||||
const height = 104;
|
||||
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
|
||||
const glyphWidth = scale * 5;
|
||||
const normalSpacing = scale + 3;
|
||||
const spacing = style === "collision"
|
||||
? Math.max(-Math.floor(scale * 0.45), -2)
|
||||
: style === "cross-shadow"
|
||||
? scale
|
||||
: normalSpacing;
|
||||
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
|
||||
const textHeight = scale * 7;
|
||||
return {
|
||||
scale,
|
||||
spacing,
|
||||
startX: Math.max(12, Math.floor((width - textWidth) / 2)),
|
||||
startY: Math.floor((height - textHeight) / 2),
|
||||
textWidth,
|
||||
textHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function drawCircleOutline(
|
||||
image: RgbaImage,
|
||||
centerX: number,
|
||||
centerY: number,
|
||||
radius: number,
|
||||
color: Rgba,
|
||||
thickness = 1,
|
||||
): void {
|
||||
const steps = Math.max(24, Math.round(radius * 8));
|
||||
for (let index = 0; index < steps; index++) {
|
||||
const angle = (index / steps) * Math.PI * 2;
|
||||
const x = centerX + Math.cos(angle) * radius;
|
||||
const y = centerY + Math.sin(angle) * radius;
|
||||
fillCircle(image, x, y, Math.max(0.75, thickness / 2), color);
|
||||
}
|
||||
}
|
||||
|
||||
function drawDashedLine(
|
||||
image: RgbaImage,
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
color: Rgba,
|
||||
dash = 5,
|
||||
gap = 4,
|
||||
thickness = 1,
|
||||
): void {
|
||||
const distance = Math.hypot(x1 - x0, y1 - y0);
|
||||
if (distance <= 0) return;
|
||||
const dx = (x1 - x0) / distance;
|
||||
const dy = (y1 - y0) / distance;
|
||||
for (let offset = 0; offset < distance; offset += dash + gap) {
|
||||
const end = Math.min(distance, offset + dash);
|
||||
drawLine(
|
||||
image,
|
||||
x0 + dx * offset,
|
||||
y0 + dy * offset,
|
||||
x0 + dx * end,
|
||||
y0 + dy * end,
|
||||
color,
|
||||
thickness,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function addDots(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
count: number,
|
||||
alpha: number,
|
||||
minimum = 75,
|
||||
maximum = 220,
|
||||
radius = 0,
|
||||
): void {
|
||||
for (let index = 0; index < count; index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
const color = randomColor(context, minimum, maximum, alpha);
|
||||
if (radius > 0) fillCircle(image, x, y, context.randomInt(1, radius), color);
|
||||
else setPixel(image, x, y, color);
|
||||
}
|
||||
}
|
||||
|
||||
function addCrossingLines(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
count: number,
|
||||
alpha: number,
|
||||
thickness = 1,
|
||||
): void {
|
||||
for (let index = 0; index < count; index++) {
|
||||
drawLine(
|
||||
image,
|
||||
context.randomInt(0, image.width - 1),
|
||||
context.randomInt(0, image.height - 1),
|
||||
context.randomInt(0, image.width - 1),
|
||||
context.randomInt(0, image.height - 1),
|
||||
randomColor(context, 65, 195, alpha),
|
||||
thickness,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawCharacters(
|
||||
image: RgbaImage,
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
style: CaptchaConcreteImageStyle,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const jitterX = Math.round(1 + ratio * 3);
|
||||
const jitterY = Math.round(2 + ratio * 4);
|
||||
const shear = 0.12 + ratio * 0.42;
|
||||
let cursor = layout.startX;
|
||||
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const character = text[index]!;
|
||||
const x = cursor + context.randomInt(-jitterX, jitterX);
|
||||
const y = layout.startY + context.randomInt(-jitterY, jitterY);
|
||||
const glyphShear = (context.randomFloat() - 0.5) * shear;
|
||||
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
|
||||
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
|
||||
? accent
|
||||
: INK;
|
||||
|
||||
if (style === "cross-shadow") {
|
||||
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
|
||||
drawGlyph(image, character, x + 3, y - 2, layout.scale, [220, 38, 38, 85], glyphShear);
|
||||
} else if (style === "collision") {
|
||||
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
|
||||
if (index > 0 && context.randomFloat() < 0.65) {
|
||||
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
|
||||
}
|
||||
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
|
||||
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
|
||||
}
|
||||
|
||||
drawGlyph(image, character, x, y, layout.scale, color, glyphShear);
|
||||
if (style === "pixel" && context.randomFloat() < 0.45) {
|
||||
drawGlyph(image, character, x + 1, y, layout.scale, [15, 23, 42, 90], glyphShear);
|
||||
}
|
||||
|
||||
cursor += layout.scale * 5 + layout.spacing;
|
||||
}
|
||||
}
|
||||
|
||||
function shiftRows(
|
||||
image: RgbaImage,
|
||||
shiftForY: (y: number) => number,
|
||||
background: Rgba = LIGHT_BACKGROUND,
|
||||
): void {
|
||||
const source = cloneImage(image);
|
||||
const output = createImage(image.width, image.height, background);
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
const shift = Math.round(shiftForY(y));
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
const sourceX = x - shift;
|
||||
if (sourceX >= 0 && sourceX < image.width) {
|
||||
putRawPixel(output, x, y, rawPixel(source, sourceX, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceImage(image, output);
|
||||
}
|
||||
|
||||
function shiftColumns(
|
||||
image: RgbaImage,
|
||||
shiftForX: (x: number) => number,
|
||||
background: Rgba = LIGHT_BACKGROUND,
|
||||
): void {
|
||||
const source = cloneImage(image);
|
||||
const output = createImage(image.width, image.height, background);
|
||||
for (let x = 0; x < image.width; x++) {
|
||||
const shift = Math.round(shiftForX(x));
|
||||
for (let y = 0; y < image.height; y++) {
|
||||
const sourceY = y - shift;
|
||||
if (sourceY >= 0 && sourceY < image.height) {
|
||||
putRawPixel(output, x, y, rawPixel(source, x, sourceY));
|
||||
}
|
||||
}
|
||||
}
|
||||
replaceImage(image, output);
|
||||
}
|
||||
|
||||
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
|
||||
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
|
||||
}
|
||||
|
||||
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 320), 70);
|
||||
const bars = Math.round(2 + ratio * 5);
|
||||
for (let index = 0; index < bars; index++) {
|
||||
fillRect(
|
||||
image,
|
||||
context.randomInt(0, image.width - 35),
|
||||
context.randomInt(18, image.height - 20),
|
||||
context.randomInt(18, 50),
|
||||
context.randomInt(1, ratio > 0.65 ? 3 : 2),
|
||||
randomColor(context, 35, 160, 75),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
|
||||
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x - 2, y, x + 2, y, [255, 255, 255, 185], 1);
|
||||
drawLine(image, x, y - 2, x, y + 2, [255, 255, 255, 185], 1);
|
||||
}
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCorrosion(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const holes = Math.round(70 + ratio * 230);
|
||||
for (let index = 0; index < holes; index++) {
|
||||
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
|
||||
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
|
||||
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
|
||||
}
|
||||
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
|
||||
}
|
||||
|
||||
function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const nodes = Array.from({ length: Math.round(8 + ratio * 12) }, () => ({
|
||||
x: context.randomInt(0, image.width - 1),
|
||||
y: context.randomInt(0, image.height - 1),
|
||||
}));
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const current = nodes[index]!;
|
||||
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
|
||||
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
|
||||
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
|
||||
}
|
||||
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
|
||||
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
|
||||
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
|
||||
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
|
||||
drawLine(
|
||||
image,
|
||||
anchorX,
|
||||
anchorY,
|
||||
anchorX + Math.cos(angle) * image.width,
|
||||
anchorY + Math.sin(angle) * image.height,
|
||||
[71, 85, 105, 65],
|
||||
1,
|
||||
);
|
||||
}
|
||||
addDots(image, context, Math.round(80 + ratio * 200), 60);
|
||||
}
|
||||
|
||||
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const centerY = Math.floor(image.height / 2);
|
||||
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
|
||||
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
|
||||
addDots(image, context, Math.round(120 + ratio * 300), 60);
|
||||
}
|
||||
|
||||
function applySplit(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const bandHeight = context.randomInt(7, 13);
|
||||
const amplitude = Math.round(4 + ratio * 13);
|
||||
shiftRows(image, (y) => {
|
||||
const band = Math.floor(y / bandHeight);
|
||||
return band % 2 === 0 ? amplitude : -amplitude;
|
||||
});
|
||||
for (let y = bandHeight; y < image.height; y += bandHeight) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [100, 116, 139, 75], 1);
|
||||
}
|
||||
addDots(image, context, Math.round(90 + ratio * 250), 65);
|
||||
}
|
||||
|
||||
function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const stripWidth = context.randomInt(10, 18);
|
||||
const amplitude = Math.round(3 + ratio * 10);
|
||||
shiftColumns(image, (x) => {
|
||||
const strip = Math.floor(x / stripWidth);
|
||||
return strip % 2 === 0 ? amplitude : -amplitude;
|
||||
});
|
||||
for (let x = stripWidth; x < image.width; x += stripWidth) {
|
||||
drawLine(image, x, 0, x, image.height - 1, [100, 116, 139, 65], 1);
|
||||
}
|
||||
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
|
||||
}
|
||||
|
||||
function applyCut(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cuts = Math.round(4 + ratio * 8);
|
||||
for (let index = 0; index < cuts; index++) {
|
||||
const x0 = context.randomInt(layout.startX - 10, layout.startX + layout.textWidth);
|
||||
const y0 = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
|
||||
const x1 = x0 + context.randomInt(24, 72);
|
||||
const y1 = y0 + context.randomInt(-18, 18);
|
||||
drawLine(image, x0, y0, x1, y1, [248, 250, 252, 245], ratio > 0.6 ? 3 : 2);
|
||||
drawLine(image, x0, y0 + 2, x1, y1 + 2, randomColor(context, 75, 170, 80), 1);
|
||||
}
|
||||
addDots(image, context, Math.round(90 + ratio * 260), 70);
|
||||
}
|
||||
|
||||
function applyDarts(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const targets = Math.round(2 + ratio * 3);
|
||||
for (let index = 0; index < targets; index++) {
|
||||
const x = context.randomInt(25, image.width - 25);
|
||||
const y = context.randomInt(16, image.height - 16);
|
||||
const maxRadius = context.randomInt(9, Math.round(14 + ratio * 11));
|
||||
for (let radius = maxRadius; radius >= 4; radius -= 5) {
|
||||
drawCircleOutline(image, x, y, radius, randomColor(context, 55, 175, 70), 1);
|
||||
}
|
||||
for (let ray = 0; ray < Math.round(3 + ratio * 4); ray++) {
|
||||
const angle = context.randomFloat() * Math.PI * 2;
|
||||
drawLine(
|
||||
image,
|
||||
x,
|
||||
y,
|
||||
x + Math.cos(angle) * context.randomInt(25, 70),
|
||||
y + Math.sin(angle) * context.randomInt(18, 55),
|
||||
randomColor(context, 55, 175, 70),
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
addDots(image, context, Math.round(100 + ratio * 240), 60);
|
||||
}
|
||||
|
||||
function applyDistortion(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const amplitudeX = 3 + ratio * 9;
|
||||
const amplitudeY = 2 + ratio * 5;
|
||||
const phase = context.randomFloat() * Math.PI * 2;
|
||||
shiftRows(image, (y) => Math.sin(y / (7 + ratio * 4) + phase) * amplitudeX);
|
||||
shiftColumns(image, (x) => Math.sin(x / (20 - ratio * 6) + phase) * amplitudeY);
|
||||
addCrossingLines(image, context, Math.round(3 + ratio * 5), 60, ratio > 0.7 ? 2 : 1);
|
||||
addDots(image, context, Math.round(100 + ratio * 280), 60);
|
||||
}
|
||||
|
||||
function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const rows = Math.round(4 + ratio * 5);
|
||||
for (let index = 0; index < rows; index++) {
|
||||
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
|
||||
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
|
||||
}
|
||||
const columns = Math.round(2 + ratio * 4);
|
||||
for (let index = 0; index < columns; index++) {
|
||||
const x = context.randomInt(10, image.width - 10);
|
||||
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
|
||||
}
|
||||
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
|
||||
const x = context.randomInt(0, image.width - 1);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x - 2, y - 2, x + 2, y + 2, [100, 116, 139, 65], 1);
|
||||
drawLine(image, x + 2, y - 2, x - 2, y + 2, [100, 116, 139, 65], 1);
|
||||
}
|
||||
}
|
||||
|
||||
function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const spacing = Math.round(10 - ratio * 4);
|
||||
const offset = context.randomInt(-image.height, image.width);
|
||||
for (let x = offset; x < image.width + image.height; x += spacing) {
|
||||
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
|
||||
}
|
||||
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 55);
|
||||
}
|
||||
|
||||
function applyWave(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const phase = context.randomFloat() * Math.PI * 2;
|
||||
const amplitude = 5 + ratio * 13;
|
||||
shiftRows(image, (y) => Math.sin(y / (5.5 + ratio * 3) + phase) * amplitude);
|
||||
shiftColumns(image, (x) => Math.cos(x / (24 - ratio * 7) + phase) * (2 + ratio * 6));
|
||||
for (let index = 0; index < Math.round(2 + ratio * 3); index++) {
|
||||
const baseY = context.randomInt(10, image.height - 10);
|
||||
let previousX = 0;
|
||||
let previousY = baseY;
|
||||
for (let x = 8; x < image.width; x += 8) {
|
||||
const y = baseY + Math.sin(x / 17 + phase + index) * (5 + ratio * 5);
|
||||
drawLine(image, previousX, previousY, x, y, randomColor(context, 75, 180, 55), 1);
|
||||
previousX = x;
|
||||
previousY = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyGridNoise(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const cell = Math.round(16 - ratio * 6);
|
||||
for (let x = 0; x < image.width; x += cell) {
|
||||
drawLine(image, x, 0, x, image.height - 1, [100, 116, 139, 55], 1);
|
||||
}
|
||||
for (let y = 0; y < image.height; y += cell) {
|
||||
drawLine(image, 0, y, image.width - 1, y, [100, 116, 139, 55], 1);
|
||||
}
|
||||
for (let index = 0; index < Math.round(10 + ratio * 26); index++) {
|
||||
const x = context.randomInt(0, Math.floor(image.width / cell)) * cell;
|
||||
const y = context.randomInt(0, Math.floor(image.height / cell)) * cell;
|
||||
fillRect(image, x, y, cell, cell, randomColor(context, 100, 220, Math.round(25 + ratio * 35)));
|
||||
}
|
||||
}
|
||||
|
||||
function applyScribble(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const scribbles = Math.round(3 + ratio * 6);
|
||||
for (let index = 0; index < scribbles; index++) {
|
||||
let x = context.randomInt(0, image.width - 1);
|
||||
let y = context.randomInt(0, image.height - 1);
|
||||
const segments = context.randomInt(7, Math.round(12 + ratio * 13));
|
||||
const color = randomColor(context, 45, 180, Math.round(55 + ratio * 45));
|
||||
for (let segment = 0; segment < segments; segment++) {
|
||||
const nextX = clamp(x + context.randomInt(-28, 28), 0, image.width - 1);
|
||||
const nextY = clamp(y + context.randomInt(-18, 18), 0, image.height - 1);
|
||||
drawLine(image, x, y, nextX, nextY, color, ratio > 0.7 && segment % 3 === 0 ? 2 : 1);
|
||||
x = nextX;
|
||||
y = nextY;
|
||||
}
|
||||
}
|
||||
addDots(image, context, Math.round(80 + ratio * 220), 55);
|
||||
}
|
||||
|
||||
function applyPixel(image: RgbaImage, context: CaptchaGeneratorContext): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const blocks = Math.round(28 + ratio * 75);
|
||||
for (let index = 0; index < blocks; index++) {
|
||||
const size = context.randomInt(2, Math.round(4 + ratio * 5));
|
||||
fillRect(
|
||||
image,
|
||||
context.randomInt(0, image.width - size),
|
||||
context.randomInt(0, image.height - size),
|
||||
size,
|
||||
size,
|
||||
randomColor(context, 65, 220, Math.round(45 + ratio * 45)),
|
||||
);
|
||||
}
|
||||
const source = cloneImage(image);
|
||||
for (let index = 0; index < Math.round(4 + ratio * 8); index++) {
|
||||
const blockWidth = context.randomInt(12, 28);
|
||||
const blockHeight = context.randomInt(6, 16);
|
||||
const x = context.randomInt(0, image.width - blockWidth);
|
||||
const y = context.randomInt(0, image.height - blockHeight);
|
||||
const shift = context.randomInt(-8, 8);
|
||||
for (let py = 0; py < blockHeight; py++) {
|
||||
for (let px = 0; px < blockWidth; px++) {
|
||||
const sourceX = clamp(x + px, 0, image.width - 1);
|
||||
const targetX = x + px + shift;
|
||||
putRawPixel(image, targetX, y + py, rawPixel(source, sourceX, y + py));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyBrokenLines(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
const ratio = disturbanceRatio(context);
|
||||
const gaps = Math.round(12 + ratio * 35);
|
||||
for (let index = 0; index < gaps; index++) {
|
||||
const horizontal = context.randomFloat() < 0.7;
|
||||
const width = horizontal ? context.randomInt(6, 24) : context.randomInt(1, 4);
|
||||
const height = horizontal ? context.randomInt(1, 3) : context.randomInt(7, 18);
|
||||
fillRect(
|
||||
image,
|
||||
context.randomInt(layout.startX - 3, layout.startX + layout.textWidth),
|
||||
context.randomInt(layout.startY - 3, layout.startY + layout.textHeight),
|
||||
width,
|
||||
height,
|
||||
LIGHT_BACKGROUND,
|
||||
);
|
||||
}
|
||||
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
|
||||
const x = context.randomInt(0, image.width - 12);
|
||||
const y = context.randomInt(0, image.height - 1);
|
||||
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
|
||||
}
|
||||
addDots(image, context, Math.round(70 + ratio * 180), 60);
|
||||
}
|
||||
|
||||
function applyStyle(
|
||||
image: RgbaImage,
|
||||
context: CaptchaGeneratorContext,
|
||||
layout: TextLayout,
|
||||
): void {
|
||||
switch (context.imageStyle) {
|
||||
case "collision":
|
||||
applyCollision(image, context);
|
||||
break;
|
||||
case "snow":
|
||||
applySnow(image, context);
|
||||
break;
|
||||
case "corrosion":
|
||||
applyCorrosion(image, context, layout);
|
||||
break;
|
||||
case "spiderweb":
|
||||
applySpiderweb(image, context);
|
||||
break;
|
||||
case "cross-shadow":
|
||||
applyCrossShadow(image, context);
|
||||
break;
|
||||
case "split":
|
||||
applySplit(image, context);
|
||||
break;
|
||||
case "split2":
|
||||
applySplit2(image, context);
|
||||
break;
|
||||
case "cut":
|
||||
applyCut(image, context, layout);
|
||||
break;
|
||||
case "darts":
|
||||
applyDarts(image, context);
|
||||
break;
|
||||
case "distortion":
|
||||
applyDistortion(image, context);
|
||||
break;
|
||||
case "stitch":
|
||||
applyStitch(image, context);
|
||||
break;
|
||||
case "striped":
|
||||
applyStriped(image, context);
|
||||
break;
|
||||
case "wave":
|
||||
applyWave(image, context);
|
||||
break;
|
||||
case "grid-noise":
|
||||
applyGridNoise(image, context);
|
||||
break;
|
||||
case "scribble":
|
||||
applyScribble(image, context);
|
||||
break;
|
||||
case "pixel":
|
||||
applyPixel(image, context);
|
||||
break;
|
||||
case "broken-lines":
|
||||
applyBrokenLines(image, context, layout);
|
||||
break;
|
||||
default:
|
||||
applyClassic(image, context);
|
||||
}
|
||||
}
|
||||
|
||||
function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
|
||||
if (style === "snow" || style === "grid-noise" || style === "stitch") return ALT_BACKGROUND;
|
||||
return LIGHT_BACKGROUND;
|
||||
}
|
||||
|
||||
export function renderTextChallenge(
|
||||
text: string,
|
||||
context: CaptchaGeneratorContext,
|
||||
): string {
|
||||
const layout = layoutFor(text, context.imageStyle);
|
||||
const image = createImage(300, 104, backgroundFor(context.imageStyle));
|
||||
|
||||
if (context.imageStyle === "grid-noise") applyGridNoise(image, context);
|
||||
else if (context.imageStyle === "snow") addDots(image, context, 130, 45, 160, 235, 1);
|
||||
else addDots(image, context, Math.round(35 + disturbanceRatio(context) * 80), 30);
|
||||
|
||||
drawCharacters(image, text, context, layout, context.imageStyle);
|
||||
applyStyle(image, context, layout);
|
||||
|
||||
if (context.imageStyle !== "snow" && context.imageStyle !== "grid-noise") {
|
||||
const ratio = disturbanceRatio(context);
|
||||
addDots(image, context, Math.round(25 + ratio * 100), 35, 100, 220);
|
||||
}
|
||||
|
||||
return pngDataUri(image);
|
||||
}
|
||||
Reference in New Issue
Block a user