UNPKG

captcha-svg-generator

Version:

A lightweight, customizable Node.js library to generate SVG CAPTCHA images with random characters, noise lines, and custom fonts using opentype.js. Ideal for Express.js, authentication, and bot protection.

249 lines (210 loc) 9.64 kB
import opentype from "opentype.js"; import { randomInt } from "./lib/random.js"; import { randomColor } from "./lib/colors.js"; import { buildCharPreset } from "./lib/presets.js"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; /** * CaptchaGenerator * * Generates customizable SVG CAPTCHAs using OpenType fonts. * Supports noise, background colors, random rotations (messy mode), * and pluggable storage for verification. */ export class CaptchaGenerator { /** * @typedef {Object} CaptchaOptions * @property {number} [size=6] Number of characters in the captcha. * @property {string} [ignoreChars=""] Characters to exclude. * @property {number} [noise=2] Number of noise lines. * @property {string} [background="#ffffff"] Background color. * @property {number} [width=150] Image width in px. * @property {number} [height=50] Image height in px. * @property {number} [fontSize=36] Font size in px. * @property {string} [presetType="all"] Character set preset. * @property {string[]} [fontFiles] Array of font file paths (.ttf/.otf). * @property {string} [fontColour=random] Captcha text color. * @property {string} [noiseColour=random] Noise line color. * @property {number} [noiseWidth=2] Noise stroke width. * @property {boolean} [messy=true] If true, rotate chars randomly. */ /** * Creates a new CaptchaGenerator. * * @param {CaptchaOptions} [options={}] Options for captcha. * @param {string[]} [fontsFamily=[]] Array of custom font paths. */ constructor(options = {}, fontsFamily = []) { this.__filename = fileURLToPath(import.meta.url); this.__dirname = dirname(this.__filename); this.defaults = { size: 6, ignoreChars: "", noise: 2, background: "#ffffff", width: 150, height: 50, fontSize: 36, presetType: "all", fontFiles: fontsFamily.length > 0 ? fontsFamily : [join(this.__dirname, "../fonts/LiberationSerif-wlR9.ttf")], fontColour: randomColor(), noiseColour: randomColor(), noiseWidth: 2, messy: true, }; this.options = { ...this.defaults, ...options }; // Final character pool this.chars = buildCharPreset(this.options.presetType) .split("") .filter((ch) => !this.options.ignoreChars.includes(ch)); this.captchaKey = null; this.captchaText = null; } /** * Generate a random unique captcha key. * @returns {string} Unique key string (hex). */ static generateKey(length = 16) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let key = ""; const now = Date.now().toString(36); // timestamp as base36 for (let i = 0; i < length; i++) { key += chars[randomInt(0, chars.length)]; } return key + now; } /** * Generate a new captcha. * * @async * @returns {Promise<{ key: string, text: string, data: string }>} * - `key`: Unique captcha key * - `text`: Captcha solution string * - `data`: SVG markup * * @example * const gen = new CaptchaGenerator(); * const { key, text, data } = await gen.generate(); */ async generate() { const text = Array.from({ length: this.options.size }, () => this.chars[randomInt(0, this.chars.length)] ).join(""); const loadedFonts = await Promise.all( this.options.fontFiles.map((file) => opentype.load(file)) ); let textSVG = ""; const y = this.options.height / 2 + this.options.fontSize / 3; if (!this.options.messy) { const font = loadedFonts[0]; const glyphs = text.split("").map((ch) => font.charToGlyph(ch)); // Compute total width of all glyphs at desired font size const naturalWidth = glyphs.reduce( (sum, g) => sum + g.advanceWidth * (this.options.fontSize / font.unitsPerEm), 0 ); const gap = 3; const totalWidth = naturalWidth + gap * (text.length - 1); // Scale to fit in box if needed const scale = Math.min(1, (this.options.width - 10) / totalWidth); // Center starting x let x = (this.options.width - totalWidth * scale) / 2; for (const glyph of glyphs) { const advanceWidth = glyph.advanceWidth * (this.options.fontSize / font.unitsPerEm) * scale; const path = glyph.getPath(x, y, this.options.fontSize * scale); const d = path.toPathData(2); textSVG += `<path d="${d}" fill="${this.options.fontColour}"/>`; x += advanceWidth + gap * scale; } } else { // Messy: random rotations // Step 1: calculate approximate natural width const fontSizes = this.options.fontSize; const chars = text.split(""); // <-- Fix here // Step 1: calculate approximate natural width const glyphWidths = chars.map((ch) => { const font = loadedFonts[randomInt(0, loadedFonts.length)]; return font.charToGlyph(ch).advanceWidth * (this.options.fontSize / font.unitsPerEm); }); const totalWidth = glyphWidths.reduce((sum, w) => sum + w, 0); // Step 2: scale down if text is wider than box const scale = Math.min(1, this.options.width / totalWidth); // Step 3: center the text block let xStart = (this.options.width - totalWidth * scale) / 2; let x = xStart; for (let i = 0; i < text.length; i++) { const char = text[i]; const font = loadedFonts[randomInt(0, loadedFonts.length)]; const glyph = font.charToGlyph(char); const charWidth = glyph.advanceWidth * (fontSizes / font.unitsPerEm) * scale; const charX = x + charWidth / 2; // center of char for rotation const rotate = randomInt(-25, 25); const path = glyph.getPath(x, y, fontSizes * scale); const d = path.toPathData(2); textSVG += `<path d="${d}" fill="${this.options.fontColour}" transform="rotate(${rotate} ${charX} ${y})"/>`; x += charWidth; // move to next character } } const noiseSVG = this.generateWavyNoise(this.options.noise); const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="${this.options.width}" height="${this.options.height}"> <rect width="100%" height="100%" fill="${this.options.background}"/> ${noiseSVG} ${textSVG} </svg> `.replace(/\s+/g, ' ').trim(); this.captchaKey = CaptchaGenerator.generateKey(16); this.captchaText = text; return { key: this.captchaKey, text, data: svg }; } /** * Store the captcha text in a storage backend. * * @param {number} ttl Expiry time in seconds * @param {(key: string, value: string, ttl: number) => Promise<void>} setFn Storage function * @returns {Promise<void>} */ async storeCaptcha(ttl, setFn) { if (typeof setFn !== "function") throw new Error("setFn must be a function"); if (!this.captchaKey || !this.captchaText) throw new Error("No captcha generated yet."); await setFn(`captcha:${this.captchaKey}`, this.captchaText, ttl); } /** * Verify user input against stored captcha text. * * @param {string} userInput User's input string * @param {string} captchaKey Captcha key * @param {(key: string) => Promise<string|null>} getFn Storage fetch function * @returns {Promise<boolean>} True if match, false otherwise */ async verifyCaptcha(userInput, captchaKey, getFn) { if (typeof getFn !== "function") throw new Error("getFn must be a function"); const storedValue = await getFn(`captcha:${captchaKey}`); return storedValue && storedValue === userInput; } /** * Generate wavy SVG noise lines. * * @param {number} count Number of noise lines * @returns {string} SVG markup of paths */ generateWavyNoise(count) { let noiseSVG = ""; for (let i = 0; i < count; i++) { const startY = randomInt(0, this.options.height); const segments = randomInt(2, 4); let d = `M0 ${startY}`; let prevX = 0; for (let s = 0; s < segments; s++) { const midX = prevX + this.options.width / segments; const midY = randomInt(0, this.options.height); const endX = midX + this.options.width / segments; const endY = randomInt(0, this.options.height); d += ` Q ${midX} ${midY}, ${endX} ${endY}`; prevX = endX; } noiseSVG += `<path d="${d}" stroke="${this.options.noiseColour}" fill="none" stroke-width="${this.defaults.noiseWidth}"/>`; } return noiseSVG; } }