loading...
Cover image for ASCII art/Pixel art in js

ASCII art/Pixel art in js

patopitaluga profile image Patricio Pitaluga ・6 min read

Let's do something fun and pretty (yet professionally useless). We can make ASCII art in the browser. ASCII art is pretty much forgotten since high definition UI are common, but it's a beautiful and nostalgic form of art. The browser might not be the the natural environment for ASCII art but nonetheless it presents some advantages, like being able to create effects using the same css and js that we use in our web projects.

How can js parse an image pixel by pixel?

Hardly.

Let's do it

The server

Since loading images in a canvas element and CORS policy don't get along very well, we need to create a node server to deliver the html and the image that we are using. This is the simplest one that I was able to create:

server.js

const fs = require('fs');
require('http').createServer((req, res) => {
  if (req.url === '/')
    res.end(require('fs').readFileSync('./index.html', 'utf8'));
  if (req.url === '/sample.jpg') {
    res.writeHead(200, { 'Content-Type': 'image/jpg' });
    res.end(fs.readFileSync('./sample.jpg'), 'binary');
  }
}).listen(3000, () => console.log('Listening port 3000'));

The frontend

In an empty index.html file, we'll have the script that creates canvas element and get the 2d context of it.

<html>
<head>
</head>
<body>
<script>
const theCanvas = document.createElement('canvas');
const theContext = theCanvas.getContext('2d');
</script>
</body>
</html>

But what is a context and why do we need one? Doesn't really matter and you can find official explanation somewhere else to not understand it anyway. We'll use one.

Then we need to load an image in a img element and load it in the canvas using the context that we created.

const theImg = new Image;
theImg.onload = () => {
  theContext.drawImage(theImg, 0, 0, theImg.width, theImg.height, 0, 0, theImg.width, theImg.height);
};
theImg.src = 'sample.jpg';

For this example I'm using a very small sample.jpg less than 100px file. It might get a really slow for big images so always use small ones. Also since we will generate characters for every pixel it won't fit in the screen if we were using a big image. You can also use the settings in the drawImage function to resize the image. Check out the documentation.

Now let's read every pixel in the image and get the rgb value of it:

for (let y = 0; y < theImg.height; y++) {
  for (let x = 0; x < theImg.width; x++) {
    const theImageData = theContext.getImageData(x, y, 1, 1);
    const theRGBvalues = theImageData.data;
    console.log('Red ' + theRGBvalues[0]);
    console.log('Green ' + theRGBvalues[1]);
    console.log('Blue ' + theRGBvalues[2]);
  }
}

For every "row" of pixels in the y axis we're getting the color information of every "column" of pixels in the x axis. That's why this process is slow.

let's set the style of our "DOM pixels" in the head of the document.

<style>
.a-row-of-pixels {
  display: flex;
}
.a-pixel {
  flex: 0 0 auto;
  height: 20px;
  width: 20px;
}
</style>

Instead of logging it we're going to draw them in "div pixels". Since updating the DOM that many times can get a little slow I'm concatenating the full pixel matrix in a single string and throw it to the DOM at the end.

let myPixelArt = '';
// Start the first row of "pixels".
myPixelArt += '<div class="a-row-of-pixels">';
for (let y = 0; y < theImg.height; y++) {
  for (let x = 0; x < theImg.width; x++) {
    const theImageData = theContext.getImageData(x, y, 1, 1);
    const theRGBvalues = theImageData.data;

    // Concatenate every column of "pixels" in this row, one after the other.
    myPixelArt += `<div class="a-pixel" style="background: rgb(${ theRGBvalues[0] }, ${ theRGBvalues[1] }, ${ theRGBvalues[2] })"></div>`;

  }
  // Concatenate the end of the row and the beginning of a new one.   
  myPixelArt += '</div><div class="a-row-of-pixels">';
}
// The last row will be empty but who cares, let's close it.
myPixelArt += '</div>';
document.body.innerHTML = myPixelArt;

To start the node server, we'll run 'node server' and enter to http://localhost:3000 in the browser to see the magic happens.

A picture of a bear with huge pixels
Every square is actually a div element with the color as background in the style attribute.

Having fun with characters

Now that We're in control of divs like pixels. How can we turn this into ASCII art?

Back in the day when interfaces lacked graphics and colors, nerds people used characters to represent different nuances of brightness in the screen according to how "bright" (how many pixels where white) were in every monospace characters. For example " .,:ilwW" is a palette of ASCII characters ordered from the darkest to the brightest. What if we want to use characters instead of colors in our pixelart generator.

First we need to set the font style for the document:

  body {
    background: black;
    color: white;
    font-family: monospace;
    font-size: 18px;
  }
  .a-pixel {
    flex: 0 0 auto;
    height: 19px;
    line-height: 19px;
    width: 10px;
    text-align: center;
  }

I'm setting the cell height to almost twice the width because characters are rectangular. You can try different sizes and proportions to get different effects.

Lets define a variable with a set of characters from the darkest to the brightest before the pixel loop:

const brightnessChars = ' .:;+=xX$';

To get the brightness of the pixel we'll find the average of the sum of the red, green and blue values.

const howBrightThisPixelIs = (theRGBvalues[0] + theRGBvalues[1] + theRGBvalues[2]) / 3;

Instead of setting the background of the cell, we will replace it with the character mapping the brightness of the pixel in the brighnessChars string length.

myPixelArt += `<div class="a-pixel">${ brightnessChars.substr(Math.floor(howBrightThisPixelIs * brightnessChars.length / 255), 1) }</div>`;

The result will look something like this:
A picture of a bear with characters instead of pixels

You can try different sets of character palettes. E.g:

const brightnessChars = ' .`^",:;Il!i><~+_-?][}{1)(|tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';

or

const brightnessChars = ' ░▒▓█';

Create your own and have fun.

Taking advantage of css and js

For the cover image of this post I experimented with setting a fixed text and changing the font-size and color for every character.

First, before the loop, I set the text that I want to be repeated along the image and a numeric variable to increment the position:

const theText = 'THIS IS THE TEXT';
let positionInText = 0;

Then, inside the loop, I'll get the letter in the position and increment the counter until it gets to the end of the phrase, then reset it to start again.

const theLetter = theText.substr(positionInText, 1);
positionInText++;
if (positionInText === theText.length) positionInText = 0;

I also defined an array with the font sizes that I want to allow.

  const fontSizes = ['12px', '13px', '14px', '15px', '18px', '20px', '22px'];

In every character I'm mapping the brigtness to the length of fontSizes array and I'm setting the color of the corresponding pixel.

myPixelArt += `<div
  class="a-pixel"
  style="
          color: rgb(${ theRGBvalues[0] }, ${ theRGBvalues[1] }, ${ theRGBvalues[2] });
          font-size: ${ fontSizes[Math.floor(howBrightThisPixelIs * fontSizes.length / 255)] };
        ">${ theLetter }</div>`;

You can experiment with other css effects like rotation, border-radius, opacity, even 3d rotations and animations. Poetry, lyrics, random texts, movie scripts. You can also try allowing some interactivity to the user. Webcam real time ascii art? What about using P5? Let me know if you achieve something interesting.

The code that I used to create the cover image:

<html>
<head>
<meta charset="UTF-8"/>
<style>
  body {
    background: black;
    color: #fff;
    font-family: monospace;
    font-size: 18px;
    font-weight: bold;
  }
  .a-row-of-pixels {
    display: flex;
  }
  .a-pixel {
    flex: 0 0 auto;
    height: 19px;
    height: 10px;
    line-height: 19px;
    width: 10px;
    width: 10px;
    // transform: rotate(20deg);
    text-align: center;
  }
</style>
</head>
<body>
<script>
const theCanvas = document.createElement('canvas');
const theContext = theCanvas.getContext('2d');

const theImg = new Image;
theImg.crossOrigin = '';
theImg.onload = () => {
  theContext.drawImage(theImg, 0, 0, theImg.width, theImg.height, 0, 0, theImg.width, theImg.height);

  const theText = 'BEARSAREAWESOMEAREN\'TTHEY?';

  // const brightnessChars = ' .,:ilwW';
  // const brightnessChars = ' .`^",:;Il!i><~+_-?][}{1)(|tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
  const brightnessChars = ' .:;+=xX$';
  // const brightnessChars = ' ░▒▓█';
  // const brightnessChars = '  ░░▒▓▓███';
  const fontSizes = ['12px', '13px', '14px', '15px', '18px', '20px', '22px'];

  let myPixelArt = '';
  myPixelArt += '<div class="a-row-of-pixels">';
  let positionInText = 0;
  for (let y = 0; y < theImg.height; y += 1) {
    for (let x = 0; x < theImg.width; x++) {
      const theImageData = theContext.getImageData(x, y, 1, 1);
      const theRGBvalues = theImageData.data;
      const howBrightThisPixelIs = (theRGBvalues[0] + theRGBvalues[1] + theRGBvalues[2]) / 3; // the average

      const theLetter = theText.substr(positionInText, 1);
      positionInText++;
      if (positionInText === theText.length) positionInText = 0;
      myPixelArt += `<div
        class="a-pixel"
        style="
color: rgb(${ theRGBvalues[0] }, ${ theRGBvalues[1] }, ${ theRGBvalues[2] });
font-size: ${ fontSizes[Math.floor(howBrightThisPixelIs * fontSizes.length / 255)] };
        ">${ theLetter }</div>`;
      // myPixelArt += `<div class="a-pixel">${ brightnessChars.substr(Math.floor(howBrightThisPixelIs * brightnessChars.length / 255), 1) }</div>`;

      // myPixelArt += `<div class="a-pixel" style="background: rgb(${ theRGBvalues[0] }, ${ theRGBvalues[1] }, ${ theRGBvalues[2] })"></div>`;
    }
    myPixelArt += '</div><div class="a-row-of-pixels">';
  }
  myPixelArt += '</div>';
  document.body.innerHTML = myPixelArt;
};
theImg.src = '/sample.jpg';
</script>
</body>
</html>

Photo by Tom Radetzki on Unsplash

Posted on by:

patopitaluga profile

Patricio Pitaluga

@patopitaluga

Fullstack dev with 15+ years of xp

Discussion

markdown guide