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