util/image.js

/*
 *  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: "#28282B",
    white: "#F9F9F3",
    background: "#FFFFFF",
    transparent: { r: 0, g: 0, b: 0, alpha: 0 },
};

/**
 * Scaling and composing images with Sharp.js
 */

module.exports = image;

var sharp;

function setProp(item, name, value)
{
    delete item[name];
    Object.defineProperty(item, name, { configurable: true, writable: true, enumerable: false, value });
    return value;
}

/**
 * Convert a value to a non-negative number, optionally scaling fractional values by a max.
 *
 * If `num` is between 0 and 1 (exclusive) and `max` is provided, the value is treated as
 * a ratio and converted to an integer by multiplying by `max` and rounding.
 *
 * Examples:
 * - `toNumber("10")` -> `10`
 * - `toNumber(0.5, 200)` -> `100`
 *
 * @param {number|number[]} num - Value to convert to a number, if array first number will be used
 * @param {number} [max] - Maximum used to scale fractional values (0 < num < 1).
 * @returns {number} A non-negative number (ratio scaled and rounded when applicable).
 */
image.toNumber = function(num, max)
{
    if (Array.isArray(num)) {
        num = lib.validNumber(...num);
    }
    num = lib.toNumber(num, { min: 0 });
    if (num > 0 && num < 1 && max > 0) {
        num = Math.round(num * max);
    }
    return num;
}

/**
 * Append opacity to the hex color
 * @param {string} color - hex color as #RRGGBB
 * @param {number} alpha - opacity as percentage 0-100%
 * @returns {string} hex color as #RRGGBBAA
 */
image.toColor = function(color, alpha)
{
    if (lib.isString(color)[0] == "#" && alpha >= 0 && alpha <= 100) {
        color = color.substr(0, 7) + Math.round(Number(255*lib.toNumber(alpha, { min: 0, max: 100 })/100)).toString(16).padStart(2, "0");
    }
    return color;
}

/**
 * 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
 *
 * Values >= 1 are in pixels, > 0 and < 1 in percentages relative to the background dimenstions
 *
 * @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.
 * @param {number} [width] - max width to be used for fractions
 * @param {number} [height] - max height to be used for fractions
 * @returns {{top:number,left:number,right:number,bottom:number}} Padding rectangle.
 * @memberof module:image
 */
image.padding = function(item, width, height)
{
    return {
        top: this.toNumber(item.padding_top || item.padding_y || item.padding, height),
        left: this.toNumber(item.padding_left || item.padding_x || item.padding, width),
        right: this.toNumber(item.padding_right || item.padding_x || item.padding, width),
        bottom: this.toNumber(item.padding_bottom || item.padding_y || item.padding, height)
    }
}

/**
 * Return region corresponding to the item gravity or absolute coordinates (top, left ,width, height)
 * @param {object} item object
 * @param {int} width - background image width
 * @param {int} height - background image height
 * @returns {object} - object { left, top, width, height }
 * @memberof module:image
 */
image.region = function(item, 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[item.gravity] || map.center;
    return {
        id: item.id,
        top: this.toNumber(item.top, height) || row * h,
        left: this.toNumber(item.left, width) || col * w,
        width: item._meta?.width || this.toNumber(item.width, width) || h,
        height: item._meta?.height || this.toNumber(item.height, 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 key properties:
 * - `dominant` - dominant color
 * - `mean` - mean color as RGB
 * - `stdev` - standard deviation 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;

        input = sharp(await sharp(input).extract(r).toBuffer());
    } else {
        input = Buffer.isBuffer(input) ? await sharp(input) : input;
    }

    const stats = await input.stats();
    stats.mean = {
        r: lib.toNumber(stats.channels[0]?.mean, { float: 0 }),
        g: lib.toNumber(stats.channels[1]?.mean, { float: 0 }),
        b: lib.toNumber(stats.channels[2]?.mean, { float: 0 }),
    };
    stats.stdev = {
        r: lib.toNumber(stats.channels[0]?.stdev, { float: 0 }),
        g: lib.toNumber(stats.channels[1]?.stdev, { float: 0 }),
        b: lib.toNumber(stats.channels[2]?.stdev, { float: 0 }),
    };
    stats._stdev = stats.channels.reduce((sum, c) => sum + lib.toNumber(c.stdev, { float: 0 }), 0) / stats.channels.length;

    stats.meta = await input.metadata();
    setProp(stats, "_image", input);

    logger.debug("stats:", image.name, "done:", region, "stats:", stats);
    return stats;
}

/**
 * Return metadata about the input
 * @param {object|Buffer} input - buffer or Sharp instance
 * @return {object} - { buffer, meta }
 * @async
 * @memberof module:image
 */
image.metadata = async function(input)
{
    if (!sharp) sharp = lib.tryRequire('sharp');

    const buffer = Buffer.isBuffer(input) ? input : await input.png().toBuffer();
    input = Buffer.isBuffer(input) ? sharp(input) : input;
    return {
        buffer,
        meta: await input.metadata(),
    }
}

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 (!sharp) sharp = lib.tryRequire('sharp');

    const filters = item.filters || [];

    // Common shortcuts
    if (item.blur_sigma) {
        if (item.blur_sigma == "auto") {
            const stats = item._stats;
            item.blur_sigma = stats.entropy > 7.5 ? 7 :
                              stats.entropy > 7.3 ? 6 :
                              stats.entropy > 7.1 ? 5 :
                              stats.entropy > 6.8 ? 4 :
                              stats.entropy > 6.3 ? 3 :
                              stats.entropy > 6 ? 2 : 1;
        }
        filters.push({ name: "blur", value: { sigma: lib.toNumber(item.blur_sigma) } });
    }

    if (!filters.length) return input.png();

    input = sharp(Buffer.isBuffer(input) ? input : await input.png().toBuffer());

    for (const filter of 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} bgitem - background item generated
 * @memberof module:image
 * @async
 */
image.detect = async function(item, bgitem)
{
    await image.create(item, bgitem);

    const region = image.region(item, bgitem._meta.width, bgitem._meta.height);

    logger.debug("detect:", image.name, "start:", item.id, "region:", region);

    const stats = await image.stats(bgitem._buffer, region);

    let _color = color.negate(stats.dominant);

    lib.split(item.text_auto || "1").forEach(x => {
        const old = _color;
        switch (x) {
        case "dominant":
            _color = color.negate(stats.dominant);
            break;

        case "mean":
            _color = color.negate(stats.mean);
            break;

        case "stdev":
            _color = color.negate(stats.stdev);
            break;

        case "complement":
            _color = color.rotate(_color);
            break;

        case "lighter":
            _color = color.lighter(_color);
            break;

        case "darker":
            _color = color.darker(_color);
            break;

        case "softlight":
            _color = color.rluminance(_color) > 0.179 ? color.lighter(_color) : color.darker(_color);
            break;

        case "luminance":
        default:
            _color = color.rluminance(_color) > 0.179 ? color.hex2rgb(item.white || image.white) : color.hex2rgb(item.black || image.black);
            break;
        }
        logger.debug("detect:", image.name, item.id, x, _color, "old:", old);
    });

    item._color = color.hex(_color);
    setProp(item, "_detect", { color: item._color, _color, stats });

    logger.debug("detect:", image.name, "done:", item, "STATS:", stats);

    return stats;
}


/**
 * Clip (crop) an item's image buffer so it fits within the background item's bounds.
 *
 * If the item is completely outside the background, it marks it as skipped.
 *
 * Side effects:
 * - May set `item._skip = 1` when the item is fully outside the background.
 * - Replaces `item._buffer` with the cropped image buffer.
 * - Replaces `item._meta` with metadata of the cropped image.
 *
 * @async
 * @memberof module:image
 * @param {Object} item item to be clipped.
 * @param {Buffer} item._buffer Source image buffer.
 * @param {Object} item._meta Source image metadata.
 * @param {number} item._meta.width Source image width.
 * @param {number} item._meta.height Source image height.
 * @param {(number|string)} [item.top] Top position (relative to background).
 * @param {(number|string)} [item.left] Left position (relative to background).
 * @param {string|number} [item.id] Item identifier (used for logging).
 * @param {Object} bgitem Background item that defines the clipping bounds.
 * @param {Object} bgitem._meta Background metadata.
 * @param {number} bgitem._meta.width Background width.
 * @param {number} bgitem._meta.height Background height.
 * @returns {Promise} Resolves when clipping is complete. Returns early if no clipping is needed.
 */
image.clip = async function(item, bgitem)
{
    const { width, height } = bgitem._meta;
    const w = item._meta.width, h = item._meta.height;
    const region = { top: 0, left: 0, width: 0, height: 0 };

    // Absolute position
    if (lib.isNumber(item.top) && lib.isNumber(item.left)) {
        const top = this.toNumber(item.top, height);
        const left = this.toNumber(item.left, width);
        if (top >= height || left >= width) {
            item._skip = 1;
            return;
        }
        if (top + h > height) region.height = height - top;
        if (left + w > width) region.width = width - left;

    } else {
        switch (item.gravity) {
        case "north":
            if (h > height) {
                region.height = height;
            }
            if (w > width) {
                region.left = Math.round((w - width)/2);
                region.width = width;
            }
            break;

        case "south":
            if (h > height) {
                region.height = height;
                region.top = h - height;
            }
            if (w > width) {
                region.left = Math.round((w - width)/2);
                region.width = width;
            }
            break;

        case "west":
            if (h > height) {
                region.top = Math.round((h - height)/2);
                region.height = height;
            }
            if (w > width) {
                region.width = width;
            }
            break;

        case "east":
            if (h > height) {
                region.top = Math.round((h - height)/2);
                region.height = height;
            }
            if (w > width) {
                region.left = w - width;
                region.width = width;
            }
            break;

        case "northwest":
            if (h > height) {
                region.height = height;
            }
            if (w > width) {
                region.width = width;
            }
            break;

        case "northeast":
            if (h > height) {
                region.height = height;
            }
            if (w > width) {
                region.left = w - width;
                region.width = width;
            }
            break;

        case "southwest":
            if (h > height) {
                region.height = height;
                region.top = h - height;
            }
            if (w > width) region.width = width;
            break;

        case "southeast":
            if (h > height) {
                region.height = height;
                region.top = h - height;
            }
            if (w > width) {
                region.left = w - width;
                region.width = width;
            }
            break;
        }
    }

    if (region.width || region.height) {
        region.width = region.width || w;
        region.height = region.height || h;

        logger.debug("clip:", image.name, item.id, item.gravity, w, h, "region:", region);

        const img = sharp(await sharp(item._buffer).extract(region).toBuffer());

        setProp(item, "_buffer", await img.toBuffer());
        setProp(item, "_meta", await img.metadata());
    }
}

/**
 * Stitch multiple images into a single vertically stacked image.
 *
 * Each input image is loaded and normalized via {@link module:image/metadata}, then composited onto a
 * transparent RGBA background. Images are placed in the order provided, starting at the top (0),
 * with each subsequent image positioned directly below the previous one.
 *
 * The resulting canvas size is:
 * - width: max width of all successfully processed images
 * - height: sum of heights of all successfully processed images
 *
 * @async
 * @memberof module:image
 * @param {Array<Buffer|string|Object>} images
 *   List of images to stitch. Each item must be accepted by {@link image.metadata}
 *   (commonly a Buffer, file path, URL, or an object supported by that helper).
 * @param {object} [options]
 * @param {boolean} [options.same_height] - add padding so all images are the same height
 * @returns {Promise<Sharp>}
 *   A Sharp instance containing the stitched PNG image.
 *
 * @throws {Error}
 *   May throw if creating/compositing/encoding fails, or if the input set produces an invalid
 *   canvas size (e.g., no valid images).
 */
image.stitch = async function(images, options)
{
    const results = await Promise.allSettled(images.map(async (img) => (image.metadata(img))));

    let width = 0, height = 0;
    const max_height = Math.max(...results.map(res => res.value.meta.height));
    const max_padding = Math.max(...results.map(res => max_height - res.value.meta.height));

    const buffers = [];

    results.forEach(res => {
        if (!res.value) {
            logger.error("stitch:", image.name, res);
            return;
        }
        const pos = { input: res.value.buffer, left: 0, top: height };
        buffers.push(pos);
        width = Math.max(width, res.value.meta.width);
        height += res.value.meta.height;

        if (options?.same_height) {
            const padding = max_height - res.value.meta.height;
            height += padding;
            pos.top += Math.floor(padding/2 || max_padding/2);
        }
    });

    const bg = sharp({
            create: { width, height, channels: 4, background: image.transparent }
        });

    return sharp(await bg.composite(buffers).png().toBuffer());
}

/**
 * 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 {Promise<object>} Sharp instance with rendered image
 * @memberof module:image
 * @async
 */
image.createGradient = async function(item, bgitem)
{
    const width = Math.max(item._meta.width, item._inner_width * 2);
    const height = Math.max(item._meta.height, item._inner_height * 2);

    const ncolor = color.negate(color.hex2rgb(item.color || item._color || "#fff"));

    var gcolor = item.gradient_color;
    if (!gcolor) {
        gcolor = item._gcolor = color.rluminance(ncolor) > 0.179 ? image.white : image.black;
    }

    const images = [
        {
            input: Buffer.from(`
            <svg width="${width}" height="${height}">
                <defs>
                    <radialGradient id="grad">
                        <stop offset="0%"  stop-color="${gcolor}" stop-opacity="0.8"/>
                        <stop offset="10%" stop-color="${gcolor}" stop-opacity="0.8"/>
                        <stop offset="20%" stop-color="${gcolor}" stop-opacity="0.75"/>
                        <stop offset="30%" stop-color="${gcolor}" stop-opacity="0.7"/>
                        <stop offset="40%" stop-color="${gcolor}" stop-opacity="0.6"/>
                        <stop offset="50%" stop-color="${gcolor}" stop-opacity="0.45"/>
                        <stop offset="60%" stop-color="${gcolor}" stop-opacity="0.3"/>
                        <stop offset="70%" stop-color="${gcolor}" stop-opacity="0.2"/>
                        <stop offset="80%" stop-color="${gcolor}" stop-opacity="0.1"/>
                        <stop offset="90%" stop-color="${gcolor}" stop-opacity="0.05"/>
                        <stop offset="100%" stop-color="${gcolor}" stop-opacity="0"/>
                    </radialGradient>
                </defs>
                <rect width="${width}" height="${height}" fill="url(#grad)" />
            </svg>`)
        },
        {
            input: item._buffer,
        },
    ];

    const region = { top: 0, left: 0, width, height };
    const padding = image.padding(item, bgitem._meta.width, bgitem._meta.height)

    const h = Math.round(height/4);
    const w = Math.round(width/4);

    switch (item.gravity) {
    case "northwest":
        region.top += h - padding.top;
        region.left += w - padding.left;
        break;
    case "north":
        region.top += h - padding.top;
        break;
    case "northeast":
        region.top += h - padding.top;
        region.width -= w - padding.right;
        break;
    case "west":
        region.left += w - padding.left;
        break;
    case "east":
        region.width -= w - padding.right;
        break;
    case "southwest":
        region.height -= h - padding.bottom;
        region.left += w - padding.left;
        break;
    case "south":
        region.height -= h - padding.bottom;
        break;
    case "southeast":
        region.height -= h - padding.bottom;
        region.width -= w - padding.right;
        break;
    }

    if (region.top + region.height > height) region.height = height - region.top;
    if (region.left + region.width > width) region.width = width - region.left;

    logger.debug("gradient:", image.name, item.id, "t:", item._color, item.color, "n:", ncolor, "g:", gcolor, "region:", width, height, region)

    const buffer = await sharp({
        create: {
            width,
            height,
            channels: 4,
            background: image.transparent
        }
    }).composite(images).
       png().
       toBuffer();

    const img = sharp(await sharp(buffer).extract(region).toBuffer());

    setProp(item, "_buffer", await img.toBuffer());
    setProp(item, "_meta", await img.metadata());

    return img;
}

/**
 * Render an SVG text block (optionally with stroke + outline dilation or drop shadow) into an image buffer,
 * trimming each line and stitching multiple lines together when needed.
 *
 * The function builds an SVG based on `item` properties, renders it with `sharp`, then:
 * - if `item.text` contains multiple lines (`\n`), renders each line separately and stitches results
 * - otherwise returns a single rendered image
 *
 * Supported effects (mutually exclusive; dilation takes precedence over shadow):
 * - Dilation/outline via SVG filter when `item.dilate_radius|dilate_alpha|dilate_color` is set
 * - Drop shadow via SVG filter when `item.shadow_width|shadow_alpha|shadow_color` is set
 *
 * Notes:
 * - Width/height and padding may be derived from `bgitem._meta.width/height`.
 * - `item.text` supports simple Pango-like tags: `<b>`, `<i>`, `<small>`, `<big>`, and closing tags `</...>`
 *   which are mapped to `<tspan>` elements.
 * - `item.size` can be a fraction (0..1) meaning a percentage of background height.
 *
 * @async
 * @memberof module:image
 * @param {Object} item - Text/rendering configuration.
 * @param {string} [item.text] - Text to render; `\\n` and `\n` create multiple lines.
 * @param {string|number} [item.width] - Output width; if omitted, derived from background width minus padding.
 * @param {string|number} [item.height] - Output height; if omitted, derived from background height minus padding.
 * @param {string} [item.color="#fff"] - Text fill color.
 * @param {number} [item.alpha] - Fill opacity/alpha (implementation-specific; passed to `toColor`).
 * @param {string} [item._color] - Fallback internal fill color.
 *
 * @param {string} [item.stroke_color] - Stroke color; if omitted it is auto-picked (black/white) based on fill luminance.
 * @param {number} [item.stroke_alpha] - Stroke opacity/alpha (passed to `toColor`).
 * @param {number} [item.stroke_width=0] - Stroke width.
 * @param {string} [item.stroke_linejoin="miter"] - Stroke line join.
 * @param {string} [item.paint_order="stroke"] - SVG `paint-order` for text (e.g. "stroke fill").
 *
 * @param {string} [item.font="sans-serif"] - Font family.
 * @param {number|string} [item.size=50] - Font size. If numeric between 0 and 1, treated as fraction of background height.
 * @param {string} [item.weight] - Weight keyword mapped to SVG font-weight ("ultralight","light","ultrabold","heavy","bold").
 * @param {string} [item.style="normal"] - Font style.
 * @param {string} [item.stretch="normal"] - Font stretch.
 * @param {string} [item.baseline="hanging"] - SVG `dominant-baseline`.
 *
 * @param {number} [item.dilate_radius=5] - Dilation radius for outline filter (enables dilation effect if set).
 * @param {number} [item.dilate_alpha=25] - Outline opacity in percent (0-100).
 * @param {string} [item.dilate_color] - Outline color; defaults to fill color.
 *
 * @param {number} [item.shadow_width=3] - Shadow offset in px for x/y (enables shadow effect if set).
 * @param {number} [item.shadow_blur=3] - Shadow blur deviation.
 * @param {number} [item.shadow_alpha=50] - Shadow opacity in percent (0-100).
 * @param {string} [item.shadow_color] - Shadow color; defaults to negated fill color (or black).
 *
 * @param {number} [item.wrap] - a factor for text wrapping, using naive formula: `width/fontSize*item.wrap`
 *
 * @param {Object} [bgitem] - Background item used for sizing/padding.
 * @param {Object} [bgitem._meta]
 * @param {number} [bgitem._meta.width] - Background width used to resolve relative sizes.
 * @param {number} [bgitem._meta.height] - Background height used to resolve relative sizes.
 *
 * @returns {Promise<Sharp>} A Sharp instance for the rendered outline/shadow text image.
 */
image.createOutline = async function(item, bgitem)
{
    const _w = bgitem?._meta?.width;
    const _h = bgitem?._meta?.height;
    const padding = this.padding(item, _w, _h);
    const width = this.toNumber(item.width, _w) || (_w - padding.left);
    const height = this.toNumber(item.height, _h) || (_h - padding.top);

    const fcolor = this.toColor(item.color || item._color || "#fff", item.alpha);
    const ncolor = color.negate(color.hex2rgb(fcolor));

    let scolor = this.toColor(item.stroke_color, item.stroke_alpha);
    if (!scolor) {
        scolor = item._scolor = this.toColor(color.rluminance(ncolor) > 0.179 ? image.white : image.black, item.stroke_alpha);
    }

    let fontSize = item.size;
    if (lib.isNumeric(fontSize) && fontSize > 0 && fontSize < 1) {
        fontSize = this.toNumber(fontSize, _h);
    }
    const fontWeight = { ultralight: "ligher", light: "lighter", ultrabold: "bolder", heavy: "bold", bold: "bold" }[item.weight];

    let lines = lib.split(lib.isString(item.text).replaceAll("\\n", "\n"), "\n");

    // Naive approximate wrapping
    if (item.wrap > 0 && fontSize > 0) {
        lines = lines.flatMap(line => lib.split(lib.wrap(line, { wrap: (width / fontSize) * item.wrap, over: 0.5 }), "\n"))
    }

    let filter = "";

    if (item.dilate_radius || item.dilate_alpha || item.dilate_color) {
        filter = `
        <filter id="filter">
            <feMorphology in="SourceAlpha" result="DILATED" operator="dilate" radius="${item.dilate_radius || 5}"></feMorphology>
            <feFlood flood-color="${item.dilate_color || fcolor}" flood-opacity="${item.dilate_alpha/100 || 0.25}" result="FLOOD"></feFlood>
            <feComposite in="FLOOD" in2="DILATED" operator="in" result="OUTLINE"></feComposite>
            <feMerge>
                <feMergeNode in="OUTLINE" />
                <feMergeNode in="SourceGraphic" />
            </feMerge>
        </filter>`;
    } else

    if (item.shadow_width || item.shadow_alpha || item.shadow_color) {
        const scolor = item.shadow_color || color.hex(ncolor) || "#000";

        filter = `
        <filter id="filter" x="0" y="0" width="200%" height="200%">
            <feDropShadow dx="${item.shadow_width || 3}"
                          dy="${item.shadow_width || 3}"
                          stdDeviation="${item.shadow_blur || 3}"
                          flood-color="${scolor}"
                          flood-opacity="${item.shadow_alpha/100 || 0.5}"/>
        </filter>`;
    }

    const results = await Promise.allSettled(lines.map(async (line) => {
        // Pango compatibility
        line = line.replace(/<([^>]+)>/gi, (tag, name) => {
            if (name[0] == "/") return "</tspan>";
            switch (name) {
            case "b": return `<tspan font-weight="bolder">`
            case "i": return `<tspan font-style="italic">`
            case "small": return `<tspan font-size="smaller">`
            case "big": return `<tspan font-size="larger">`
            default: return "<tspan>"
            }
        });

        const svg = `
            <svg width="${width}" height="${height}">
                ${filter}
                <text
                    x="${padding.left}"
                    y="${padding.top}"
                    dominant-baseline="${item.baseline || "hanging"}"
                    font-family="${item.font || "sans-serif"}"
                    font-size="${fontSize || 50}"
                    font-weight="${fontWeight || "normal"}"
                    font-style="${item.style || "normal"}"
                    font-stretch="${item.stretch || "normal"}"
                    fill="${fcolor}"
                    stroke="${scolor}"
                    stroke-width="${item.stroke_width || 0}"
                    stroke-linejoin="${item.stroke_linejoin || "miter"}"
                    paint-order="${item.paint_order || "stroke"}"
                    filter="url(#filter)">${line}</text>
            </svg>`;

        logger.debug("outline:", image.name, item.id, svg);
        return sharp(Buffer.from(svg)).trim({ threshold: 1, lineArt: true }).toBuffer();
    }));

    if (results.length == 1) {
        return sharp(results[0].value);
    }

    return image.stitch(results.map(x => x.value), { same_height: true });
}

/**
 * Create a Sharp image instance that renders a text block using Pango rendering.
 *
 * Builds an optional `<span ...>...</span>` wrapper around the provided text using supported
 * attributes (color, size, weight, underline, etc.), normalizes newline sequences (`"\\n"` -> `"\n"`),
 * and passes sizing/font/layout options to `sharp({ text: ... })`.
 *
 * @param {Object} item - Text render options.
 * @param {number|string} [item.width] - Target width. If not provided, falls back to `bgitem._meta.width`.
 * @param {number|string} [item.height] - Target height. If not provided, falls back to `bgitem._meta.height`.
 * @param {string} item.text - Text to render. `"\\n"` sequences are converted to newlines and trimmed.
 * @param {string} [item.font] - Font family name to use.
 * @param {string} [item.fontfile] - Path to a font file to use.
 * @param {number|string} [item.dpi] - Text rendering DPI. When set, height is not passed to Sharp.
 * @param {number|string} [item.spacing] - Line/paragraph spacing passed to Sharp.
 * @param {string} [item.align] - Text alignment passed to Sharp (e.g. "left", "center", "right").
 * @param {boolean|string|number} [item.justify] - Whether to justify text (coerced via `lib.toBool`).
 * @param {string} [item.wrap="word"] - Wrapping mode passed to Sharp.
 *
 * @param {string} [item._color] - Text color (becomes `color="..."` attribute).
 * @param {string} [item.color] - Text color.
 * @param {string} [item.bgcolor] - Background color.
 * @param {string|number} [item.size] - Font size, Pango format or a fraction of the height if < 1
 * @param {string|number} [item.weight] - Font weight.
 * @param {string} [item.style] - Font style.
 * @param {string|number} [item.stretch] - Font stretch.
 * @param {string|boolean} [item.strikethrough] - Strikethrough enable/value.
 * @param {string} [item.strikethrough_color] - Strikethrough color.
 * @param {string|boolean} [item.underline] - Underline enable/value.
 * @param {string} [item.underline_color] - Underline color.
 * @param {string|number} [item.line_height] - Line height.
 * @param {string|number} [item.segment] - Pango segment attribute.
 * @param {string|number} [item.letter_spacing] - Letter spacing.
 * @param {string|boolean} [item.allow_breaks] - Allow line breaks.
 * @param {string|number} [item.rise] - Text rise.
 * @param {string|number} [item.baseline_shift] - Baseline shift.
 * @param {string} [item.font_features] - Font features string.
 * @param {string} [item.gravity_hint] - Gravity hint.
 * @param {string} [item.text_gravity] - Text gravity (stored as `gravity` attribute).
 * @param {string|number} [item.alpha] - Text alpha; if not ending in `%`, `%` is appended.
 * @param {string|number} [item.bgalpha] - Background alpha; if not ending in `%`, `%` is appended.
 *
 * @param {Object} [bgitem] - Optional background item used for default sizing.
 * @param {Object} [bgitem._meta]
 * @param {number} [bgitem._meta.width] - Default width fallback.
 * @param {number} [bgitem._meta.height] - Default height fallback.
 *
 * @returns {object} A Sharp instance configured with a text input.
 * @memberof module:image
 */
image.createText = function(item, bgitem)
{
    const width = this.toNumber(item.width, bgitem?._meta?.width);
    const height = this.toNumber(item.height, bgitem?._meta?.height);

    let text = lib.isString(item.text).replaceAll("\\n", "\n").trim();

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

        if (p.endsWith("alpha") && val.at(-1) != "%") {
            val += "%";
        } else
        if (p == "size") {
            if (item.dpi) val = null;
            if (lib.isNumeric(val)) {
                val = this.toNumber(val, bgitem?._meta?.height) + "pt";
            }
        }
        if (val) {
            attrs.push(`${p.replace(/^_|^text_/, "")}="${val}"`);
        }
    }
    if (attrs.length) text = `<span ${attrs.join(" ")}>${text}</span>`;

    logger.debug("text:", image.name, 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",
        }
    });
}

/**
 * Create a Sharp image instance from different input sources and optionally resize it.
 *
 * Accepts an image from a file path/URL (`item.file`), an existing Sharp instance (`item.data`),
 * a Buffer (`item.data`), or a base64 data URL (`item.data` starting with `"data:image/"`).
 * If `width`/`height` are provided (or derived from `bgitem._meta`), the image is resized and
 * a new Sharp instance is returned.
 *
 * @async
 * @memberof module:image
 * @param {Object} item
 * @param {number|string} [item.width] - Target width. If not provided, may be taken from `bgitem._meta.width`.
 * @param {number|string} [item.height] - Target height. If not provided, may be taken from `bgitem._meta.height`.
 *                                       If still missing, defaults to `width` (square).
 * @param {string} [item.file] - Image input as a file path/URL (anything Sharp can open).
 * @param {object|Buffer|string} [item.data] - Image input:
 *   - a Sharp instance (will be converted to Buffer),
 *   - a Buffer,
 *   - a base64 data URL beginning with `"data:image/"`.
 * @param {Object} [item.sharp] - Options passed to `sharp(input, item.sharp)` constructor.
 *
 * @param {string} [item.fit] - Sharp resize `fit` option (e.g. `"cover"`, `"contain"`, `"fill"`, `"inside"`, `"outside"`).
 * @param {string|Object} [item.position] - Sharp resize `position` option (gravity/strategy).
 * @param {string} [item.kernel] - Sharp resize `kernel` option.
 * @param {string|Object} [item.background] - Background color used for padding when applicable.
 * @param {boolean} [item.withoutEnlargement] - Do not enlarge if image is smaller than target size.
 * @param {boolean} [item.withoutReduction] - Do not reduce if image is larger than target size.
 * @param {boolean} [item.fastShrinkOnLoad] - Use fast shrink-on-load where supported.
 *
 * @param {Object} [bgitem] - Optional background item used only as a source of fallback dimensions.
 * @param {Object} [bgitem._meta]
 * @param {number} [bgitem._meta.width] - Fallback width if `item.width` is not provided.
 * @param {number} [bgitem._meta.height] - Fallback height if `item.height` is not provided.
 *
 * @returns {Promise<object>} A Sharp instance for the loaded (and possibly resized) image.
 */
image.createImage = async function(item, bgitem)
{
    const width = this.toNumber(item.width, bgitem?._meta?.width);
    const height = this.toNumber(item.height, bgitem?._meta?.height) || 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 a border image buffer with optional rounded corners.
 *
 * Builds an RGBA image of the given size filled with `item.border_color` (and `item.border_alpha`),
 * falling back to semi-transparent white when no color is provided. If a radius is specified
 * (`item.border_radius` or `item.radius`), it applies an SVG rounded-rectangle mask using a composite
 * operation (default `dest-in` or `item.border_blend`).
 *
 * @async
 * @memberof module:image
 * @param {Object} item - Border configuration.
 * @param {string|Object} [item.border_color] - Border color (passed through `this.toColor`).
 * @param {number} [item.border_alpha] - Alpha used with `border_color` (passed through `this.toColor`).
 * @param {number} [item.border_radius] - Corner radius (falls back to `item.radius`).
 * @param {number} [item.radius] - Alternate corner radius if `border_radius` is not provided.
 * @param {string} [item.border_blend='dest-in'] - Sharp composite blend mode used for the radius mask.
 * @param {number} width - Output image width in pixels.
 * @param {number} height - Output image height in pixels.
 * @returns {Promise<Buffer>} PNG-encoded image buffer.
 */
image.createBorder = async function(item, width, height)
{
    const border_color = this.toColor(item.border_color, item.border_alpha)

    const borderImage = await sharp({
        create: {
            width,
            height,
            channels: 4,
            background: border_color || { r: 255, g: 255, b: 255, alpha: 0.5 }
        }
    });

    const radius = lib.toNumber(item.border_radius || item.radius, { min: 0 });
    if (radius) {
        borderImage.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.border_blend || 'dest-in'
        }]);
    }

    return borderImage.png().toBuffer();
}

/**
 * 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
 * @param {number} [item.stroke_width] - trigger outlined text with given stroke width, color is used as the fill
 * @param {string} [item.stroke_color] - outline stroke color
 * @param {number} [item.stroke_alpha] - opacity for the outline fill color
 * @param {number} [item.dilate_radius] - trigger outline dilate filter with given radius around text
 * @param {string} [item.dilate_color] - outline delate filter color
 * @param {number} [item.dilate_alpha] - opacity for the outline delate color
 * @param {string} [item.stroke_linejoin] - dilate stroke line join, miter, arcs, bevel, round
 * @param {number} [item.shadow_width] - width of the drop shadow for SVG texts
 * @param {number} [item.shadow_alpha] - opacity for drop shadows
 * @param {string} [item.shadow_color] - drop shadow color, if not given oposite of the stroke is used
 * @param {number} [item.shadow_blur] -
 * @param {object} [bgitem] - background item
 * @returns {Promise<object>} - a sharp object containing an image, as PNG
 * @memberof module:image
 * @async
 */

image.create = async function(item, bgitem)
{
    const _w = bgitem?._meta?.width;
    const _h = bgitem?._meta?.height;

    let width = this.toNumber(item.width, _w);
    let height = this.toNumber(item.height, _h);
    const border = this.toNumber(item.border, _h);
    const padding = image.padding(item, _w, _h);

    logger.debug("create:", image.name, "start:", item, "C:", width, height, border, padding);

    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, bgitem);

    } else

    if (item.text) {
        if (item.stroke_width || item.dilate_radius ||
            item.shadow_width || item.shadow_blur || item.shadow_alpha) {
            innerImage = await image.createOutline(item, bgitem);
        } else {
           innerImage = image.createText(item, bgitem);
        }

    } else {
        innerImage = sharp({
            create: {
                width: width || 1280,
                height: height || 1024,
                channels: 4,
                background: item.background || image.background,
            }
        });
    }

    const meta = await innerImage.metadata();

    item._inner_width = width = meta.width;
    item._inner_height = height = meta.height;
    item._inner_top = border + padding.top;
    item._inner_left = border + padding.left;

    const cw = width + border * 2;
    const ch = height + border * 2;
    const images = [];

    setProp(item, "_stats", await innerImage.stats());

    const radius = lib.toNumber(item.radius, { min: 0 });
    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 || 'dest-in'
            }]);
    }

    if (border) {
        const borderImage = await image.createBorder(item, cw, ch);
        images.push({
            input: borderImage,
            top: padding.top,
            left: padding.left,
        });
    }

    // Place inside the padding background
    images.push({
        input: await innerImage.png().toBuffer(),
        top: item._inner_top,
        left: item._inner_left,
    });

    const padding_color = this.toColor(item.padding_color, item.padding_alpha)

    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 || image.transparent,
        }
    });

    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 || 'dest-in'
        });
    }

    logger.debug("create:", image.name, "done:", item.id, width, height, "CW:", cw, ch, pw, ph, "B:", border, "I:", images.length, "P:", padding);

    const img = await image.convert(outterImage.composite(images), item);

    setProp(item, "_buffer", await img.toBuffer())
    setProp(item, "_meta", 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.background));

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

    const results = await Promise.allSettled(other.map(async (item) => {
        // Autodetect color for texts
        if (item.text && !item.color) {
            await image.detect(item, bgitem);
        }

        await image.create(item, bgitem);

        if (lib.isFlag(lib.split(item.gradient), ["1", "true", item.type, item.id])) {
            await image.createGradient(item, bgitem);
        }

        await image.clip(item, bgitem);

        return item;
    }));

    const buffers = results.map((res, i) => {
        const item = res.value;
        logger.logger(item?._buffer ? "debug" : "error", "composite:", image.name, "result:", res, res.value ? "" : items[i]);
        if (!item?._buffer || item?._skip) return 0;

        return {
            input: item._buffer,
            gravity: item.gravity || undefined,
            blend: item.blend || undefined,
            top: lib.isNumber(item.top) ? this.toNumber(item.top, bgitem._meta.height) : undefined,
            left: lib.isNumber(item.left) ? this.toNumber(item.left, bgitem._meta.width) : undefined,
        };
    }).filter(x => x);

    // Composite all elements onto the background
    bgimage.composite(buffers);

    setProp(bgitem, "_buffer", await bgimage.png().toBuffer())
    setProp(bgitem, "_meta", await bgimage.metadata())

    logger.debug("composite:", image.name, "bg:", bgitem);

    return items;
}