Text art, often called "ASCII art", is a way of displaying images in a text-only medium. You've probably seen it in the terminal output of some of your favorite command line apps.
For this project, we'll be building a fully browser-based text art generator, using React and TypeScript. The output will be highly customizable, with options for increasing brightness and contrast, width in characters, inverting the text and background colors, and even changing the character set we use to generate the images.
All the code is available on GitHub, and there's a live demo you can play around with too!
Algorithm
The basic algorithm is as follows:
Calculate the relative density of each character in the character set (charset), averaged over all its pixels, when displayed in a monospace font. For example,
.
is very sparse, whereas#
is very dense, anda
is somewhere in between.-
Normalize the resulting absolute values into relative values in the range
0..1
, where 0 is the sparsest character in the charset and 1 is the densest.If the "invert" option is selected, subtract the relative values from 1. This way, you get light pixels mapped to dense characters, suitable for light text on a dark background.
-
Calculate the required aspect ratio (width:height) in "char-pixels", based on the rendered width and height of the characters, where each char-pixel is a character from the charset.
For example, a charset composed of
half-width
characters will need to render more char-pixels vertically to have the same resulting aspect ratio as one composed offull-width
characters. Render the target image in the required aspect ratio, then calculate the relative luminance of each pixel.
Apply brightness and contrast modifying functions to each pixel value, based on the configured options.
As before, normalize the absolute values into relative values in the range
0..1
(0 is the darkest and 1 is lightest).Map the resulting luminance value of each pixel onto the character closest in density value.
Render the resulting 2d matrix of characters in a monospace font.
With the HTML5 Canvas API, we can do all this without leaving the browser! 🚀
Show me the code!
Without further ado...
Calculating character density
CanvasRenderingContext2D#getImageData
gives a Uint8ClampedArray
of channels in the order red, green, blue, alpha
. For example, a 2×2 image in these colors (the last pixel is transparent):
Would result in the following data:
[
// red green blue alpha
255, 0, 0, 255, // top-left pixel
0, 255, 0, 255, // top-right pixel
0, 0, 255, 255, // bottom-left pixel
0, 0, 0, 0, // bottom-right pixel
]
As we're drawing black on transparent, we check which channel we're in using a modulo operation and ignore all the channels except for alpha
(the transparency channel).
Here's our logic for calculating character density:
const CANVAS_SIZE = 70
const FONT_SIZE = 50
const BORDER = (CANVAS_SIZE - FONT_SIZE) / 2
const LEFT = BORDER
const BASELINE = CANVAS_SIZE - BORDER
const RECT: Rect = [0, 0, CANVAS_SIZE, CANVAS_SIZE]
export enum Channels {
Red,
Green,
Blue,
Alpha,
Modulus,
}
export type Channel = Exclude<Channels, Channels.Modulus>
export const getRawCharDensity =
(ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D) =>
(ch: string): CharVal => {
ctx.clearRect(...RECT)
ctx.fillText(ch, LEFT, BASELINE)
const val = ctx
.getImageData(...RECT)
.data.reduce(
(total, val, idx) =>
idx % Channels.Modulus === Channels.Alpha
? total - val
: total,
0,
)
return {
ch,
val,
}
}
Note that we subtract the alpha values rather than adding them, because denser characters are darker (lower RGB values) than sparser ones. This means all the raw values will be negative. However, that doesn't matter, as we'll be normalizing them shortly.
Next, we iterate over the whole charset, keeping a track of min
and max
:
export const createCanvas = (width: number, height: number) =>
globalThis.OffscreenCanvas
? new OffscreenCanvas(width, height)
: (Object.assign(document.createElement('canvas'), {
width,
height,
}) as HTMLCanvasElement)
export const getRawCharDensities = (charSet: CharSet): RawCharDensityData => {
const canvas = createCanvas(CANVAS_SIZE, CANVAS_SIZE)
const ctx = canvas.getContext('2d')!
ctx.font = `${FONT_SIZE}px monospace`
ctx.fillStyle = '#000'
const charVals = [...charSet].map(getRawCharDensity(ctx))
let max = -Infinity
let min = Infinity
for (const { val } of charVals) {
max = Math.max(max, val)
min = Math.min(min, val)
}
return {
charVals,
min,
max,
}
}
Finally, we normalize the values in relation to that min
and max
:
export const getNormalizedCharDensities =
({ invert }: CharValsOptions) =>
({ charVals, min, max }: RawCharDensityData) => {
// minimum of 1, to prevent dividing by 0
const range = Math.max(max - min, 1)
return charVals
.map(({ ch, val }) => {
const v = (val - min) / range
return {
ch,
val: invert ? 1 - v : v,
}
})
.sort((a, b) => a.val - b.val)
}
Calculating aspect ratio
Here's how we calculate aspect ratio:
// separators and newlines don't play well with the rendering logic
const SEPARATOR_REGEX = /[\n\p{Z}]/u
const REPEAT_COUNT = 100
const pre = appendInvisible('pre')
const _getCharScalingData =
(repeatCount: number) =>
(
ch: string,
): {
width: number
height: number
aspectRatio: AspectRatio
} => {
pre.textContent = `${`${ch.repeat(repeatCount)}\n`.repeat(repeatCount)}`
const { width, height } = pre.getBoundingClientRect()
const min = Math.min(width, height)
pre.textContent = ''
return {
width: width / repeatCount,
height: height / repeatCount,
aspectRatio: [min / width, min / height],
}
}
For performance reasons, we assume all characters in the charset are equal width and height. If they're not, the output will be garbled anyway.
Calculating image pixel brightness
Here's how we calculate the relative brightness, or technically the relative perceived luminance, of each pixel:
const perceivedLuminance = {
[Channels.Red]: 0.299,
[Channels.Green]: 0.587,
[Channels.Blue]: 0.114,
} as const
export const getMutableImageLuminanceValues = ({
resolutionX,
aspectRatio,
img,
}: ImageLuminanceOptions) => {
if (!img) {
return {
pixelMatrix: [],
flatPixels: [],
}
}
const { width, height } = img
const scale = resolutionX / width
const [w, h] = [width, height].map((x, i) =>
Math.round(x * scale * aspectRatio[i]),
)
const rect: Rect = [0, 0, w, h]
const canvas = createCanvas(w, h)
const ctx = canvas.getContext('2d')!
ctx.fillStyle = '#fff'
ctx.fillRect(...rect)
ctx.drawImage(img, ...rect)
const pixelData = ctx.getImageData(...rect).data
let curPix = 0
const pixelMatrix: { val: number }[][] = []
let max = -Infinity
let min = Infinity
for (const [idx, d] of pixelData.entries()) {
const channel = (idx % Channels.Modulus) as Channel
if (channel !== Channels.Alpha) {
// rgb channel
curPix += d * perceivedLuminance[channel]
} else {
// append pixel and reset during alpha channel
// we set `ch` later, on second pass
const thisPix = { val: curPix, ch: '' }
max = Math.max(max, curPix)
min = Math.min(min, curPix)
if (idx % (w * Channels.Modulus) === Channels.Alpha) {
// first pixel of line
pixelMatrix.push([thisPix])
} else {
pixelMatrix[pixelMatrix.length - 1].push(thisPix)
}
curPix = 0
}
}
// one-dimensional form, for ease of sorting and iterating.
// changing individual pixels within this also
// mutates `pixelMatrix`
const flatPixels = pixelMatrix.flat()
for (const pix of flatPixels) {
pix.val = (pix.val - min) / (max - min)
}
// sorting allows us to iterate over the pixels
// and charVals simultaneously, in linear time
flatPixels.sort((a, b) => a.val - b.val)
return {
pixelMatrix,
flatPixels,
}
}
Why mutable, you ask? Well, we can improve performance by re-using this matrix for the characters to output.
In addition, we return a flattened and sorted version of the matrix. Mutating the objects in this flattened version persists through to the matrix itself. This allows for iterating in O(n)
instead of O(nm)
time complexity, where n
is the number of pixels and m
is the number of chars in the charset.
Map pixels to characters
Here's how we map the pixels onto characters:
export type CharPixelMatrixOptions = {
charVals: CharVal[]
brightness: number
contrast: number
} & ImageLuminanceOptions
let cachedLuminanceInfo = {} as ImageLuminanceOptions &
ReturnType<typeof getMutableImageLuminanceValues>
export const getCharPixelMatrix = ({
brightness,
contrast,
charVals,
...imageLuminanceOptions
}: CharPixelMatrixOptions): CharPixelMatrix => {
if (!charVals.length) return []
const luminanceInfo = Object.entries(imageLuminanceOptions).every(
([key, val]) =>
cachedLuminanceInfo[key as keyof typeof imageLuminanceOptions] ===
val,
)
? cachedLuminanceInfo
: getMutableImageLuminanceValues(imageLuminanceOptions)
cachedLuminanceInfo = { ...imageLuminanceOptions, ...luminanceInfo }
const charPixelMatrix = luminanceInfo.pixelMatrix as CharVal[][]
const flatCharPixels = luminanceInfo.flatPixels as CharVal[]
const multiplier = exponential(brightness)
const polynomialFn = polynomial(exponential(contrast))
let charValIdx = 0
let charVal: CharVal
for (const charPix of flatCharPixels) {
while (charValIdx < charVals.length) {
charVal = charVals[charValIdx]
if (polynomialFn(charPix.val) * multiplier > charVal.val) {
++charValIdx
continue
} else {
break
}
}
charPix.ch = charVal!.ch
}
// cloning the array updates the reference to let React know it needs to re-render,
// even though individual rows and cells are still the same mutated ones
return [...charPixelMatrix]
}
The polynomial
function increases contrast by skewing values toward the extremes. You can see some examples of polynomial functions at easings.net — quad
, cubic
, quart
, and quint
are polynomials of degree 2, 3, 4, and 5 respectively.
The exponential
function simply converts numbers in the range 0..100
(suitable for user-friendly configuration) into numbers exponentially increasing in the range 0.1..10
(giving better results for the visible output).
Here are those two functions:
export const polynomial = (degree: number) => (x: number) =>
x < 0.5
? Math.pow(2, degree - 1) * Math.pow(x, degree)
: 1 - Math.pow(-2 * x + 2, degree) / 2
export const exponential = (n: number) => Math.pow(10, n / 50 - 1)
...Fin!
Finally, here's how we render the text art to a string:
export const getTextArt = (charPixelMatrix: CharPixelMatrix) =>
charPixelMatrix.map((row) => row.map((x) => x.ch).join('')).join('\n')
The UI for this project is built in React ⚛ and mostly isn't as interesting as the algorithm itself. I might write a future post about that if there's interest in it.
I had a lot of fun and learned a lot creating this project! 🎉 Future additional features, in approximate order of implementation difficulty, could include:
- Allowing colorized output.
- Moving at least some of the logic to web workers to prevent blocking of the main thread during expensive computation. Unfortunately, the OffscreenCanvas API currently has poor support outside of Chromium-based browsers, which limits what we could do in this respect while remaining cross-browser compatible without adding quite a bit of complexity.
- Adding an option to use dithering, which would improve results for small charsets or charsets with poor contrast characteristics.
- Taking into account the sub-char-pixel properties of each character to give more accurate rendering. For example,
_
is dense at the bottom and empty at the top, rather than uniformly low-density. - Adding an option to use an edge detection algorithm to improve results for certain types of images.
- Allowing for variable-width charsets and fonts. This would require a massive rewrite of the algorithm and isn't something I've ever seen done before, but it would theoretically be possible.
I'm not planning on implementing any of these features in the near future, but those are some ideas to get you started for anyone that wants to try forking the project.
Thanks for reading! Don't forget to leave your feedback in the comments 😁