/**
* Module for rendering string into GIF image
* @module wakabtcha/lib/image-generator
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/master/captcha.pl}
*/
const DEFAULT_FONT_HEIGHT = 8;
const DEFAULT_FONT = {
'a': [4, [0, 2, 1, 1, 2, 1, 3, 2, 3, 5, 4, 6], [3, 3, 1, 3, 0, 4, 0, 5, 1, 6, 2, 6, 3, 5]],
'b': [3, [0, 0, 0, 6, 2, 6, 3, 5, 3, 4, 2, 3, 0, 3]],
'c': [3, [3, 6, 1, 6, 0, 5, 0, 4, 1, 3, 3, 3]],
'd': [3, [3, 0, 3, 6, 1, 6, 0, 5, 0, 4, 1, 3, 3, 3]],
'e': [3, [3, 6, 1, 6, 0, 5, 0, 3, 1, 2, 3, 2, 3, 3, 2, 4, 0, 4]],
'f': [3, [1, 6, 1, 1, 2, 0, 3, 0], [0, 3, 2, 3]],
'g': [3, [3, 6, 1, 6, 0, 5, 0, 4, 1, 3, 3, 3, 3, 7, 2, 8, 0, 8]],
'h': [3, [0, 0, 0, 6], [0, 3, 2, 3, 3, 4, 3, 6]],
'i': [2, [1, 3, 1, 6], [1, 1, 1.5, 1.5, 1, 2, 0.5, 1.5]],
'j': [3, [2, 3, 2, 7, 1, 8, 0, 7], [2, 1, 2.5, 1.5, 2, 2, 1.5, 1.5]],
'k': [3, [0, 0, 0, 6], [0, 4, 1, 4, 2, 2], [0, 4, 1, 4, 3, 6]],
'l': [2, [0.5, 0, 0.5, 5, 1.5, 6]],
'm': [4, [0, 6, 0, 3, 1, 3, 2, 4], [2, 6, 2, 3, 3, 3, 4, 4, 4, 6]],
'n': [3, [0, 3, 0, 6], [0, 4, 1, 3, 2, 3, 3, 4, 3, 6]],
'o': [3, [0, 4, 1, 3, 2, 3, 3, 4, 3, 5, 2, 6, 1, 6, 0, 5, 0, 4]],
'p': [3, [0, 8, 0, 3, 2, 3, 3, 4, 3, 5, 2, 6, 0, 6]],
'q': [3, [3, 6, 1, 6, 0, 5, 0, 4, 1, 3, 3, 3, 3, 8], [2, 7, 4, 7]],
'r': [3, [0, 3, 0, 6], [0, 4, 1, 3, 2, 3]],
's': [3, [0, 6, 2, 6, 3, 5, 0, 4, 1, 3, 3, 3]],
't': [3, [1, 1, 1, 5, 2, 6], [0, 3, 2, 3]],
'u': [3, [0, 3, 0, 6, 2, 6, 3, 5, 3, 3]],
'v': [3, [0, 3, 1.5, 6, 3, 3]],
'w': [4, [0, 3, 0, 5, 1, 6, 2, 5, 3, 6, 4, 5, 4, 3]],
'x': [3, [0, 3, 3, 6], [0, 6, 3, 3]],
'y': [3, [0, 3, 0, 5, 1, 6, 3, 6], [3, 3, 3, 7, 2, 8, 0, 8]],
'z': [3, [0, 3, 3, 3, 0, 6, 3, 6], [0.5, 4.5, 2.5, 4.5]],
' ': [3],
};
const DEFAULT_CAPTHCA_HEIGHT = 18;
const DEFAULT_CAPTCHA_SCRIBBLE = 0.2;
const DEFAULT_CAPTCHA_SCALING = 0.15;
const DEFAULT_CAPTCHA_ROTATION = 0.3;
const DEFAULT_CAPTCHA_SPACING = 2.5;
/**
* Render text into 2-dimensional array of pixels
* @param {String} str Text to draw
* @param {Number} [captchaHeight=18] Height of image
* @param {Number} [captchaScribble=0.2] Random scatter level
* @param {Number} [captchaScaling=0.15] Amplitude of random scale change
* @param {Number} [captchaRotation=0.3] Amplitude of random rotation
* @param {Number} [captchaSpacing=2.5] Distance between letters
* @param {Object.<String,Number|Number[]>} [font=DEFAULT_FONT] Captcha font
* object, where keys are characters, values are arrays where first value
* is character width, other values are strokes where each stroke is
* represented by array of coordinates [x1,y1, x2,y2, x3,y3, ..., xN,yN]
* @param {Number} [fontHeight=8] Height of characters in font
* @return {Array.<Array.<Number>>} pixels Source image as 2-dimensional
* array of pixels
*/
const drawString = (str,
captchaHeight = DEFAULT_CAPTHCA_HEIGHT,
captchaScribble = DEFAULT_CAPTCHA_SCRIBBLE,
captchaScaling = DEFAULT_CAPTCHA_SCALING,
captchaRotation = DEFAULT_CAPTCHA_ROTATION,
captchaSpacing = DEFAULT_CAPTCHA_SPACING,
font = DEFAULT_FONT,
fontHeight = DEFAULT_FONT_HEIGHT) => {
const pixels = Array(captchaHeight).fill(null).map(() => []);
/**
* @typedef {Object} TransformationObject
* @property {Number} dx X offset
* @property {Number} dy Y offset
* @property {Number} scale Scale
* @property {Number} rotation Rotation angle
*/
/**
* This method is ported from captcha.pl
* @param {Number} char_w Width of character in pixels
* @return {TransformationObject} transformation object
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L349}
*/
const setup_transform = (char_w) => {
const dx = char_w / 2;
const dy = fontHeight / 2;
const scale = captchaHeight / fontHeight * (1 + (captchaScaling) * (Math.random() * 2 - 1));
const rot = (Math.random() * 2 - 1) * (captchaRotation);
return { dx, dy, scale, rot };
};
/**
* This method is ported from captcha.pl
* @param {Number} x X coordinate
* @param {Number} y Y coordinate
* @param {TransformationObject} transform
* @return {Number[]} [x, y]
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L359}
*/
const transform_coords = (x, y, { dx, dy, scale, rot }) => {
x = Math.floor(scale *
(Math.cos(rot) * (x - dx) - Math.sin(rot) * (y - dy) + dx + Math.random() * captchaScribble));
y = Math.floor(scale *
(Math.sin(rot) * (x - dx) + Math.cos(rot) * (y - dy) + dy + Math.random() * captchaScribble));
return [x, y];
};
/**
* This method is ported from captcha.pl
* @param {Number} x1 Line start X coordinate
* @param {Number} y1 Line start Y coordinate
* @param {Number} x2 Line end X coordinate
* @param {Number} y2 Line end Y coordinate
* @param {Number} [col=1] Color index (in palette)
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L369}
*/
const draw_line = (x1, y1, x2, y2, col=1) => {
let x = x1;
let y = y1;
let dx = x2 - x1;
let dy = y2 - y1;
let x_inc = (dx < 0) ? -1 : 1;
let l = dx * x_inc;
let y_inc = (dy < 0) ? -1 : 1;
let m = dy * y_inc;
let dx2 = l * 2;
let dy2 = m * 2;
if(l >= m) {
let err = dy2 - l;
for (let i = 0; i < l; i++) {
draw_pixel(x, y, col);
if (err > 0) {
y += y_inc;
err -= dx2;
}
err += dy2;
x += x_inc;
}
} else {
let err = dx2 - m;
for (let i = 0; i < m; i++) {
draw_pixel(x, y, col);
if(err > 0) {
x += x_inc;
err -= dy2;
}
err += dx2;
y += y_inc;
}
}
draw_pixel(x, y, col);
};
/**
* This method is ported from captcha.pl
* @param {Number} x Pixel X coordinate
* @param {Number} y Pixel Y coordinate
* @param {Number} [col=1] Color index (in palette)
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L413}
*/
const draw_pixel = (x, y, col=1) => {
if (x < 0 || y < 0) {
return;
}
if (!pixels[y]) {
pixels[y] = [];
}
pixels[y][x] = col;
pixels[y][x + 1] = col;
};
/**
* This method is ported from captcha.pl
* @param {String} str Text to draw
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L315}
*/
const draw_string = (str) => {
const chars = str.split('');
let x_offs = Math.floor(captchaHeight / fontHeight * 2);
for (const char of chars) {
if (!font[char]) {
continue;
}
const char_w = font[char][0];
const transformation = setup_transform(char_w);
const strokes = font[char].slice(1);
for (const stroke of strokes) {
const coords = Array.from(stroke);
let [ prev_x, prev_y ] = transform_coords(coords.shift(), coords.shift(), transformation);
while (coords.length) {
let [ x, y ] = transform_coords(coords.shift(), coords.shift(), transformation);
draw_line(prev_x + x_offs, prev_y, x + x_offs, y, 1);
prev_x = x;
prev_y = y;
}
}
x_offs += Math.floor((char_w + (captchaSpacing)) * transformation.scale);
}
};
draw_string(str);
return pixels;
};
/**
* Create gif file from 2-dimensional array of pixels
* @param {Array.<Array.<Number>>} pixels Source image as 2-dimensional
* array of pixels
* @param {Number} [foregroundColor=0x000000] Color of text represented by
* 24-bit integer (in 0xRRGGBB format)
* @param {Number} [backgroundColor=0xFFFFFF] Color of background represented
* by 24-bit integer (in 0xRRGGBB format)
* @param {Boolean} [backgroundTransparent=true] Use transparent background
* instead of solid color
* @return {Buffer} Image (image/gif)
*/
const generateGif = (pixels, foregroundColor = 0x000000, backgroundColor = 0xFFFFFF, backgroundTransparent = true) => {
let pixelIndex = 0;
let offset = 0;
let block = '';
const height = pixels.length;
let widths = pixels.map(arr => arr ? arr.length : 0).filter(Boolean);
if (!widths.length) {
widths = [3];
}
const width = Math.max(...widths);
let allocSize = 416; // size of header
const totalPixels = width * height;
allocSize += totalPixels;
const addPix = Math.ceil(totalPixels / 126);
allocSize += addPix;
const numBlocks = Math.ceil((totalPixels + addPix) / 256);
allocSize += numBlocks;
allocSize += 4; // size of terminator
const buffer = Buffer.alloc(allocSize);
/**
* This method is ported from captcha.pl
* @param {Number} width Width in pixels
* @param {Number} height Height in pixels
* @param {Number[]} palette Array of colors represented by 24-bit integers
* in 0xRRGGBB format
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L431}
*/
const start_128_gif = (width, height, palette) => {
// GIF file header
offset += buffer.write('GIF89a', offset, 6, 'ascii'); // Header
// Logical screen descriptor
offset = buffer.writeUInt16LE(width, offset); // Logical screen width in pixels
offset = buffer.writeUInt16LE(height, offset); // Logical screen height in pixels
offset = buffer.writeUInt8(0xa6, offset); // Global color table flags
offset = buffer.writeUInt8(0, offset); // Background color index (#0)
offset = buffer.writeUInt8(0, offset); // Default pixel aspect ratio
// Global color table
for (let i = 0; i < 128; i++) {
const color = palette[i] || 0;
offset = buffer.writeUInt8(color >> 16 & 0xFF, offset); // R
offset = buffer.writeUInt8(color >> 8 & 0xFF, offset); // G
offset = buffer.writeUInt8(color >> 0 & 0xFF, offset); // B
}
// Graphic Control Extension
offset = buffer.writeUInt16BE(0x21f9, offset); // start of GCE block
offset = buffer.writeUInt8(4, offset); // 4 bytes of GCE data follow
const transparentByte = backgroundTransparent ? 0x01 : 0x00;
offset = buffer.writeUInt8(transparentByte, offset); // there is a transparent background color
offset = buffer.writeUInt16LE(0, offset); // delay for animation in hundredths of a second: not used
offset = buffer.writeUInt8(0, offset); // color #0 is transparent
offset = buffer.writeUInt8(0, offset); // end of GCE block
// Image descriptor
offset = buffer.writeUInt8(0x2c, offset); // start of image descriptor
offset = buffer.writeUInt16LE(0, offset); // Image Left Position
offset = buffer.writeUInt16LE(0, offset); // Image Top Position
offset = buffer.writeUInt16LE(width, offset); // Image width
offset = buffer.writeUInt16LE(height, offset); // Image height
offset = buffer.writeUInt8(0x00, offset); // Local color table (no local color table)
// Start of Image
offset = buffer.writeUInt8(0x07, offset); // LZW Minimum Code Size
};
/**
* This method is ported from captcha.pl
* @param {Number[]} pixels Row of color indexes
* indexes
* @param {Number} num How many pixels to draw
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L301}
*/
const emit_pixel_block = (pixels, num) => {
for (let i = 0; i < num; i++) {
if (pixels) {
emit_gif_pixel(pixels[i]);
} else {
emit_gif_pixel(0);
}
}
};
/**
* This method is ported from captcha.pl
* @param {Number} pixel Color index
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L455}
*/
const emit_gif_pixel = (pixel) => {
if (pixelIndex % 126 === 0) {
emit_gif_byte(0x80);
}
emit_gif_byte(pixel);
pixelIndex++;
};
/**
* This method is ported from captcha.pl
* @param {Number} byte Char to write
* @param {Boolean} [terminate=false] If true, write leftover bytes that
* didn't form whole block
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L464}
*/
const emit_gif_byte = (byte, terminate = false) => {
block += String.fromCharCode(byte || 0);
if(block.length === 255 || terminate) {
offset = buffer.writeUInt8(block.length, offset);
offset += buffer.write(block, offset, block.length, 'binary');
block = '';
}
};
/**
* This method is ported from captcha.pl
* @see [captcha.pl source]{@link https://github.com/some1suspicious/wakaba-original/blob/b71c8df40a70ab654c420f4e42a51aa5e6d7a158/captcha.pl#L478}
*/
const end_gif = () => {
emit_gif_byte(0x81);
emit_gif_byte(0, true);
offset = buffer.writeUInt8(0x3b, offset); // GIF file terminator
};
start_128_gif(width, height, [backgroundColor, foregroundColor]);
for (let y = 0; y < height; y++) {
emit_pixel_block(pixels[y], width);
}
end_gif();
return buffer;
};
/**
* Generate an image with text from string
* @param {String} str Captcha answer
* @param {Object} [options] Override defaults
* @param {Number} [options.captchaHeight=18] Height of image
* @param {Number} [options.captchaScribble=0.2] Random scatter level
* @param {Number} [options.captchaScaling=0.15] Amplitude of random scale
* change
* @param {Number} [options.captchaRotation=0.3] Amplitude of random rotation
* @param {Number} [options.captchaSpacing=2.5] Distance between letters
* @param {Object.<String,Number|Number[]>} [options.font=DEFAULT_FONT]
* Captcha font object, where keys are characters, values are arrays where
* first value is character width, other values are strokes where each
* stroke is represented by array of coordinates [x1,y1, x2,y2, x3,y3, ...,
* xN,yN]
* @param {Number} [options.fontHeight=8] Height of characters in font
* @param {Number} [options.foregroundColor=0x000000] Color of text
* represented by 24-bit integer (in 0xRRGGBB format)
* @param {Number} [options.backgroundColor=0xFFFFFF] Color of background
* represented by 24-bit integer (in 0xRRGGBB format)
* @param {Boolean} [options.backgroundTransparent=true] Use transparent
* background instead of solid color
* @return {Buffer} Image (image/gif)
* @alias module:wakabtcha.generateImage
* @see {@link module:wakabtcha/image-generator~DEFAULT_FONT}
*/
module.exports.generate = (str, options={}) => {
const pixels = drawString(str,
options.captchaHeight,
options.captchaScribble,
options.captchaScaling,
options.captchaRotation,
options.captchaSpacing,
options.font,
options.fontHeight,
);
const buffer = generateGif(pixels,
options.foregroundColor,
options.backgroundColor,
options.backgroundTransparent);
return buffer;
};