Source: images.js

/**
 * @license MIT
 * Copyright © 2025 Steve Butler (henspace.com)
 * Permission is hereby granted, free of charge, to any person obtaining a
 * copy of this software and associated documentation files (the “Software”),
 * to deal in the Software without restriction, including without limitation
 * the rights to use, copy, modify, merge, publish, distribute, sublicense,
 * and/or sell copies of the Software, and to permit persons to whom the
 * Software is furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 * 
 * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
 * FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 * IN THE SOFTWARE.
 */

/**
 * @module hcje/images
 * @description
 * Functions and classes to facilitate the generation of random images which are then provided as data URLs for use in
 * image elements. Each image is composed of a number of cells. This allows, for example, an image of multiple circles
 * to be created without the need for any supporting media file.
 */

import * as utils from "./utils.js";

/**
 * Data provided to a cell painter function.
 * @typedef {Object} CellPainterData
 * @propery {CanvasRenderingContext2D} context - Rendering context
 * @property {module:hcje/utils~Dimensions} imageSize - Dimensions of the image.
 * @property {module:hcje/utils~RectData} cell - Rectangle data for the cell.
 * @property {Object} custom - Additional painter specific data.
 */

/**
 * Function responsible for painting a cell (rectangular area).
 * @typedef {function(CellPainterData)} CellPainter
 */ 


/**
 * Painter for a text cell.
 * @see module:hcje/images~CellPainter
 * @param {module:hcje/images~CellPainterData} data - Data describing the cell to be painted.
 *  Additional data describing the text are provided in the data.custom property.
 * @param {string} data.custom.fontName - Font name supplied as a CSS font name
 * @param {string | Array<string>} data.custom.text - Text to paint.
 * @param {boolean} [data.custom.fitWidth] - If true, the text is fitted to the width of a cell. Normally it is fitted
 *   to the height of a cell.
 */
export function textCellPainter(data) {
  const word = utils.getRandomMember(data.custom?.text ?? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890');
  let fontSize = data.cell.height;
  data.context.font = `${fontSize}px ${data.custom?.fontName ?? 'sans-serif'}`;
  data.context.textBaseline = 'middle';
  if (data.custom?.fitWidth) {
    const textMetrics = data.context.measureText(word);
    fontSize = fontSize * data.cell.width / textMetrics.width;
    data.context.font = `${fontSize}px ${data.custom?.fontName ?? 'sans-serif'}`;
  }
  if (data.custom?.fill) {
    data.context.fillText(word, data.cell.x, data.cell.y + data.cell.height / 2);
  } else {
    data.context.strokeText(word, data.cell.x, data.cell.y + data.cell.height / 2);
  }
}

/**
 * Painter for an image cell.
 * @see module:hcje/images~CellPainter
 * @param {module:hcje/images~CellPainterData} data - Data describing the cell to be painted. 
 *  Additional data describing the image are provided in the data.custom property.
 * @param {HTMLImageElement | SVGImageElement | ImageBitmap} data.custom.image - Image to paint.
 * @param {boolean} data.custom.keepAspect - If true, maintain the image's aspect ratio.
 */
export function imageCellPainter(data) {
  if (!data.custom?.image) {
    console.error(`Image not provided in data.custom.image.`);
    return;
  }
  let height = data.cell.height;
  if (data.custom?.keepAspect && data.custom.image.width != 0) {
    height = data.cell.width * data.custom.image.height / data.custom.image.width;
  }
  data.context.drawImage(data.custom.image, data.cell.x, data.cell.y, data.cell.width, height);
}

/**
 * Circle painter.
 * @see module:hcje/images~CellPainter
 * @param {module:hcje/images~CellPainterData} data - Data describing the cell to paint.
 */
export function circleCellPainter(data) {
  data.context.beginPath();
  data.context.arc(data.cell.x + data.cell.width / 2, data.cell.y + data.cell.height / 2,
    data.cell.width / 2, 0, 2 * Math.PI);
  if (data.custom?.fill) {
    data.context.fill();
  } else {
    data.context.stroke();
  }
}

/**
 * Triangle painter.
 * @see module:hcje/images~CellPainter
 * @param {module:hcje/images~CellPainterData} data - Data describing the cell to paint.
 */
export function triangleCellPainter(data) {
  const custom = data.custom ?? {};
  custom.points = [
    {x: data.cell.x, y: data.cell.y + data.cell.height},
    {x: data.cell.x + data.cell.width / 2, y: data.cell.y},
    {x: data.cell.x + data.cell.width, y: data.cell.y + data.cell.height},
  ];
  const pathData = {
    context: data.context,
    imageSize: data.imageSize,
    cell: data.cell,
    custom,
  }
  pathCellPainter(pathData);
}

/**
 * Random path painter.
 * @see module:hcje/images~CellPainter
 * @param {module:hcje/images~CellPainterData} data - Data describing the cell to paint.
 */
export function randomPathCellPainter(data) {
  const custom = data.custom ?? {};
  let vertices = Math.max(4, data.custom.vertices ?? 0);
  const points = [];
  points.push({x: data.cell.x + data.cell.width / 2, y: data.cell.y});
  points.push({x: data.cell.x + data.cell.width, y: data.cell.y + data.cell.height / 2});
  points.push({x: data.cell.x + data.cell.width / 2, y: data.cell.y + data.cell.height});
  points.push({x: data.cell.x, y: data.cell.y + data.cell.height / 2});

  for (let index = 4; index < data.cell.vertices; index++) {
    const x = utils.getRandomIntInclusive(data.cell.x, data.cell.x + data.cell.width);
    const y = utils.getRandomIntInclusive(data.cell.y, data.cell.y + data.cell.height);
    points.push({x, y});
  }
  custom.points = utils.shuffle(points);
  const pathData = {
    context: data.context,
    imageSize: data.imageSize,
    cell: data.cell,
    custom,
  }
  pathCellPainter(pathData);
}

/**
 * Path painter.
 * @see module:hcje/images~CellPainter
 * @param {module:hcje/images~CellPainterData} data - Data describing the cell to paint.
 *  Additional data describing the path and fill are provided in the data.custom property.
 * @param {Array<module:hcje/utils~Coordinate>} data.custom.points - Array of coordinates describing the path.
 * @param {boolean} data.custom.fill - If true, the path is filled.
 */
export function pathCellPainter(data) {
  const points = data.custom.points;
  if (!points || points.length < 2) {
    console.error('Attempt to draw path with less than 2 points ignored.');
    return;
  }
  const path = new Path2D();
  path.moveTo(points[0].x, points[0].y);
  for (let index = 1; index < points.length; index++) {
    path.lineTo(points[index].x, points[index].y)
  }
  path.closePath();
  if (data.custom?.fill) {
    data.context.fill(path);
  } else {
    data.context.stroke(path);
  }
}

/**
 * Configuration data for an automatically generated image. The image is created from a 2 dimensional array of square
 * cells arranged to fit within the image. To provide more variation, the size of each cell can be slightly randomised.
 * @typedef {Object} ImageConfig
 * @property {module:hcje/utils~Dimensions} imageSize - Dimensions of the image.
 * @property {CellPainter} cellPainter
 * @property {Object} cellConfig
 * @property {string} [cellConfig.backgroundColor = 'transparent'] - CSS color of background
 * @property {number} [cellConfig.jitter = 0] - Proportion of the cell size by which a cell can be repositioned.
 * @property {number} [cellConfig.minScale = 1] - Minimum scale applied to cell size
 * @property {number} [cellConfig.maxScale = 1] - Maximum scale applied to cell size
 * @property {Array<string>} [cellConfig.palette = ['black']] - array of possibl CSS colors used for foreground colors
 * @property {number} [cellConfig.rowCount = 1] - Number of rows into which the image is divided. Cells are square, so
 *   the number of columns is automatically calculated.
 * @property {number} [cellConfig.strokeWidth = 8] - Stroke width
 * @property {boolean} [cellConfig.strokeWithinCell = false] - If true, the cell size passed to painters is reduced so 
 * that strokes up to the border will fit within the original cell size.
 * @property {Object} [custom] - Additional information that might be required by a specific painter
 */

/**
 * Class for an image generator. This can create data urls for automatically generated images.
 */
export class ImageGenerator {
  /** Cache of dynamic images held as object urls.
   * @type {Map<string, string>} */
  #cache = new Map();

  /**
   * Revoke a previously cached data url and remove from the cache.
   * @param {string} cacheId - ID of the cached image.
   */
  revokeCacheId(cacheId) {
    if (!this.#cache.has(cacheId)) {
      console.error(`Ignored attempt to revoke missing cache Id ${cacheId}`);
      return;
    }
    this.#cache.get(cacheId).revokeObjectURL();
    this.#cache.delete(cacheId);
    console.debug(`Revoked cache Id ${cacheId}`);
  }

  /**
   * Revoke all cached urls and clear the cache.
   */
  revokeAll() {
    this.#cache.forEach((url)=> url.revokeObjectURL());
    this.#cache.clear();
  }

  /**
   * Create an object URL for an automatically generated image. If the image has been previously cached, the cached
   * version is returned and a new image is **NOT** returned. 
   * @param {string} cacheId - ID of the image held in a cache for future retrieval or revocation. 
   * @param {module:hcje/images/ImageConfig} config - Image configuration.
   * @returns {Promise} Fulfils to a string containing a blob URL. 
   * [Blob]{@link https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/blob}
   */
  createObjectUrlOrUseCache(cacheId, config) {
    if (!cacheId) {
      return Promise.reject(new Error(`Attempt to create an image with no cache id rejected.`));
    }
    if (this.#cache.has(cacheId)) {
      return Promise.resolve(this.#cache.get(cacheId));
    } else {
      return this.createObjectUrl(config)
        .then((url) => {
          if (cacheId) {
            this.#cache.set(cacheId, url);
          }
          return url;
        });
    }
  }
  
  /**
   * Create an object URL for an automatically generated image.
   * @param {module:hcje/images~ImageConfig} config - Image configuration.
   * @returns {Promise} Fulfils to a string containing a blob URL. 
   * [Blob]{@link https://developer.mozilla.org/en-US/docs/Web/URI/Reference/Schemes/blob}
   */
  createObjectUrl(config) {
    const backgroundColor = config.cellConfig?.backgroundColor ?? 'transparent';
    const jitter = config.cellConfig?.jitter ?? 0;
    const maxScale = config.cellConfig?.maxScale ?? 1;
    const minScale = config.cellConfig?.minScale ?? 1;
    const palette = config.cellConfig?.palette ?? ['black'];
    const rowCount = config.cellConfig?.rowCount ?? 1;
    const strokeWidth = config.cellConfig?.strokeWidth ?? 4;
    const strokeWithinCell = config.cellConfig?.strokeWithinCell ?? false;

    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = config.imageSize.width;
    canvas.height = config.imageSize.height;
    context.fillStyle = backgroundColor;
    context.fillRect(0, 0, config.imageSize.width, config.imageSize.height);
    if (rowCount <= 0) {
      return Promise.reject(new Error(`Attempt made to create image with invalid rowCount of ${rowCount}`));
    }
    const cellSide = config.imageSize.height / rowCount;
    const columns = Math.ceil(config.imageSize.width / cellSide);
    for (let row = 0; row < rowCount; row++) {
      for (let col = 0; col < columns; col++) {
        const color = utils.getRandomMember(palette);
        const cellMargin = strokeWithinCell ? strokeWidth / 2 : 0;
        const cell = {
          x: cellMargin + col * cellSide + utils.getRandomNumberBetween(-cellSide * jitter, cellSide * jitter),
          y: cellMargin + row * cellSide + utils.getRandomNumberBetween(-cellSide * jitter, cellSide * jitter),
          width: utils.getRandomNumberBetween(cellSide * minScale, cellSide * maxScale) - 2 * cellMargin,
          height: utils.getRandomNumberBetween(cellSide * minScale, cellSide * maxScale) - 2 * cellMargin,
        }
        context.fillStyle = color;
        context.strokeStyle = color;
        context.lineWidth = strokeWidth;
        context.lineJoin = 'round';
        if (!config.cellPainter) {
          return Promise.reject(new Error(`No cell painter so image cannot be drawn.`));
        }
        config.cellPainter({
          context, 
          imageSize: config.imageSize, 
          cell,
          custom: config.custom
        })
      }
    }

    return new Promise((resolve) => {
      canvas.toBlob((blob) => {
        console.log(blob);
        resolve(URL.createObjectURL(blob));
      });
    });
  }
}