UNPKG

terminal-art

Version:

Convert popular image formats into terminal compatible ansii art

219 lines (198 loc) 8.99 kB
const promisify = require('util').promisify; const getPixels = promisify(require('get-pixels')); const nL = "\n"; // eslint-disable-line quotes // Applies control sequences when printing to screen const cS = (...c) => '\x1B' + `[${c.join('')}m`; const colourOctal = ansii => cS(0) + cS('48;5;', ansii) + cS('38;5;', ansii); /** * Converts popular image formats into a long octal sequence representative * of the colours within. Effectively returning a terminal-compatible * low-resolution copy of the inputted image. * - Approximates the terminal width and sets the image to a 3/4 ratio in * the event of a width not being stipulated. * - Either a file `path` or `Buffer` may be provided, a mimeType is mandatory * for the latter * * @param {Buffer|string} path * @param {Object} [options] * @param {number} [options.maxCharWidth] - how many characters wide should the image be * @param {string} [options.mimeType] - in the event that a buffer is provided as source * @param {number} [options.minDist=5] - a higher number may improve speed but reduce colour accuracy * @param {number} [options.frameIdx=0] - Only really applicable to gifs * @returns {Promise<[][]>} ansii 2d arr */ async function processImage(img, { maxCharWidth = null, minDist = 5, frameIdx = 0, previousImg = null, storeAllPixels = false } = {}) { const { pixels, frames, width, height, channels, frameSize } = img; maxCharWidth = maxCharWidth || process.stdout.columns * 0.75; frameIdx = Math.min(frameIdx, frames); // Because we are printing to a medium which has long pixels along the Y axis, // a sampling offset always has to be made let xOffset = 1; let yOffset = 1; if (width > maxCharWidth) { xOffset = Math.ceil(width / maxCharWidth); } yOffset = parseInt( (((xOffset / width) * height) * 2) * (width / height) ); let previousAnsii, previousColour; function getAnsiiCode(colours, colourStr) { if (colours[3] === 0) { return [ null, colourStr === previousColour ? ' ' : cS(0) + ' ' ]; } const ansii = colourStr === previousColour ? previousAnsii : pixelToAnsii(applyAlpha(colours), minDist); return [ ansii, ansii === previousAnsii && !storeAllPixels ? ' ' : colourOctal(ansii) + ' ' ]; } const output = []; const maxHeight = Math.ceil(height / yOffset); const maxWidth = Math.ceil(width / xOffset); for (let y = 0; y < maxHeight; y++) { const row = []; for (let x = 0; x < maxWidth; x++) { const offset = (y * width * channels * yOffset) + (x * channels * xOffset) + (frameIdx * frameSize); const colours = pixels.data.slice(offset, offset + channels); const colourStr = colours.join('.'); const [ansii, ansiiCode] = getAnsiiCode(colours, colourStr); if (previousImg && ansii === null) { row.push(previousImg[y][x]); } else { row.push(ansiiCode); } previousColour = colourStr; previousAnsii = ansii || previousAnsii; } output.push(row); previousAnsii = null; previousColour = null; } return output; }; async function getImageFrames(path, options) { if (path instanceof Buffer && !options.mimeType) { throw new Error('If a buffer is provided, so should be the mimeType as part of the options'); } const img = await loadImage(path, options.mimeType); const frames = []; for (let frameIdx = 0; frameIdx < img.frames; frameIdx++) { frames.push( await processImage( img, { ...options, previousImg: frameIdx > 0 ? frames[frameIdx - 1] : null, frameIdx } ) ); } return frames; } async function gifToAnsii(path, options) { const frames = await getImageFrames(path, { ...options, storeAllPixels: true }); return frames.map( frame => frame.map( row => row.join('') ).join(cS(0) + nL) + cS(0) ); }; async function imageToAnsii(path, options) { if (path instanceof Buffer && !options.mimeType) { throw new Error('If a buffer is provided, so should be the mimeType as part of the options'); } const img = await loadImage(path, options.mimeType); const frame = await processImage(img, options); return frame.map( row => row.join('') ).join(cS(0) + nL) + cS(0); }; async function loadImage(path, mimeType = null) { const pixels = await getPixels(path, mimeType); const [frames, width, height, channels] = pixels.shape.length === 3 ? [1, ...pixels.shape] : pixels.shape; const frameSize = width * height * channels; return { pixels, frames, width, height, channels, frameSize }; } /** * Returns an octal sequence which represents the ansii * colour best matching the given rgb value * @param {number[]} - r, g, b * @returns {string} */ function pixelToAnsii(colours, minDist = 5) { let closest = null; let chosen = null; for (const ansii in _source) { const dist = getDist(colours, _source[ansii]); if (dist <= minDist) { return ansii; } if (closest === null || dist < closest) { closest = dist; chosen = ansii; } } return chosen; } function applyAlpha(colours) { const alpha = colours.length > 3 ? colours[3] : 255; if (alpha > 240) { return colours; } const normalisedAlpha = alpha / 255 || 0; const addAlpha = c => c * normalisedAlpha; return [addAlpha(colours[0]), addAlpha(colours[1]), addAlpha(colours[2]), alpha]; } /** * The euclidean distance between two 3d arrays * @param {number[]} - r, g, b * @param {number[]} - r, g, b * @returns {number} */ function getDist(s1, s2) { return Math.sqrt( Math.pow(s1[0] - s2[0], 2) + Math.pow(s1[1] - s2[1], 2) + Math.pow(s1[2] - s2[2], 2) ); } const loadSource = source => source.split(';').reduce( (acc, hex) => { const dec = parseInt(hex, 16); const [ansii, ...colours] = [24, 16, 8, 0] .map(pow => (dec >> pow) & 255).reverse(); return { ...acc, [ansii]: colours }; }, {} ); module.exports = { processImage, imageToAnsii, getImageFrames, gifToAnsii }; /** * Serialisation of octal codes for ansii colours */ const _source = loadSource('0;8001;800002;808003;80000004;80008005;80800006;c0c0c007;80808008;ff09;ff000a;ffff0b;ff00000c;ff00ff0d;ffff000e;ffffff0f;10;5f000011;87000012;af000013;d7000014;ff000015;5f0016;5f5f0017;875f0018;af5f0019;d75f001a;ff5f001b;87001c;5f87001d;8787001e;af87001f;d7870020;ff870021;af0022;5faf0023;87af0024;afaf0025;d7af0026;ffaf0027;d70028;5fd70029;87d7002a;afd7002b;d7d7002c;ffd7002d;ff002e;5fff002f;87ff0030;afff0031;d7ff0032;ffff0033;5f34;5f005f35;87005f36;af005f37;d7005f38;ff005f39;5f5f3a;5f5f5f3b;875f5f3c;af5f5f3d;d75f5f3e;ff5f5f3f;875f40;5f875f41;87875f42;af875f43;d7875f44;ff875f45;af5f46;5faf5f47;87af5f48;afaf5f49;d7af5f4a;ffaf5f4b;d75f4c;5fd75f4d;87d75f4e;afd75f4f;d7d75f50;ffd75f51;ff5f52;5fff5f53;87ff5f54;afff5f55;d7ff5f56;ffff5f57;8758;5f008759;8700875a;af00875b;d700875c;ff00875d;5f875e;5f5f875f;875f8760;af5f8761;d75f8762;ff5f8763;878764;5f878765;87878766;af878767;d7878768;ff878769;af876a;5faf876b;87af876c;afaf876d;d7af876e;ffaf876f;d78770;5fd78771;87d78772;afd78773;d7d78774;ffd78775;ff8776;5fff8777;87ff8778;afff8779;d7ff877a;ffff877b;af7c;5f00af7d;8700af7e;af00af7f;d700af80;ff00af81;5faf82;5f5faf83;875faf84;af5faf85;d75faf86;ff5faf87;87af88;5f87af89;8787af8a;af87af8b;d787af8c;ff87af8d;afaf8e;5fafaf8f;87afaf90;afafaf91;d7afaf92;ffafaf93;d7af94;5fd7af95;87d7af96;afd7af97;d7d7af98;ffd7af99;ffaf9a;5fffaf9b;87ffaf9c;afffaf9d;d7ffaf9e;ffffaf9f;d7a0;5f00d7a1;8700d7a2;af00d7a3;d700d7a4;ff00d7a5;5fd7a6;5f5fd7a7;875fd7a8;af5fd7a9;d75fd7aa;ff5fd7ab;87d7ac;5f87d7ad;8787d7ae;af87d7af;d787d7b0;ff87d7b1;afd7b2;5fafd7b3;87afd7b4;afafd7b5;d7afd7b6;ffafd7b7;d7d7b8;5fd7d7b9;87d7d7ba;afd7d7bb;d7d7d7bc;ffd7d7bd;ffd7be;5fffd7bf;87ffd7c0;afffd7c1;d7ffd7c2;ffffd7c3;ffc4;5f00ffc5;8700ffc6;af00ffc7;d700ffc8;ff00ffc9;5fffca;5f5fffcb;875fffcc;af5fffcd;d75fffce;ff5fffcf;87ffd0;5f87ffd1;8787ffd2;af87ffd3;d787ffd4;ff87ffd5;afffd6;5fafffd7;87afffd8;afafffd9;d7afffda;ffafffdb;d7ffdc;5fd7ffdd;87d7ffde;afd7ffdf;d7d7ffe0;ffd7ffe1;ffffe2;5fffffe3;87ffffe4;afffffe5;d7ffffe6;ffffffe7;80808e8;121212e9;1c1c1cea;262626eb;303030ec;3a3a3aed;444444ee;4e4e4eef;585858f0;626262f1;6c6c6cf2;767676f3;808080f4;8a8a8af5;949494f6;9e9e9ef7;a8a8a8f8;b2b2b2f9;bcbcbcfa;c6c6c6fb;d0d0d0fc;dadadafd;e4e4e4fe;eeeeeeff');