UNPKG

ascii-silhouettify

Version:

A command-line app that converts images into ASCII silhouettes, a style of ASCII art distinguished by uniformly filled geometric shapes rather than lines or textures.

1,429 lines (1,371 loc) 302 kB
#!/usr/bin/env node import path$1, { dirname, basename } from 'path'; import { fileURLToPath } from 'url'; import os from 'os'; import { Worker } from 'worker_threads'; import chroma from 'chroma-js'; import sharp from 'sharp'; import { fileURLToPath as fileURLToPath$1 } from 'node:url'; import { win32, posix } from 'node:path'; import { realpathSync as realpathSync$1, lstatSync, readdir, readdirSync, readlinkSync, promises } from 'fs'; import * as actualFS from 'node:fs'; import { lstat, readdir as readdir$1, readlink, realpath } from 'node:fs/promises'; import { EventEmitter } from 'node:events'; import Stream from 'node:stream'; import { StringDecoder } from 'node:string_decoder'; const RGBS = 'DAwMxQ8fE6EOwZwAADfaiBeYOpbdzMzMdnZ250hWFsYM+fGlO3j/tACeYdbW8vLyAAAAAABfAACHAACvAADXAAD/AF8AAF9fAF+HAF+' + 'vAF/XAF//AIcAAIdfAIeHAIevAIfXAIf/AK8AAK9fAK+HAK+vAK/XAK//ANcAANdfANeHANevANfXANf/AP8AAP9fAP+HAP+vAP/XAP//XwAAXwB' + 'fXwCHXwCvXwDXXwD/X18AX19fX1+HX1+vX1/XX1//X4cAX4dfX4eHX4evX4fXX4f/X68AX69fX6+HX6+vX6/XX6//X9cAX9dfX9eHX9evX9fXX9f' + '/X/8AX/9fX/+HX/+vX//XX///hwAAhwBfhwCHhwCvhwDXhwD/h18Ah19fh1+Hh1+vh1/Xh1//h4cAh4dfh4eHh4evh4fXh4f/h68Ah69fh6+Hh6+' + 'vh6/Xh6//h9cAh9dfh9eHh9evh9fXh9f/h/8Ah/9fh/+Hh/+vh//Xh///rwAArwBfrwCHrwCvrwDXrwD/r18Ar19fr1+Hr1+vr1/Xr1//r4cAr4d' + 'fr4eHr4evr4fXr4f/r68Ar69fr6+Hr6+vr6/Xr6//r9cAr9dfr9eHr9evr9fXr9f/r/8Ar/9fr/+Hr/+vr//Xr///1wAA1wBf1wCH1wCv1wDX1wD' + '/118A119f11+H11+v11/X11//14cA14df14eH14ev14fX14f/168A169f16+H16+v16/X16//19cA19df19eH19ev19fX19f/1/8A1/9f1/+H1/+' + 'v1//X1////wAA/wBf/wCH/wCv/wDX/wD//18A/19f/1+H/1+v/1/X/1///4cA/4df/4eH/4ev/4fX/4f//68A/69f/6+H/6+v/6/X/6///9cA/9d' + 'f/9eH/9ev/9fX/9f///8A//9f//+H//+v///X////CAgIEhISHBwcJiYmMDAwOjo6RERETk5OWFhYYmJibGxsdnZ2gICAioqKlJSUnp6eqKiosrK' + 'yvLy8xsbG0NDQ2tra5OTk7u7u'; const buffer = Buffer.from(RGBS, 'base64'); var Palette; (function (Palette) { Palette[Palette["STANDARD_8"] = 0] = "STANDARD_8"; Palette[Palette["STANDARD_16"] = 1] = "STANDARD_16"; Palette[Palette["EXTENDED_240"] = 2] = "EXTENDED_240"; Palette[Palette["EXTENDED_256"] = 3] = "EXTENDED_256"; })(Palette || (Palette = {})); const palette = new Array(256); const closestColorCache = new Map(); function loadHtmlColors() { const htmlColors = new Array(256); for (let i = 0, j = 0; i < palette.length; ++i) { const r = buffer[j++]; const g = buffer[j++]; const b = buffer[j++]; palette[i] = chroma(r, g, b); htmlColors[i] = ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0').toUpperCase(); } return htmlColors; } function clearClosestColorCache() { closestColorCache.clear(); } function findClosestColorIndexAmong(indices, darkness, r, g, b, a) { const key = (r << 24) | (g << 16) | (b << 8) | a; const value = closestColorCache.get(key); if (value !== undefined) { return value; } let index = 0; const c = chroma(r, g, b).lab(); c[0] *= a / 255; if (c[0] >= darkness) { const q = chroma.lab(c[0], c[1], c[2]); let error = Number.MAX_VALUE; for (let i = indices.length - 1; i >= 0; --i) { const p = palette[indices[i]]; const e = chroma.deltaE(p, q); if (e < error) { error = e; index = indices[i]; } } } closestColorCache.set(key, index); return index; } function findClosestColorIndex(pal, darkness, r, g, b, a) { const key = (r << 24) | (g << 16) | (b << 8) | a; const value = closestColorCache.get(key); if (value !== undefined) { return value; } let index = 0; const c = chroma(r, g, b).lab(); c[0] *= a / 255; if (c[0] >= darkness) { const q = chroma.lab(c[0], c[1], c[2]); let error = Number.MAX_VALUE; let i; let minIndex; switch (pal) { case Palette.STANDARD_8: i = 7; minIndex = 0; break; case Palette.STANDARD_16: i = 15; minIndex = 0; break; case Palette.EXTENDED_240: i = 255; minIndex = 16; // Do not compare against the standard 16 ANSI colors since they are commonly redefined. break; default: i = 255; minIndex = 0; break; } for (; i >= minIndex; --i) { const p = palette[i]; const e = chroma.deltaE(p, q); if (e < error) { error = e; index = i; } } } closestColorCache.set(key, index); return index; } const PRINTABLE_ASCII = ' !"#$%&\'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'; const TERM_WIDTH = 9; const TERM_HEIGHT = 19; const HTML_WIDTH = 9.363636363636363; class GlyphImage { character; pixels; htmlEscapedCharacter; neofetchEscapedCharacter; count; constructor(character, pixels) { this.character = character; this.pixels = pixels; this.character = character; this.pixels = pixels; this.count = 0; for (let i = pixels.length - 1; i >= 0; --i) { const row = pixels[i]; for (let j = row.length - 1; j >= 0; --j) { if (row[j]) { ++this.count; } } } switch (character) { case '&': this.htmlEscapedCharacter = '&amp;'; break; case '<': this.htmlEscapedCharacter = '&lt;'; break; case '>': this.htmlEscapedCharacter = '&gt;'; break; case '"': this.htmlEscapedCharacter = '&quot;'; break; case '\'': this.htmlEscapedCharacter = '&apos;'; break; default: this.htmlEscapedCharacter = character; break; } switch (character) { case '\\': this.neofetchEscapedCharacter = '\\\\'; break; case '$': this.neofetchEscapedCharacter = '\x24'; break; default: this.neofetchEscapedCharacter = character; break; } } } class Glyph { character; htmlEscapedCharacter; neofetchEscapedCharacter; count; constructor(character, htmlEscapedCharacter, neofetchEscapedCharacter, count) { this.character = character; this.htmlEscapedCharacter = htmlEscapedCharacter; this.neofetchEscapedCharacter = neofetchEscapedCharacter; this.count = count; } } class GlyphInfo { masks; glyphs; minCount; constructor(masks, glyphs, minCount) { this.masks = masks; this.glyphs = glyphs; this.minCount = minCount; } } async function loadGlyphs() { const masks = []; const glyphsImages = new Array(PRINTABLE_ASCII.length); const { data, info } = await sharp(path$1.join(dirname(fileURLToPath(import.meta.url)), 'assets', 'glyphs.png')) .raw().toColourspace('b-w').toBuffer({ resolveWithObject: true }); for (let i = PRINTABLE_ASCII.length - 1; i >= 0; --i) { const glphyPixels = new Array(TERM_HEIGHT); const I = i * TERM_WIDTH; for (let j = TERM_HEIGHT - 1; j >= 0; --j) { const row = glphyPixels[j] = new Array(TERM_WIDTH); const J = I + j * info.width; for (let k = row.length - 1; k >= 0; --k) { row[k] = (data[J + k] !== 0); } } glyphsImages[i] = new GlyphImage(PRINTABLE_ASCII[i], glphyPixels); } glyphsImages.sort((a, b) => a.count - b.count); masks.length = TERM_WIDTH * TERM_HEIGHT; for (let i = masks.length - 1; i >= 0; --i) { masks[i] = [0, 0, 0]; } for (let i = glyphsImages.length - 1; i >= 0; --i) { const pixels = glyphsImages[i].pixels; const index = i >> 5; const mask = 1 << (i & 31); for (let j = TERM_HEIGHT - 1; j >= 0; --j) { const row = pixels[j]; const tableOffset = TERM_WIDTH * j; for (let k = row.length - 1; k >= 0; --k) { if (!row[k]) { masks[tableOffset + k][index] |= mask; } } } } const glyphs = new Array(glyphsImages.length); for (let i = glyphsImages.length - 1; i >= 0; --i) { const glyphImage = glyphsImages[i]; glyphs[i] = new Glyph(glyphImage.character, glyphImage.htmlEscapedCharacter, glyphImage.neofetchEscapedCharacter, glyphImage.count); } return new GlyphInfo(masks, glyphs, glyphs[1].count); } class Image { indices; width; height; neofetchIndices; neofetchStyles; constructor(indices, width, height, neofetchIndices, neofetchStyles) { this.indices = indices; this.width = width; this.height = height; this.neofetchIndices = neofetchIndices; this.neofetchStyles = neofetchStyles; } } async function loadImage(filename, pal, colors, darkness) { const { data, info } = await sharp(filename).raw().toBuffer({ resolveWithObject: true }); const indices = new Uint8Array(info.width * info.height); const frequencies = new Array(256).fill(0); clearClosestColorCache(); switch (info.channels) { case 1: for (let i = 0; i < indices.length; ++i) { ++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[i], data[i], data[i], 0xFF)]; } break; case 2: for (let i = 0, j = 0; i < indices.length; ++i) { ++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[j], data[j], data[j++], data[j++])]; } break; case 3: for (let i = 0, j = 0; i < indices.length; ++i) { ++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[j++], data[j++], data[j++], 0xFF)]; } break; case 4: for (let i = 0, j = 0; i < indices.length; ++i) { ++frequencies[indices[i] = findClosestColorIndex(pal, darkness, data[j++], data[j++], data[j++], data[j++])]; } break; } clearClosestColorCache(); const neofetchIndices = []; const neofetchStyles = new Array(256); while (true) { let maxIndex = -1; let maxFrequency = 0; for (let i = frequencies.length - 1; i > 0; --i) { if (frequencies[i] > maxFrequency) { maxIndex = i; maxFrequency = frequencies[i]; } } if (maxIndex < 0) { break; } frequencies[maxIndex] = 0; neofetchIndices.push(maxIndex); if (neofetchIndices.length <= 6) { neofetchStyles[maxIndex] = `\${c${neofetchIndices.length}}`; } } if (neofetchIndices.length <= colors) { return new Image(indices, info.width, info.height, neofetchIndices, neofetchStyles); } neofetchIndices.length = colors; switch (info.channels) { case 1: for (let i = 0; i < indices.length; ++i) { indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[i], data[i], data[i], 0xFF); } break; case 2: for (let i = 0, j = 0; i < indices.length; ++i) { indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[j], data[j], data[j++], data[j++]); } break; case 3: for (let i = 0, j = 0; i < indices.length; ++i) { indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[j++], data[j++], data[j++], 0xFF); } break; case 4: for (let i = 0, j = 0; i < indices.length; ++i) { indices[i] = findClosestColorIndexAmong(neofetchIndices, darkness, data[j++], data[j++], data[j++], data[j++]); } break; } clearClosestColorCache(); return new Image(indices, info.width, info.height, neofetchIndices, neofetchStyles); } function partitionArray(data, numberOfPartitions) { const result = []; const minPartitionSize = Math.floor(data.length / numberOfPartitions); const extraCount = data.length % numberOfPartitions; let startIndex = 0; for (let i = 0; i < numberOfPartitions; i++) { const partitionSize = minPartitionSize + (i < extraCount ? 1 : 0); const partition = data.slice(startIndex, startIndex + partitionSize); result.push(partition); startIndex += partitionSize; } return result; } class Ascii { text; matched; constructor(text, matched) { this.text = text; this.matched = matched; } } class Offset { x; y; constructor(x, y) { this.x = x; this.y = y; } } class Task { offsets; image; glyphInfo; glyphScaleX; glyphScaleY; rows; cols; rowScale; colScale; marginX; marginY; color; format; palette; htmlColors; constructor(offsets, image, glyphInfo, glyphScaleX, glyphScaleY, rows, cols, rowScale, colScale, marginX, marginY, color, format, palette, htmlColors) { this.offsets = offsets; this.image = image; this.glyphInfo = glyphInfo; this.glyphScaleX = glyphScaleX; this.glyphScaleY = glyphScaleY; this.rows = rows; this.cols = cols; this.rowScale = rowScale; this.colScale = colScale; this.marginX = marginX; this.marginY = marginY; this.color = color; this.format = format; this.palette = palette; this.htmlColors = htmlColors; } } var Format; (function (Format) { Format[Format["TEXT"] = 0] = "TEXT"; Format[Format["HTML"] = 1] = "HTML"; Format[Format["NEOFETCH"] = 2] = "NEOFETCH"; })(Format || (Format = {})); async function convert(image, glyphInfo, color, imageScale, fontSize, lineHeight, format, palette, htmlColors, workers) { let scaledGlyphWidth = HTML_WIDTH * fontSize / 12; let scaledGlyphHeight = lineHeight * fontSize * 96 / 72; if (format !== Format.HTML) { scaledGlyphWidth = Math.round(scaledGlyphWidth); scaledGlyphHeight = Math.round(scaledGlyphHeight); } const glyphScaleX = scaledGlyphWidth / (imageScale * TERM_WIDTH); const glyphScaleY = scaledGlyphHeight / (imageScale * TERM_HEIGHT); const scaledImageWidth = imageScale * image.width; const scaledImageHeight = imageScale * image.height; const rows = Math.ceil(scaledImageHeight / scaledGlyphHeight); const cols = Math.ceil(scaledImageWidth / scaledGlyphWidth); const paddedWidth = Math.ceil(cols * scaledGlyphWidth); const paddedHeight = Math.ceil(rows * scaledGlyphHeight); const marginX = (scaledImageWidth - paddedWidth) / 2; const marginY = (scaledImageHeight - paddedHeight) / 2; const rowScale = scaledGlyphHeight / imageScale; const colScale = scaledGlyphWidth / imageScale; // Repeat the image conversion for various origins within a glyph-sized region and return the best one found. const offsets = []; for (let y = -19; y <= 0; ++y) { for (let x = -9; x <= 0; ++x) { offsets.push(new Offset(x, y)); } } const offs = partitionArray(offsets, workers.length); let ascii = new Ascii('', -1); let wc = workers.length; await new Promise(resolve => { for (let i = workers.length - 1; i >= 0; --i) { const worker = workers[i]; function messageHandler(result) { worker.removeListener('message', messageHandler); if (result.matched > ascii.matched) { ascii = result; } if (--wc === 0) { resolve(); } } worker.addListener('message', messageHandler); worker.postMessage(new Task(offs[i], image, glyphInfo, glyphScaleX, glyphScaleY, rows, cols, rowScale, colScale, marginX, marginY, color, format, palette, htmlColors)); } }); return ascii; } function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var balancedMatch = balanced$1; function balanced$1(a, b, str) { if (a instanceof RegExp) a = maybeMatch(a, str); if (b instanceof RegExp) b = maybeMatch(b, str); var r = range(a, b, str); return r && { start: r[0], end: r[1], pre: str.slice(0, r[0]), body: str.slice(r[0] + a.length, r[1]), post: str.slice(r[1] + b.length) }; } function maybeMatch(reg, str) { var m = str.match(reg); return m ? m[0] : null; } balanced$1.range = range; function range(a, b, str) { var begs, beg, left, right, result; var ai = str.indexOf(a); var bi = str.indexOf(b, ai + 1); var i = ai; if (ai >= 0 && bi > 0) { if(a===b) { return [ai, bi]; } begs = []; left = str.length; while (i >= 0 && !result) { if (i == ai) { begs.push(i); ai = str.indexOf(a, i + 1); } else if (begs.length == 1) { result = [ begs.pop(), bi ]; } else { beg = begs.pop(); if (beg < left) { left = beg; right = bi; } bi = str.indexOf(b, i + 1); } i = ai < bi && ai >= 0 ? ai : bi; } if (begs.length) { result = [ left, right ]; } } return result; } var balanced = balancedMatch; var braceExpansion = expandTop; var escSlash = '\0SLASH'+Math.random()+'\0'; var escOpen = '\0OPEN'+Math.random()+'\0'; var escClose = '\0CLOSE'+Math.random()+'\0'; var escComma = '\0COMMA'+Math.random()+'\0'; var escPeriod = '\0PERIOD'+Math.random()+'\0'; function numeric(str) { return parseInt(str, 10) == str ? parseInt(str, 10) : str.charCodeAt(0); } function escapeBraces(str) { return str.split('\\\\').join(escSlash) .split('\\{').join(escOpen) .split('\\}').join(escClose) .split('\\,').join(escComma) .split('\\.').join(escPeriod); } function unescapeBraces(str) { return str.split(escSlash).join('\\') .split(escOpen).join('{') .split(escClose).join('}') .split(escComma).join(',') .split(escPeriod).join('.'); } // Basically just str.split(","), but handling cases // where we have nested braced sections, which should be // treated as individual members, like {a,{b,c},d} function parseCommaParts(str) { if (!str) return ['']; var parts = []; var m = balanced('{', '}', str); if (!m) return str.split(','); var pre = m.pre; var body = m.body; var post = m.post; var p = pre.split(','); p[p.length-1] += '{' + body + '}'; var postParts = parseCommaParts(post); if (post.length) { p[p.length-1] += postParts.shift(); p.push.apply(p, postParts); } parts.push.apply(parts, p); return parts; } function expandTop(str) { if (!str) return []; // I don't know why Bash 4.3 does this, but it does. // Anything starting with {} will have the first two bytes preserved // but *only* at the top level, so {},a}b will not expand to anything, // but a{},b}c will be expanded to [a}c,abc]. // One could argue that this is a bug in Bash, but since the goal of // this module is to match Bash's rules, we escape a leading {} if (str.substr(0, 2) === '{}') { str = '\\{\\}' + str.substr(2); } return expand(escapeBraces(str), true).map(unescapeBraces); } function embrace(str) { return '{' + str + '}'; } function isPadded(el) { return /^-?0\d/.test(el); } function lte(i, y) { return i <= y; } function gte(i, y) { return i >= y; } function expand(str, isTop) { var expansions = []; var m = balanced('{', '}', str); if (!m) return [str]; // no need to expand pre, since it is guaranteed to be free of brace-sets var pre = m.pre; var post = m.post.length ? expand(m.post, false) : ['']; if (/\$$/.test(m.pre)) { for (var k = 0; k < post.length; k++) { var expansion = pre+ '{' + m.body + '}' + post[k]; expansions.push(expansion); } } else { var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); var isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m.body); var isSequence = isNumericSequence || isAlphaSequence; var isOptions = m.body.indexOf(',') >= 0; if (!isSequence && !isOptions) { // {a},b} if (m.post.match(/,.*\}/)) { str = m.pre + '{' + m.body + escClose + m.post; return expand(str); } return [str]; } var n; if (isSequence) { n = m.body.split(/\.\./); } else { n = parseCommaParts(m.body); if (n.length === 1) { // x{{a,b}}y ==> x{a}y x{b}y n = expand(n[0], false).map(embrace); if (n.length === 1) { return post.map(function(p) { return m.pre + n[0] + p; }); } } } // at this point, n is the parts, and we know it's not a comma set // with a single entry. var N; if (isSequence) { var x = numeric(n[0]); var y = numeric(n[1]); var width = Math.max(n[0].length, n[1].length); var incr = n.length == 3 ? Math.abs(numeric(n[2])) : 1; var test = lte; var reverse = y < x; if (reverse) { incr *= -1; test = gte; } var pad = n.some(isPadded); N = []; for (var i = x; test(i, y); i += incr) { var c; if (isAlphaSequence) { c = String.fromCharCode(i); if (c === '\\') c = ''; } else { c = String(i); if (pad) { var need = width - c.length; if (need > 0) { var z = new Array(need + 1).join('0'); if (i < 0) c = '-' + z + c.slice(1); else c = z + c; } } } N.push(c); } } else { N = []; for (var j = 0; j < n.length; j++) { N.push.apply(N, expand(n[j], false)); } } for (var j = 0; j < N.length; j++) { for (var k = 0; k < post.length; k++) { var expansion = pre + N[j] + post[k]; if (!isTop || isSequence || expansion) expansions.push(expansion); } } } return expansions; } var expand$1 = /*@__PURE__*/getDefaultExportFromCjs(braceExpansion); const MAX_PATTERN_LENGTH = 1024 * 64; const assertValidPattern = (pattern) => { if (typeof pattern !== 'string') { throw new TypeError('invalid pattern'); } if (pattern.length > MAX_PATTERN_LENGTH) { throw new TypeError('pattern is too long'); } }; // translate the various posix character classes into unicode properties // this works across all unicode locales // { <posix class>: [<translation>, /u flag required, negated] const posixClasses = { '[:alnum:]': ['\\p{L}\\p{Nl}\\p{Nd}', true], '[:alpha:]': ['\\p{L}\\p{Nl}', true], '[:ascii:]': ['\\x' + '00-\\x' + '7f', false], '[:blank:]': ['\\p{Zs}\\t', true], '[:cntrl:]': ['\\p{Cc}', true], '[:digit:]': ['\\p{Nd}', true], '[:graph:]': ['\\p{Z}\\p{C}', true, true], '[:lower:]': ['\\p{Ll}', true], '[:print:]': ['\\p{C}', true], '[:punct:]': ['\\p{P}', true], '[:space:]': ['\\p{Z}\\t\\r\\n\\v\\f', true], '[:upper:]': ['\\p{Lu}', true], '[:word:]': ['\\p{L}\\p{Nl}\\p{Nd}\\p{Pc}', true], '[:xdigit:]': ['A-Fa-f0-9', false], }; // only need to escape a few things inside of brace expressions // escapes: [ \ ] - const braceEscape = (s) => s.replace(/[[\]\\-]/g, '\\$&'); // escape all regexp magic characters const regexpEscape = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); // everything has already been escaped, we just have to join const rangesToString = (ranges) => ranges.join(''); // takes a glob string at a posix brace expression, and returns // an equivalent regular expression source, and boolean indicating // whether the /u flag needs to be applied, and the number of chars // consumed to parse the character class. // This also removes out of order ranges, and returns ($.) if the // entire class just no good. const parseClass = (glob, position) => { const pos = position; /* c8 ignore start */ if (glob.charAt(pos) !== '[') { throw new Error('not in a brace expression'); } /* c8 ignore stop */ const ranges = []; const negs = []; let i = pos + 1; let sawStart = false; let uflag = false; let escaping = false; let negate = false; let endPos = pos; let rangeStart = ''; WHILE: while (i < glob.length) { const c = glob.charAt(i); if ((c === '!' || c === '^') && i === pos + 1) { negate = true; i++; continue; } if (c === ']' && sawStart && !escaping) { endPos = i + 1; break; } sawStart = true; if (c === '\\') { if (!escaping) { escaping = true; i++; continue; } // escaped \ char, fall through and treat like normal char } if (c === '[' && !escaping) { // either a posix class, a collation equivalent, or just a [ for (const [cls, [unip, u, neg]] of Object.entries(posixClasses)) { if (glob.startsWith(cls, i)) { // invalid, [a-[] is fine, but not [a-[:alpha]] if (rangeStart) { return ['$.', false, glob.length - pos, true]; } i += cls.length; if (neg) negs.push(unip); else ranges.push(unip); uflag = uflag || u; continue WHILE; } } } // now it's just a normal character, effectively escaping = false; if (rangeStart) { // throw this range away if it's not valid, but others // can still match. if (c > rangeStart) { ranges.push(braceEscape(rangeStart) + '-' + braceEscape(c)); } else if (c === rangeStart) { ranges.push(braceEscape(c)); } rangeStart = ''; i++; continue; } // now might be the start of a range. // can be either c-d or c-] or c<more...>] or c] at this point if (glob.startsWith('-]', i + 1)) { ranges.push(braceEscape(c + '-')); i += 2; continue; } if (glob.startsWith('-', i + 1)) { rangeStart = c; i += 2; continue; } // not the start of a range, just a single character ranges.push(braceEscape(c)); i++; } if (endPos < i) { // didn't see the end of the class, not a valid class, // but might still be valid as a literal match. return ['', false, 0, false]; } // if we got no ranges and no negates, then we have a range that // cannot possibly match anything, and that poisons the whole glob if (!ranges.length && !negs.length) { return ['$.', false, glob.length - pos, true]; } // if we got one positive range, and it's a single character, then that's // not actually a magic pattern, it's just that one literal character. // we should not treat that as "magic", we should just return the literal // character. [_] is a perfectly valid way to escape glob magic chars. if (negs.length === 0 && ranges.length === 1 && /^\\?.$/.test(ranges[0]) && !negate) { const r = ranges[0].length === 2 ? ranges[0].slice(-1) : ranges[0]; return [regexpEscape(r), false, endPos - pos, false]; } const sranges = '[' + (negate ? '^' : '') + rangesToString(ranges) + ']'; const snegs = '[' + (negate ? '' : '^') + rangesToString(negs) + ']'; const comb = ranges.length && negs.length ? '(' + sranges + '|' + snegs + ')' : ranges.length ? sranges : snegs; return [comb, uflag, endPos - pos, true]; }; /** * Un-escape a string that has been escaped with {@link escape}. * * If the {@link windowsPathsNoEscape} option is used, then square-brace * escapes are removed, but not backslash escapes. For example, it will turn * the string `'[*]'` into `*`, but it will not turn `'\\*'` into `'*'`, * becuase `\` is a path separator in `windowsPathsNoEscape` mode. * * When `windowsPathsNoEscape` is not set, then both brace escapes and * backslash escapes are removed. * * Slashes (and backslashes in `windowsPathsNoEscape` mode) cannot be escaped * or unescaped. */ const unescape = (s, { windowsPathsNoEscape = false, } = {}) => { return windowsPathsNoEscape ? s.replace(/\[([^\/\\])\]/g, '$1') : s.replace(/((?!\\).|^)\[([^\/\\])\]/g, '$1$2').replace(/\\([^\/])/g, '$1'); }; // parse a single path portion const types = new Set(['!', '?', '+', '*', '@']); const isExtglobType = (c) => types.has(c); // Patterns that get prepended to bind to the start of either the // entire string, or just a single path portion, to prevent dots // and/or traversal patterns, when needed. // Exts don't need the ^ or / bit, because the root binds that already. const startNoTraversal = '(?!(?:^|/)\\.\\.?(?:$|/))'; const startNoDot = '(?!\\.)'; // characters that indicate a start of pattern needs the "no dots" bit, // because a dot *might* be matched. ( is not in the list, because in // the case of a child extglob, it will handle the prevention itself. const addPatternStart = new Set(['[', '.']); // cases where traversal is A-OK, no dot prevention needed const justDots = new Set(['..', '.']); const reSpecials = new Set('().*{}+?[]^$\\!'); const regExpEscape$1 = (s) => s.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); // any single thing other than / const qmark$1 = '[^/]'; // * => any number of characters const star$1 = qmark$1 + '*?'; // use + when we need to ensure that *something* matches, because the * is // the only thing in the path portion. const starNoEmpty = qmark$1 + '+?'; // remove the \ chars that we added if we end up doing a nonmagic compare // const deslash = (s: string) => s.replace(/\\(.)/g, '$1') class AST { type; #root; #hasMagic; #uflag = false; #parts = []; #parent; #parentIndex; #negs; #filledNegs = false; #options; #toString; // set to true if it's an extglob with no children // (which really means one child of '') #emptyExt = false; constructor(type, parent, options = {}) { this.type = type; // extglobs are inherently magical if (type) this.#hasMagic = true; this.#parent = parent; this.#root = this.#parent ? this.#parent.#root : this; this.#options = this.#root === this ? options : this.#root.#options; this.#negs = this.#root === this ? [] : this.#root.#negs; if (type === '!' && !this.#root.#filledNegs) this.#negs.push(this); this.#parentIndex = this.#parent ? this.#parent.#parts.length : 0; } get hasMagic() { /* c8 ignore start */ if (this.#hasMagic !== undefined) return this.#hasMagic; /* c8 ignore stop */ for (const p of this.#parts) { if (typeof p === 'string') continue; if (p.type || p.hasMagic) return (this.#hasMagic = true); } // note: will be undefined until we generate the regexp src and find out return this.#hasMagic; } // reconstructs the pattern toString() { if (this.#toString !== undefined) return this.#toString; if (!this.type) { return (this.#toString = this.#parts.map(p => String(p)).join('')); } else { return (this.#toString = this.type + '(' + this.#parts.map(p => String(p)).join('|') + ')'); } } #fillNegs() { /* c8 ignore start */ if (this !== this.#root) throw new Error('should only call on root'); if (this.#filledNegs) return this; /* c8 ignore stop */ // call toString() once to fill this out this.toString(); this.#filledNegs = true; let n; while ((n = this.#negs.pop())) { if (n.type !== '!') continue; // walk up the tree, appending everthing that comes AFTER parentIndex let p = n; let pp = p.#parent; while (pp) { for (let i = p.#parentIndex + 1; !pp.type && i < pp.#parts.length; i++) { for (const part of n.#parts) { /* c8 ignore start */ if (typeof part === 'string') { throw new Error('string part in extglob AST??'); } /* c8 ignore stop */ part.copyIn(pp.#parts[i]); } } p = pp; pp = p.#parent; } } return this; } push(...parts) { for (const p of parts) { if (p === '') continue; /* c8 ignore start */ if (typeof p !== 'string' && !(p instanceof AST && p.#parent === this)) { throw new Error('invalid part: ' + p); } /* c8 ignore stop */ this.#parts.push(p); } } toJSON() { const ret = this.type === null ? this.#parts.slice().map(p => (typeof p === 'string' ? p : p.toJSON())) : [this.type, ...this.#parts.map(p => p.toJSON())]; if (this.isStart() && !this.type) ret.unshift([]); if (this.isEnd() && (this === this.#root || (this.#root.#filledNegs && this.#parent?.type === '!'))) { ret.push({}); } return ret; } isStart() { if (this.#root === this) return true; // if (this.type) return !!this.#parent?.isStart() if (!this.#parent?.isStart()) return false; if (this.#parentIndex === 0) return true; // if everything AHEAD of this is a negation, then it's still the "start" const p = this.#parent; for (let i = 0; i < this.#parentIndex; i++) { const pp = p.#parts[i]; if (!(pp instanceof AST && pp.type === '!')) { return false; } } return true; } isEnd() { if (this.#root === this) return true; if (this.#parent?.type === '!') return true; if (!this.#parent?.isEnd()) return false; if (!this.type) return this.#parent?.isEnd(); // if not root, it'll always have a parent /* c8 ignore start */ const pl = this.#parent ? this.#parent.#parts.length : 0; /* c8 ignore stop */ return this.#parentIndex === pl - 1; } copyIn(part) { if (typeof part === 'string') this.push(part); else this.push(part.clone(this)); } clone(parent) { const c = new AST(this.type, parent); for (const p of this.#parts) { c.copyIn(p); } return c; } static #parseAST(str, ast, pos, opt) { let escaping = false; let inBrace = false; let braceStart = -1; let braceNeg = false; if (ast.type === null) { // outside of a extglob, append until we find a start let i = pos; let acc = ''; while (i < str.length) { const c = str.charAt(i++); // still accumulate escapes at this point, but we do ignore // starts that are escaped if (escaping || c === '\\') { escaping = !escaping; acc += c; continue; } if (inBrace) { if (i === braceStart + 1) { if (c === '^' || c === '!') { braceNeg = true; } } else if (c === ']' && !(i === braceStart + 2 && braceNeg)) { inBrace = false; } acc += c; continue; } else if (c === '[') { inBrace = true; braceStart = i; braceNeg = false; acc += c; continue; } if (!opt.noext && isExtglobType(c) && str.charAt(i) === '(') { ast.push(acc); acc = ''; const ext = new AST(c, ast); i = AST.#parseAST(str, ext, i, opt); ast.push(ext); continue; } acc += c; } ast.push(acc); return i; } // some kind of extglob, pos is at the ( // find the next | or ) let i = pos + 1; let part = new AST(null, ast); const parts = []; let acc = ''; while (i < str.length) { const c = str.charAt(i++); // still accumulate escapes at this point, but we do ignore // starts that are escaped if (escaping || c === '\\') { escaping = !escaping; acc += c; continue; } if (inBrace) { if (i === braceStart + 1) { if (c === '^' || c === '!') { braceNeg = true; } } else if (c === ']' && !(i === braceStart + 2 && braceNeg)) { inBrace = false; } acc += c; continue; } else if (c === '[') { inBrace = true; braceStart = i; braceNeg = false; acc += c; continue; } if (isExtglobType(c) && str.charAt(i) === '(') { part.push(acc); acc = ''; const ext = new AST(c, part); part.push(ext); i = AST.#parseAST(str, ext, i, opt); continue; } if (c === '|') { part.push(acc); acc = ''; parts.push(part); part = new AST(null, ast); continue; } if (c === ')') { if (acc === '' && ast.#parts.length === 0) { ast.#emptyExt = true; } part.push(acc); acc = ''; ast.push(...parts, part); return i; } acc += c; } // unfinished extglob // if we got here, it was a malformed extglob! not an extglob, but // maybe something else in there. ast.type = null; ast.#hasMagic = undefined; ast.#parts = [str.substring(pos - 1)]; return i; } static fromGlob(pattern, options = {}) { const ast = new AST(null, undefined, options); AST.#parseAST(pattern, ast, 0, options); return ast; } // returns the regular expression if there's magic, or the unescaped // string if not. toMMPattern() { // should only be called on root /* c8 ignore start */ if (this !== this.#root) return this.#root.toMMPattern(); /* c8 ignore stop */ const glob = this.toString(); const [re, body, hasMagic, uflag] = this.toRegExpSource(); // if we're in nocase mode, and not nocaseMagicOnly, then we do // still need a regular expression if we have to case-insensitively // match capital/lowercase characters. const anyMagic = hasMagic || this.#hasMagic || (this.#options.nocase && !this.#options.nocaseMagicOnly && glob.toUpperCase() !== glob.toLowerCase()); if (!anyMagic) { return body; } const flags = (this.#options.nocase ? 'i' : '') + (uflag ? 'u' : ''); return Object.assign(new RegExp(`^${re}$`, flags), { _src: re, _glob: glob, }); } get options() { return this.#options; } // returns the string match, the regexp source, whether there's magic // in the regexp (so a regular expression is required) and whether or // not the uflag is needed for the regular expression (for posix classes) // TODO: instead of injecting the start/end at this point, just return // the BODY of the regexp, along with the start/end portions suitable // for binding the start/end in either a joined full-path makeRe context // (where we bind to (^|/), or a standalone matchPart context (where // we bind to ^, and not /). Otherwise slashes get duped! // // In part-matching mode, the start is: // - if not isStart: nothing // - if traversal possible, but not allowed: ^(?!\.\.?$) // - if dots allowed or not possible: ^ // - if dots possible and not allowed: ^(?!\.) // end is: // - if not isEnd(): nothing // - else: $ // // In full-path matching mode, we put the slash at the START of the // pattern, so start is: // - if first pattern: same as part-matching mode // - if not isStart(): nothing // - if traversal possible, but not allowed: /(?!\.\.?(?:$|/)) // - if dots allowed or not possible: / // - if dots possible and not allowed: /(?!\.) // end is: // - if last pattern, same as part-matching mode // - else nothing // // Always put the (?:$|/) on negated tails, though, because that has to be // there to bind the end of the negated pattern portion, and it's easier to // just stick it in now rather than try to inject it later in the middle of // the pattern. // // We can just always return the same end, and leave it up to the caller // to know whether it's going to be used joined or in parts. // And, if the start is adjusted slightly, can do the same there: // - if not isStart: nothing // - if traversal possible, but not allowed: (?:/|^)(?!\.\.?$) // - if dots allowed or not possible: (?:/|^) // - if dots possible and not allowed: (?:/|^)(?!\.) // // But it's better to have a simpler binding without a conditional, for // performance, so probably better to return both start options. // // Then the caller just ignores the end if it's not the first pattern, // and the start always gets applied. // // But that's always going to be $ if it's the ending pattern, or nothing, // so the caller can just attach $ at the end of the pattern when building. // // So the todo is: // - better detect what kind of start is needed // - return both flavors of starting pattern // - attach $ at the end of the pattern when creating the actual RegExp // // Ah, but wait, no, that all only applies to the root when the first pattern // is not an extglob. If the first pattern IS an extglob, then we need all // that dot prevention biz to live in the extglob portions, because eg // +(*|.x*) can match .xy but not .yx. // // So, return the two flavors if it's #root and the first child is not an // AST, otherwise leave it to the child AST to handle it, and there, // use the (?:^|/) style of start binding. // // Even simplified further: // - Since the start for a join is eg /(?!\.) and the start for a part // is ^(?!\.), we can just prepend (?!\.) to the pattern (either root // or start or whatever) and prepend ^ or / at the Regexp construction. toRegExpSource(allowDot) { const dot = allowDot ?? !!this.#options.dot; if (this.#root === this) this.#fillNegs(); if (!this.type) { const noEmpty = this.isStart() && this.isEnd(); const src = this.#parts .map(p => { const [re, _, hasMagic, uflag] = typeof p === 'string' ? AST.#parseGlob(p, this.#hasMagic, noEmpty) : p.toRegExpSource(allowDot); this.#hasMagic = this.#hasMagic || hasMagic; this.#uflag = this.#uflag || uflag; return re; }) .join(''); let start = ''; if (this.isStart()) { if (typeof this.#parts[0] === 'string') { // this is the string that will match the start of the pattern, // so we need to protect against dots and such. // '.' and '..' cannot match unless the pattern is that exactly, // even if it starts with . or dot:true is set. const dotTravAllowed = this.#parts.length === 1 && justDots.has(this.#parts[0]); if (!dotTravAllowed) { const aps = addPatternStart; // check if we have a possibility of matching . or .., // and prevent that. const needNoTrav = // dots are allowed, and the pattern starts with [ or . (dot && aps.has(src.charAt(0))) || // the pattern starts with \., and then [ or . (src.startsWith('\\.') && aps.has(src.charAt(2))) || // the pattern starts with \.\., and then [ or . (src.startsWith('\\.\\.') && aps.has(src.charAt(4))); // no need to prevent dots if it can't match a dot, or if a // sub-pattern will be preventing it anyway. const needNoDot = !dot && !allowDot && aps.has(src.charAt(0)); start = needNoTrav ? startNoTraversal : needNoDot ? startNoDot : ''; } } } // append the "end of path portion" pattern to negation tails let end = ''; if (this.isEnd() && this.#root.#filledNegs && this.#parent?.type === '!') { end = '(?:$|\\/)'; } const final = start + src + end; return [ final, unescape(src), (this.#hasMagic = !!this.#hasMagic), this.#uflag, ]; } // We need to calculate the body *twice* if it's a repeat pattern // at the start, once in nodot mode, then again in dot mode, so a // pattern like *(?) can match 'x.y' const repeated = this.type === '*' || this.type === '+'; // some kind of extglob const start = this.type === '!' ? '(?:(?!(?:' : '(?:'; let body = this.#partsToRegExp(dot); if (this.isStart() && this.isEnd() && !body && this.type !== '!') { // invalid extglob, has to at least be *something* present, if it's // the entire path portion. const s = this.toString(); this.#parts = [s]; this.type = null; this.#hasMagic = undefined; return [s, unescape(this.toString()), false, false]; } // XXX abstract out this map method let bodyDotAllowed = !repeated || allowDot || dot || false ? '' : this.#partsToRegExp(true); if (bodyDotAllowed === body) { bodyDotAllowed = ''; } if (bodyDotAllowed) { body = `(?:${body})(?:${bodyDotAllowed})*?`; } // an empty !() is exactly equivalent to a starNoEmpty let final = ''; if (this.type === '!' && this.#emptyExt) { final = (this.isStart() && !dot ? startNoDot : '') + starNoEmpty; } else { const close = this.type === '!' ? // !() must match something,but !(x) can match '' '))' + (this.isStart() && !dot && !allowDot ? startNoDot : '') + star$1 + ')' : this.type === '@' ? ')' : this.type === '?' ? ')?' : this.type === '+' && bodyDotAllowed ? ')' : this.type === '*' && bodyDotAllowed ? `)?` : `)${this.type}`; final = start + body + close; } return [ final, unescape(body), (this.#hasMagic = !!this.#hasMagic), this.#uflag, ]; } #partsToRegExp(dot) { return this.#parts .map(p => {