/*
* Author: Vlad Seryakov vseryakov@gmail.com
* backendjs 2018
*/
/**
* @module image
*/
const lib = require(__dirname + '/../lib');
const logger = require(__dirname + '/../logger');
const color = require(__dirname + '/color');
const image = {
name: "image",
args: [
{ name: "black", descr: "Default color to use for black text" },
{ name: "white", descr: "Default color to use for white text" },
{ name: "background", descr: "Default background color for new images" },
],
black: '#333',
white: '#E9E9E9',
background: "#FFFFFF",
};
/**
* Scaling and composing images with Sharp.js
*/
module.exports = image;
var sharp;
/**
* Normalize padding-related item into a rectangle object.
*
* Accepts either side-specific padding values or axis-wide or single padding:
* - `padding_top`, `padding_right`, `padding_bottom`, `padding_left` (highest priority)
* - `padding_y` applies to top/bottom, `padding_x` applies to left/right
* - `padding` applies to all sides
*
* Missing/undefined values are coerced via `lib.toNumber(...)`.
*
* @param {object} [item] Input item object.
* @param {number|string} [item.padding] Padding for all sides.
* @param {number|string} [item.padding_x] Padding for left/right.
* @param {number|string} [item.padding_y] Padding for top/bottom.
* @param {number|string} [item.padding_top] Padding for top side.
* @param {number|string} [item.padding_right] Padding for right side.
* @param {number|string} [item.padding_bottom] Padding for bottom side.
* @param {number|string} [item.padding_left] Padding for left side.
* @returns {{top:number,left:number,right:number,bottom:number}} Padding rectangle.
* @memberof module:image
*/
image.padding = function(item)
{
return {
top: lib.toNumber(item.padding_top || item.padding_y || item.padding),
left: lib.toNumber(item.padding_left || item.padding_x || item.padding),
right: lib.toNumber(item.padding_right || item.padding_x || item.padding),
bottom: lib.toNumber(item.padding_bottom || item.padding_y || item.padding)
}
}
/**
* Return region corresponding to the gravity
* @param {string} gravity
* @param {int} width
* @param {int} height
* @returns {object} - object { left, top, width, height }
* @memberof module:image
*/
image.gravityRegion = function(gravity, width, height)
{
const w = Math.floor(width / 3);
const h = Math.floor(height / 3);
const map = {
northwest: [0, 0], north: [1, 0], northeast: [2, 0],
west: [0, 1], center: [1, 1], east: [2, 1],
southwest: [0, 2], south: [1, 2], southeast: [2, 2],
};
const [col, row] = map[gravity] || map.center;
return {
top: row * h,
left: col * w,
width: h,
height: h
};
}
/**
* Return the stats about given region with inverted dominant color, if no region provided stats for the whole image
* @param {Buffer|object} input - buffer or sharp object
* @param {object} [region] - region to extract and return stats for
* @returns {object} region stats with additional properties:
* - `dominant` - dominant color
* - `mean` - mean color as RGB
* - `meta` - meta data object
* - `image` - image as hidden property
* @memberof module:image
*/
image.stats = async function(input, region)
{
if (!sharp) sharp = lib.tryRequire('sharp');
logger.debug("stats:", image.name, "start:", region);
if (region?.width > 0 && region?.height > 0) {
input = Buffer.isBuffer(input) ? input : await input.toBuffer();
// Keep region bounds valid
const meta = await sharp(input).metadata();
const r = Object.keys(region).reduce((a, b) => { a[b] = lib.toNumber(region[b]); return a }, {});
if (r.top + r.height > meta.height) r.height = meta.height - r.top;
if (r.left + r.width > meta.width) r.width = meta.width - r.left;
const buffer = await sharp(input).extract(r).toBuffer();
input = sharp(buffer);
} else {
input = Buffer.isBuffer(input) ? await sharp(input) : input;
}
const stats = await input.stats();
stats.mean = {
r: parseInt(stats.channels[0].mean),
g: parseInt(stats.channels[1].mean),
b: parseInt(stats.channels[2].mean)
};
stats.meta = await input.metadata();
Object.defineProperty(stats, "_image", {
configurable: true,
writable: true,
enumerable: false,
value: input,
})
logger.debug("stats:", image.name, "done:", region, "stats:", stats);
return stats;
}
const ops = [
"autoOrient", "rotate", "flip", "flop", "affine", "sharpen", "erode",
"dilate", "median", "blur", "flatten", "unflatten", "gamma", "negate", "normalise",
"normalize", "clahe", "convolve", "threshold", "boolean", "linear", "recomb", "modulate",
"trim", "extend", "extract",
];
/**
* Apply Sharp image filters and conversions
* @param {object} input - sharp image
* @param {object}
* @param {object[]} [item.filters] - filters, each operation must provide filter name and value as a map or an object
* @returns {object} transformed image
* @memberof module:image
*/
image.convert = async function(input, item)
{
if (!lib.isArray(item?.filters)) return input.png();
input = sharp(Buffer.isBuffer(input) ? input : await input.png().toBuffer());
for (const filter of item.filters) {
if (!ops.includes(filter?.name)) continue;
let value = filter.value || undefined;
if (lib.isString(value)) {
value = lib.toMap(value, { maptype: "auto" });
}
logger.debug("convert:", image.name, item.id, filter);
input[filter.name](value);
}
return input.png();
}
/**
* Merge defaults into an item, supports format `id.name` or `type.name` where id must match item.id and type item.type
* @param {object} item
* @param {object} [defaults]
* @returns {object}
* @memberof module:image
*/
image.mergeDefaults = function(item, defaults)
{
for (const p in defaults) {
const v = defaults[p];
if (v === undefined || v === null || v === "") continue;
const [id, name] = p.split(".");
// Item specific
if (name) {
if ((id === item.id || id === item.type) &&
(item[name] === undefined || item[name] === "")) {
item[name] = v;
}
continue;
}
// Global
if (item[p] === undefined || item[p] === "") {
item[p] = v;
}
}
return item;
}
/**
* Detect a text color to place on top of the area inside given background
* @param {object} item - input item
* @param {object} bgimage - Sharp image with background
* @memberof module:image
* @async
*/
image.detect = async function(item, bgimage)
{
const input = await image.create(item);
const meta = await input.metadata();
const bgmeta = await bgimage.metadata();
const region = image.gravityRegion(item.gravity, bgmeta.width, bgmeta.height);
Object.assign(region,
{ id: item.id, gravity: item.gravity },
{
top: item.top || region.top,
left: item.left || region.left,
width: item.width || region.width || meta.width,
height: item.height || region.height || meta.height,
});
logger.debug("detect:", image.name, "start:", item.id, "region:", region);
const stats = await image.stats(bgimage, region);
let ncolor = color.negate(stats.dominant);
lib.split(item.text_auto || "luminance").forEach(x => {
const old = ncolor;
switch (x) {
case "dominant":
ncolor = color.negate(stats.dominant);
break;
case "mean":
ncolor = color.negate(stats.mean);
break;
case "complement":
ncolor = color.rotate(ncolor);
break;
case "lighter":
ncolor = color.lighter(ncolor);
break;
case "darker":
ncolor = color.darker(ncolor);
break;
case "softlight":
ncolor = color.rluminance(ncolor) > 0.179 ? color.lighter(ncolor) : color.darker(ncolor);
break;
case "luminance":
default:
ncolor = color.rluminance(ncolor) > 0.179 ? color.hex2rgb(item.white || image.white) : color.hex2rgb(item.black || image.black);
break;
}
logger.debug("detect:", image.name, item.id, x, ncolor, "old:", old);
});
item._mean = stats.mean;
item._dominant = stats.dominant;
item._color = color.hex(ncolor);
logger.debug("detect:", image.name, "done:", item, "STATS:", stats);
return stats;
}
/**
* Generate an SVG radial-gradient ellipse overlay for an image item and return it as a Buffer.
*
* Picks a gradient color (`gcolor`) based on `item.color` and `item.gradient_type`, adjusting for
* luminance/contrast, then builds an SVG sized to `item.meta.width`/`item.meta.height`.
*
* @param {Object} item
* @param {Object} item._meta
* @param {number} item._meta.width - SVG width in pixels.
* @param {number} item._meta.height - SVG height in pixels.
* @param {string} [item.color="#fff"] - Base color in hex format.
* @param {string} [item._color="#fff"] - Detected color in hex format.
* @param {string} [item.gradient_type] - Gradient mode, e.g. "mid" (default uses negate).
* @param {string|number} [item.id] - Identifier used for debug logging.
* @returns {Buffer} SVG markup as a Buffer.
* @memberof module:image
*/
image.createGradient = async function(item)
{
const width = item._meta.width;
const height = item._meta.height;
let gcolor = item.gradient_color;
if (!gcolor) {
const _color = item.color || item._color || "#fff";
const ncolor = color.negate(color.hex2rgb(_color));
gcolor = color.rluminance(ncolor) > 0.179 ? color.hex2rgb(item.white || image.white) : color.hex2rgb(item.black || image.black);
gcolor = item._gcolor = color.hex(gcolor);
logger.debug("gradient:", image.name, item.id, width, height, "t:", _color, ncolor, "g:", gcolor)
}
const svg = Buffer.from(`
<svg width="${width}" height="${height}">
<defs>
<radialGradient id="grad" cx="50%" cy="50%" r="100%" fx="50%" fy="50%">
<stop offset="0%" stop-color="${gcolor}" stop-opacity="0.90"/>
<stop offset="20%" stop-color="${gcolor}" stop-opacity="0.75"/>
<stop offset="40%" stop-color="${gcolor}" stop-opacity="0.55"/>
<stop offset="60%" stop-color="${gcolor}" stop-opacity="0.35"/>
<stop offset="80%" stop-color="${gcolor}" stop-opacity="0.2"/>
<stop offset="90%" stop-color="${gcolor}" stop-opacity="0.05"/>
<stop offset="100%" stop-color="${gcolor}" stop-opacity="0"/>
</radialGradient>
</defs>
<ellipse cx="${Math.round(width/2)}" cy="${Math.round(height/2)}" rx="${Math.round(width/2.3)}" ry="${Math.round(height/3)}" fill="url(#grad)" />
</svg>`);
const buffer = await sharp(svg).
blur({ sigma: item.gradient_sigma || 25, minAmplitude: 0.01 }).
png().
toBuffer();
delete item._gradient_buffer;
Object.defineProperty(item, "_gradient_buffer", {
configurable: true,
writable: true,
enumerable: false,
value: buffer,
})
return buffer;
}
image.createOutline = function(item)
{
const width = lib.toNumber(item.width, { min: 0 });
const height = lib.toNumber(item.height, { min: 0 }) || width;
const filter = `
<filter id="filter">
<feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="5"></feMorphology>
<feFlood flood-color="#32DFEC" flood-opacity="0.5" result="PINK"></feFlood>
<feComposite in="PINK" in2="DILATED" operator="in" result="OUTLINE"></feComposite>
<feMerge>
<feMergeNode in="OUTLINE" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>`;
const svg = `
<svg width="${width}" height="${height}">
<text
x="50%"
y="50%"
text-anchor="middle"
dominant-baseline="middle"
font-family="sans-serif"
font-size="${fontSize}"
font-weight="${fontWeiight}"
fill="${textColor}"
stroke="${outlineColor}"
stroke-width="${strokeWidth}"
stroke-linejoin="miter"
paint-order="stroke"
filter="${filter}">
${item.outline}
</text>
</svg>`
return sharp({ input: Buffer.from(svg) });
}
image.createText = function(item)
{
const width = lib.toNumber(item.width, { min: 0 });
const height = lib.toNumber(item.height, { min: 0 }) || width;
let text = lib.isString(item.text).replaceAll("\\n", "\n");
const attrs = [];
for (const p of ["_color", "color", "bgcolor", "size", "weight", "style", "stretch",
"strikethrough", "strikethrough_color",
"underline", "underline_color",
"line_height", "segment", "letter_spacing", "allow_breaks", "rise", "baseline_shift",
"font_features", "gravity_hint", "text_gravity",
"alpha", "bgalpha"]) {
let val = item[p];
if (val) {
if (p.endsWith("alpha") && val.at(-1) != "%") val += "%";
attrs.push(`${p.replace(/^_|^text_/, "")}="${val}"`);
}
}
if (attrs.length) text = `<span ${attrs.join(" ")}>${text}</span>`;
logger.debug("composite:", "text:", item.id, text);
return sharp({
text: {
rgba: true,
text,
width: width || undefined,
height: !item.dpi && height || undefined,
font: item.font || undefined,
fontfile: item.fontfile || undefined,
dpi: lib.toNumber(item.dpi) || undefined,
spacing: lib.toNumber(item.spacing) || undefined,
align: item.align || undefined,
justify: lib.toBool(item.justify),
wrap: item.wrap || "word",
}
});
}
image.createImage = async function(item)
{
const width = lib.toNumber(item.width, { min: 0 });
const height = lib.toNumber(item.height, { min: 0 }) || width;
let input = lib.isString(item.file);
if (item.data instanceof sharp) {
input = await item.data.toBuffer();
} else
if (Buffer.isBuffer(item.data)) {
input = item.data;
} else
if (lib.isString(item.data).startsWith("data:image/")) {
input = Buffer.from(item.data.substr(item.data.indexOf("base64,") + 7), "base64");
}
let img = sharp(input, item.sharp);
if (width || height) {
img = await img.resize({
width,
height,
fit: item.fit || undefined,
position: item.position || undefined,
kernel: item.kernel || undefined,
background: item.background,
withoutEnlargement: item.withoutEnlargement,
withoutReduction: item.withoutReduction,
fastShrinkOnLoad: item.fastShrinkOnLoad,
}).toBuffer();
img = sharp(img);
}
return img;
}
/**
* Create an image from a photo or text, supports raunded corners, borders
* @param {object} item
* @param {string|Buffer} [item.file] - input file name
* @param {Buffer|string} [item.data] - image buffer or data:image/*;base64, takes precedence over file name
* @param {int} [item.width] - desired width, max width for text
* @param {int} [item.height] - desired height, defaults to width if not given
* @param {string} [item.background=#FFFFFF] - main background color
* @param {int} [item.border] - border size in px
* @param {string} [item.border_color=FFFFFFF0] - border color
* @param {number} [item.border_radius] - make border round, this is divider of the size
* @param {number} [item.padding] - transparent padding around image
* @param {number} [item.padding_x]
* @param {number} [item.padding_y]
* @param {number} [item.padding_top]
* @param {number} [item.padding_bottom]
* @param {number} [item.padding_left]
* @param {number} [item.padding_right]
* @param {int} [item.padding_color] - padding background color
* @param {number} [item.padding_radius] - make padding round
* @param {string} [item.fit] - resize mode
* @param {string} [item.position]
* @param {string} [item.kernel]
* @param {string} [item.gravity]
* @param {boolean} [item.withoutEnlargement]
* @param {boolean} [item.withoutReduction]
* @param {boolean} [item.fastShrinkOnLoad]
* @param {number} [item.radius] - make it round, inner and outer layers, this is divider of the size
* @param {string} [item.text] - text to render
* @param {string} [item.color] - text color
* @param {string} [item.alpha] - text opacity 0-65535 or 50%
* @param {string} [item.bgalpha] - text background opacity 0-65535 or 50%
* @param {string} [item.font] - font family
* @param {string} [item.fontfile] - font file
* @param {string} [item.weight] - One of `ultralight, light, normal, bold, ultrabold, heavy`, or a numeric weight.
* @param {string} [item.size] - font size in 1024ths of a point, or in points (e.g. 12.5pt), or one of the absolute sizes
* xx-small, x-small, small, medium, large, x-large, xx-large, or a percentage (e.g. 200%), or one of
* the relative sizes smaller or larger.
* @param {string} [item.stretch] - One of `ultracondensed, extracondensed, condensed, semicondensed, normal, semiexpanded,
* expanded, extraexpanded, ultraexpanded`.
* @param {string} [item.style] - One of `normal, oblique, italic`.
* @param {int} [item.spacing] - font spacing
* @param {int} [item.dpi=72] - text size in DPI
* @param {boolean} [item.justify=true] - text justification
* @param {string} [item.wrap] - text wrapping if width is provided: `word, char, word-char, none`
* @param {string} [item.align] - text aligment: `left, centre, center, right`
* @param {string} [item.blend] - how to blend image, one of `clear, source, over, in, out, atop, dest,
* dest-over, dest-in, dest-out, dest-atop, xor, add, saturate, multiply, screen, overlay, darken,
* lighten, colour-dodge, color-dodge, colour-burn,color-burn, hard-light, soft-light, difference, exclusion`.
* @param {object} [item.sharp] - additional raw Sharp item
* @returns {Promise} - a sharp object containing an image, as PNG
* @memberof module:image
* @async
*/
image.create = async function(item)
{
logger.debug("create:", image.name, "start:", item);
const images = [];
const border = lib.toNumber(item.border, { min: 0 });
const radius = lib.toNumber(item.radius, { min: 0 });
const padding = image.padding(item);
let width = lib.toNumber(item.width, { min: 0 });
let height = lib.toNumber(item.height, { min: 0 }) || width;
if (!sharp) sharp = lib.tryRequire('sharp');
var innerImage;
if (item.data instanceof sharp && !(width || height)) {
innerImage = item.data;
} else
if (item.file || item.data) {
innerImage = await image.createImage(item);
} else
if (item.text) {
innerImage = image.createText(item);
} else
if (item.outline) {
innerImage = image.createOutline(item);
} else {
innerImage = sharp({
create: {
width: width || 1280,
height: height || 1024,
channels: 4,
background: item.background || image.background,
}
});
}
const meta = await innerImage.metadata();
width = item._inner_width = meta.width;
height = item._inner_height = meta.height;
const cw = width + border * 2;
const ch = height + border * 2;
if (radius) {
innerImage.
composite([{
input: Buffer.from(`<svg><rect x="0" y="0" width="${width}" height="${height}" rx="${Math.round(width/radius)}" ry="${Math.round(height/radius)}"/></svg>`),
blend: item.radius_blend || item.blend || 'dest-in'
}]);
}
if (border) {
let border_color = item.border_color;
if (border_color && item.border_alpha) {
border_color += Math.round(Number(255*lib.toNumber(item.border_alpha, { min: 0, max: 100 })/100)).toString(16).padStart(2, "0");
}
const borderImage = await sharp({
create: {
width: cw,
height: ch,
channels: 4,
background: border_color || { r: 255, g: 255, b: 255, alpha: 0.5 }
}
});
const bradius = lib.toNumber(item.border_radius || radius, { min: 0 });
if (bradius) {
borderImage.composite([{
input: Buffer.from(`<svg><rect x="0" y="0" width="${cw}" height="${ch}" rx="${Math.round(cw/bradius)}" ry="${Math.round(ch/bradius)}"/></svg>`),
blend: item.border_blend || item.blend || 'dest-in'
}]);
}
images.push({
input: await borderImage.png().toBuffer(),
top: padding.top,
left: padding.left,
});
}
// Place inside the border background
images.push({
input: await innerImage.png().toBuffer(),
top: border + padding.top,
left: border + padding.left,
});
let padding_color = item.padding_color;
if (padding_color && item.padding_alpha) {
padding_color += Math.round(Number(255*lib.toNumber(item.padding_alpha, { min: 0, max: 100 })/100)).toString(16).padStart(2, "0");
}
const pw = cw + padding.left + padding.right;
const ph = ch + padding.top + padding.bottom;
const outterImage = sharp({
create: {
width: pw,
height: ph,
channels: 4,
background: padding_color || { r: 0, g: 0, b: 0, alpha: 0 }
}
});
const pradius = lib.toNumber(item.padding_radius, { min: 0 });
if (pradius) {
images.push({
input: Buffer.from(`<svg><rect x="0" y="0" width="${pw}" height="${ph}" rx="${Math.round(pw/pradius)}" ry="${Math.round(ph/pradius)}"/></svg>`),
blend: item.padding_blend || item.blend || 'dest-in'
});
}
logger.debug("create:", image.name, "done:", item.id, width, height, "CW:", cw, ch, "PW:", pw, ph, "B:", border, "I:", images.length, "P:", padding);
const img = await image.convert(outterImage.composite(images), item);
delete item.buffer;
Object.defineProperty(item, "_buffer", {
configurable: true,
writable: true,
enumerable: false,
value: await img.toBuffer(),
})
delete item.meta;
Object.defineProperty(item, "_meta", {
configurable: true,
writable: true,
enumerable: false,
value: await img.metadata(),
});
return img;
}
/**
* Blend multiple images together, first image is used as the background for remaning items
* @param {object[]} items - list of objects representing an image or text, properties are given to {@link module:image.create},
* additional string property `id` can be used to describe or distinguish items from each other
* @param {object} [defaults] - defaults global for all items, same parameters
* @returns {object[]} - same items list with additional properties: buffer, meta
* @memberof module:image
* @async
*/
image.composite = async function(items, defaults)
{
logger.debug("composite:", image.name, items, defaults);
items = lib.isArray(items, []).
map(item => image.mergeDefaults(item, defaults)).
filter(item => (item.file || item.data || item.text || item.outline));
if (!items.length) return items;
// Use the first image as background, no padding
const bgitem = image.mergeDefaults(items[0], defaults);
for (const p in bgitem) {
if (p.startsWith("padding")) delete bgitem[p];
}
const bgimage = await image.create(bgitem);
// Render remaning elements individually, preseve the order
const other = items.slice(1);
await Promise.all(other.map(async (item) => {
// Autodetect color for texts
if ((item.text || item.outline) && !item.color) {
await image.detect(item, bgimage);
}
await image.create(item);
if (lib.isFlag(lib.split(item.gradient), ["1", "true", item.type, item.id])) {
await image.createGradient(item);
}
}));
// Composite all elements onto the background
const buffers = [];
other.forEach(async (item) => {
if (item._gradient_buffer) {
buffers.push({
input: item._gradient_buffer,
blend: item.gradient_blend || undefined,
gravity: item.gravity || undefined,
top: item.top || undefined,
left: item.left || undefined,
})
}
buffers.push({
input: item._buffer,
gravity: item.gravity || undefined,
blend: item.blend || undefined,
top: item.top || undefined,
left: item.left || undefined
});
});
bgimage.composite(buffers);
delete bgitem.buffer;
Object.defineProperty(bgitem, "_buffer", {
configurable: true,
writable: true,
enumerable: false,
value: await bgimage.png().toBuffer(),
})
delete bgitem.meta;
Object.defineProperty(bgitem, "_meta", {
configurable: true,
writable: true,
enumerable: false,
value: await bgimage.toBuffer(),
})
logger.debug("composite:", image.name, "bg:", bgitem);
return items;
}