captcha-canvas
Version:
A captcha generator by using skia-canvas module.
450 lines (449 loc) • 17.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Captcha = void 0;
const skia_canvas_1 = require("skia-canvas");
const constants_1 = require("./constants");
const util_1 = require("./util");
/**
* Core CAPTCHA class for low-level canvas operations and image generation.
*
* This class provides direct access to the underlying canvas operations for creating
* CAPTCHA images. It offers more granular control compared to CaptchaGenerator but
* requires manual coordination of drawing operations. Use CaptchaGenerator for most
* use cases unless you need fine-grained control over the rendering process.
*
* @example Basic usage
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha.drawCaptcha({ text: 'HELLO', size: 40 });
* captcha.drawTrace({ color: '#95a5a6' });
*
* const buffer = await captcha.png;
* console.log('Text:', captcha.text);
* ```
*
* @example Advanced multi-step generation
* ```typescript
* const captcha = new Captcha(400, 150, 6);
*
* // Add background noise with decoys
* captcha.addDecoy({ total: 30, opacity: 0.3 });
*
* // Draw main captcha text
* captcha.drawCaptcha({
* text: 'SECURE',
* size: 60,
* colors: ['#e74c3c', '#3498db']
* });
*
* // Add connecting trace lines
* captcha.drawTrace({ size: 4, opacity: 0.8 });
*
* // Add more decoys on top
* captcha.addDecoy({ total: 20, opacity: 0.2 });
*
* const buffer = await captcha.png;
* ```
*/
class Captcha {
/**
* Creates a new Captcha instance with specified canvas dimensions.
*
* Initializes the underlying HTML5 canvas and 2D rendering context with
* optimized settings for CAPTCHA generation. The canvas is configured
* for high-quality text rendering and precise drawing operations.
*
* @param width - Canvas width in pixels (default: 300)
* @param height - Canvas height in pixels (default: 100)
* @param characters - Expected number of characters for coordinate calculation (default: 6)
*
* @example Standard CAPTCHA size
* ```typescript
* const captcha = new Captcha(300, 100);
* ```
*
* @example Large CAPTCHA for better accessibility
* ```typescript
* const captcha = new Captcha(500, 200, 8);
* ```
*
* @example Mobile-optimized size
* ```typescript
* const captcha = new Captcha(250, 80, 5);
* ```
*/
constructor(width, height, characters) {
var _a;
if (width === void 0) { width = constants_1.defaultDimension.width; }
if (height === void 0) { height = constants_1.defaultDimension.height; }
if (characters === void 0) { characters = (_a = constants_1.defaultCaptchaOption.characters) !== null && _a !== void 0 ? _a : 6; }
this._height = height;
this._width = width;
this._captcha = { ...constants_1.defaultCaptchaOption, characters: characters };
this._trace = constants_1.defaultTraceOptions;
this._decoy = constants_1.defaultDecoyOptions;
const canvas = new skia_canvas_1.Canvas(width, height);
const ctx = canvas.getContext('2d');
ctx.lineJoin = 'miter';
ctx.textBaseline = 'middle';
ctx.textAlign = 'center';
this._canvas = canvas;
this._ctx = ctx;
this.async = true;
this._coordinates = [];
this._canvas.gpu = false;
}
/**
* Gets the current CAPTCHA text that has been drawn on the canvas.
*
* Returns the text content that users need to enter to solve the CAPTCHA.
* This value is set when `drawCaptcha()` is called and represents the
* solution to the generated CAPTCHA image.
*
* @returns The CAPTCHA text string, or empty string if no text has been set
*
* @example
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha.drawCaptcha({ text: 'HELLO' });
* console.log(captcha.text); // Output: "HELLO"
* ```
*/
get text() {
return this._captcha.text || "";
}
/**
* Gets the generated CAPTCHA image as a PNG buffer.
*
* Returns either a Promise<Buffer> (async mode) or Buffer (sync mode) containing
* the PNG-encoded image data. The mode is determined by the `async` property.
*
* @returns PNG image buffer (Promise in async mode, Buffer in sync mode)
*
* @example Async mode (default)
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha.drawCaptcha({ text: 'HELLO' });
*
* const buffer = await captcha.png;
* fs.writeFileSync('captcha.png', buffer);
* ```
*
* @example Sync mode
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha.async = false;
* captcha.drawCaptcha({ text: 'HELLO' });
*
* const buffer = captcha.png as Buffer;
* fs.writeFileSync('captcha.png', buffer);
* ```
*/
get png() {
if (this.async) {
return this._canvas.toBuffer('png');
}
else {
return this._canvas.toBufferSync('png');
}
}
/**
* Draws a background image on the CAPTCHA canvas.
*
* The image is automatically scaled to fit the entire canvas dimensions,
* providing a background that increases visual complexity and security.
* This should typically be called before drawing text and other elements.
*
* @param image - Pre-loaded image object (use `resolveImage()` to load from file/URL)
* @returns The Captcha instance for method chaining
*
* @example With background image
* ```typescript
* import { resolveImage } from 'captcha-canvas';
*
* const backgroundImage = await resolveImage('./noise-pattern.jpg');
* const captcha = new Captcha(300, 100);
*
* captcha
* .drawImage(backgroundImage)
* .drawCaptcha({ text: 'HELLO', opacity: 0.9 });
* ```
*
* @example Texture background
* ```typescript
* const texture = await resolveImage('https://example.com/texture.png');
* const captcha = new Captcha(400, 150);
*
* captcha
* .drawImage(texture)
* .addDecoy({ opacity: 0.2 })
* .drawCaptcha({ text: 'SECURE' });
* ```
*/
drawImage(image) {
this._ctx.drawImage(image, 0, 0, this._width, this._height);
return this;
}
/**
* Adds decoy characters to the CAPTCHA for enhanced security against OCR.
*
* Decoy characters are randomly positioned fake characters that confuse
* automated solving attempts while remaining distinguishable from real
* text by humans through opacity, size, or positioning differences.
*
* @param decoyOption - Configuration for decoy character appearance
* @returns The Captcha instance for method chaining
*
* @example Basic decoy setup
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha
* .addDecoy({ total: 25, opacity: 0.3 })
* .drawCaptcha({ text: 'HELLO' });
* ```
*
* @example Layered security with multiple decoy calls
* ```typescript
* const captcha = new Captcha(400, 150);
* captcha
* .addDecoy({ total: 30, opacity: 0.2, size: 15 }) // Background noise
* .drawCaptcha({ text: 'SECURE' })
* .addDecoy({ total: 15, opacity: 0.1, size: 25 }); // Foreground confusion
* ```
*
* @example Custom decoy styling
* ```typescript
* const captcha = new Captcha(350, 120);
* captcha
* .addDecoy({
* total: 40,
* color: '#95a5a6',
* font: 'Arial',
* size: 18,
* opacity: 0.25
* })
* .drawCaptcha({ text: 'VERIFY' });
* ```
*/
addDecoy(decoyOption = {}) {
var _a, _b;
const option = { ...this._decoy, ...decoyOption };
if (!option.total)
option.total = Math.floor(this._width * this._height / 10000);
const decoyText = (0, util_1.randomText)(option.total);
this._ctx.font = `${option.size}px ${option.font}`;
this._ctx.globalAlpha = ((_a = option.opacity) !== null && _a !== void 0 ? _a : constants_1.defaultDecoyOptions.opacity);
this._ctx.fillStyle = ((_b = option.color) !== null && _b !== void 0 ? _b : constants_1.defaultDecoyOptions.color);
for (const element of decoyText) {
this._ctx.fillText(element, (0, util_1.getRandom)(30, this._width - 30), (0, util_1.getRandom)(30, this._height - 30));
}
return this;
}
/**
* Draws connecting trace lines between CAPTCHA characters for enhanced security.
*
* Trace lines connect the character positions, making character segmentation
* significantly more difficult for automated systems while maintaining human
* readability. The lines follow the character coordinates established by `drawCaptcha()`.
*
* @param traceOption - Configuration for trace line appearance
* @returns The Captcha instance for method chaining
*
* @example Basic trace line
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha
* .drawCaptcha({ text: 'HELLO' })
* .drawTrace({ color: '#95a5a6', size: 3 });
* ```
*
* @example Subtle trace for better readability
* ```typescript
* const captcha = new Captcha(350, 120);
* captcha
* .drawCaptcha({ text: 'VERIFY', size: 45 })
* .drawTrace({
* color: '#bdc3c7',
* size: 2,
* opacity: 0.6
* });
* ```
*
* @example Security-focused thick trace
* ```typescript
* const captcha = new Captcha(400, 150);
* captcha
* .addDecoy({ total: 20, opacity: 0.2 })
* .drawCaptcha({ text: 'SECURE', size: 50 })
* .drawTrace({
* color: '#e74c3c',
* size: 5,
* opacity: 0.8
* });
* ```
*
*
* **Note:** Call `drawCaptcha()` before `drawTrace()` to ensure proper character coordinate calculation.
*/
drawTrace(traceOption = {}) {
var _a, _b, _c;
const option = { ...this._trace, ...traceOption };
if (!this._coordinates[0])
this._coordinates = (0, util_1.getRandomCoordinate)(this._height, this._width, this._captcha.characters || 6);
const coordinates = this._coordinates;
this._ctx.strokeStyle = ((_a = option.color) !== null && _a !== void 0 ? _a : constants_1.defaultTraceOptions.color);
this._ctx.globalAlpha = ((_b = option.opacity) !== null && _b !== void 0 ? _b : constants_1.defaultTraceOptions.opacity);
this._ctx.beginPath();
this._ctx.moveTo(coordinates[0][0], coordinates[0][1]);
this._ctx.lineWidth = ((_c = option.size) !== null && _c !== void 0 ? _c : constants_1.defaultTraceOptions.size);
for (let i = 1; i < coordinates.length; i++) {
this._ctx.lineTo(coordinates[i][0], coordinates[i][1]);
}
this._ctx.stroke();
return this;
}
/**
* Draws the main CAPTCHA text on the canvas with specified styling options.
*
* This is the core method for rendering the CAPTCHA text that users need to solve.
* Supports both single configuration objects and arrays for multi-styled text segments.
* Character positions are automatically calculated and stored for trace line generation.
*
* @param captchaOption - Single text configuration or array of configurations for multi-styled segments
* @returns The Captcha instance for method chaining
*
* @example Basic text rendering
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha.drawCaptcha({
* text: 'HELLO',
* size: 40,
* color: '#2c3e50'
* });
* ```
*
* @example Advanced styling with transformations
* ```typescript
* const captcha = new Captcha(350, 120);
* captcha.drawCaptcha({
* text: 'SECURE',
* size: 50,
* font: 'Arial',
* rotate: 15, // Random rotation up to ±15 degrees per character
* skew: true, // Apply random skewing transformation
* opacity: 0.85
* });
* ```
*
* @example Multi-color text using colors array
* ```typescript
* const captcha = new Captcha(400, 150);
* captcha.drawCaptcha({
* text: 'RAINBOW',
* size: 45,
* colors: ['#e74c3c', '#f39c12', '#f1c40f', '#27ae60', '#3498db', '#9b59b6']
* });
* ```
*
* @example Multi-styled text segments
* ```typescript
* const captcha = new Captcha(450, 150);
* captcha.drawCaptcha([
* { text: 'SEC', size: 50, color: '#e74c3c', font: 'Arial', rotate: 10 },
* { text: 'URE', size: 45, color: '#27ae60', font: 'Times', skew: true }
* ]);
* ```
*
* @example Random text generation
* ```typescript
* const captcha = new Captcha(300, 100);
* captcha.drawCaptcha({
* characters: 6, // Generate 6 random characters
* size: 40,
* colors: ['#34495e', '#e67e22']
* });
* console.log('Generated text:', captcha.text);
* ```
*
* @throws {Error} When array mode is used but text property is missing from segments
* @throws {Error} When text length doesn't match specified character count
*/
drawCaptcha(captchaOption = {}) {
var _a, _b, _c, _d, _e, _f, _g, _h;
if (Array.isArray(captchaOption)) {
let text = "";
for (const option of captchaOption) {
if (!option.text)
throw new Error("Each captcha option in array must have a text property.");
text += option.text;
}
this._captcha.text = text;
this._captcha.characters = text.length;
if (!this._coordinates[0])
this._coordinates = (0, util_1.getRandomCoordinate)(this._height, this._width, this._captcha.characters || 6);
const coordinates = this._coordinates;
let charIndex = 0;
for (const option of captchaOption) {
const text = option.text || "";
for (let i = 0; i < text.length; i++) {
this._ctx.save();
this._ctx.translate(coordinates[charIndex][0], coordinates[charIndex][1]);
this._ctx.font = `${option.size}px ${option.font}`;
this._ctx.globalAlpha = ((_a = option.opacity) !== null && _a !== void 0 ? _a : constants_1.defaultCaptchaOption.opacity);
this._ctx.fillStyle = ((_b = option.color) !== null && _b !== void 0 ? _b : constants_1.defaultCaptchaOption.color);
if (option.skew) {
this._ctx.transform(1, Math.random(), (0, util_1.getRandom)(20) / 100, 1, 0, 0);
}
if (option.rotate && option.rotate > 0) {
this._ctx.rotate((0, util_1.getRandom)(-option.rotate, option.rotate) * Math.PI / 180);
}
if (option.colors && ((_c = option.colors) === null || _c === void 0 ? void 0 : _c.length) >= 2) {
this._ctx.fillStyle = option.colors[(0, util_1.getRandom)(option.colors.length - 1)];
}
this._ctx.fillText(text[i], 0, 0);
this._ctx.restore();
charIndex++;
}
}
}
else {
const option = { ...this._captcha, ...captchaOption };
if (captchaOption.text)
option.text = captchaOption.text;
if (!option.text)
option.text = (0, util_1.randomText)(((_d = option.characters) !== null && _d !== void 0 ? _d : constants_1.defaultCaptchaOption.characters));
if (option.text.length != option.characters) {
if (captchaOption.text) {
throw new Error("Size of text and no. of characters is not matching.");
}
else {
option.text = (0, util_1.randomText)(((_e = option.characters) !== null && _e !== void 0 ? _e : constants_1.defaultCaptchaOption.characters));
}
}
this._captcha = option;
if (!this._coordinates[0])
this._coordinates = (0, util_1.getRandomCoordinate)(this._height, this._width, option.characters || 6);
const coordinates = this._coordinates;
this._ctx.font = `${option.size}px ${option.font}`;
this._ctx.globalAlpha = ((_f = option.opacity) !== null && _f !== void 0 ? _f : constants_1.defaultCaptchaOption.opacity);
this._ctx.fillStyle = ((_g = option.color) !== null && _g !== void 0 ? _g : constants_1.defaultCaptchaOption.color);
for (let n = 0; n < coordinates.length; n++) {
this._ctx.save();
this._ctx.translate(coordinates[n][0], coordinates[n][1]);
if (option.skew) {
this._ctx.transform(1, Math.random(), (0, util_1.getRandom)(20) / 100, 1, 0, 0);
}
if (option.rotate && option.rotate > 0) {
this._ctx.rotate((0, util_1.getRandom)(-option.rotate, option.rotate) * Math.PI / 180);
}
if (option.colors && ((_h = option.colors) === null || _h === void 0 ? void 0 : _h.length) >= 2) {
this._ctx.fillStyle = option.colors[(0, util_1.getRandom)(option.colors.length - 1)];
}
this._ctx.fillText(option.text[n], 0, 0);
this._ctx.restore();
}
}
return this;
}
}
exports.Captcha = Captcha;