42 Astoundingly Useful Scripts and Automations for the Macintosh

Create your own ASCII art palettes with densitySort

You can create your own ASCII art palettes from special (fixed pitch) fonts and specific collections of characters, using the densitySort script here.

Jerry Stratton, January 27, 2021

ASCII viola

asciiArt Viola.jpg --palette "BDAGECF " --save viola.png --bgcolor 1,1,1,0

A lot of choosing your palette for ASCII art is an art, because the distribution of pixels in each character matters as much as the number of them. Even resolution can matter: a densely packed character such as an asterisk might look darker than a sparsely packed character such as a thick letter O if there are only a few characters per row, but instead have the same effect if there are thousands of characters per row.

But a script can get you started. I wrote densitySort to help me put together the palettes I used in 42 Astounding Scripts.

The script is fairly simple. It creates an image of each character, calculates the darkness of the image, and then saves that darkness in an array.

Then, it sorts the array by darkness descending and prints out the sorted palette.

At its most basic, you can give it a palette and it will sort the palette using the Monaco font, because that’s the font I use in Terminal.

Say you want to use all of the letters that make up a musical scale in an ASCII image:

  • $ ~/bin/densitySort ABCDEFG
  • BDAGECF

You can now use that palette to create, say, an ASCII art image of a guitar (or of a viola, as in the top image on this post):

  • asciiArt Guitar.jpg --palette "BDAGECF "

Because some characters have special meanings on the command line, you’ll often need to surround the palette with apostrophes:

  • $ ~/bin/densitySort '$&%@!'
  • @&%$!

You can change the font using the --font option.

  • $ ~/bin/densitySort '$&%@!' --font Courier
  • @$&%!

If you leave off the palette, it will provide the entire normal ASCII art character set for the font:

  • $ ~/bin/densitySort --font Courier
  • MQW#BNqpHERmKdgAGbX8@SDO$PUkwZyF69heT0a&xV%Cs4fY52Lonz3ucJjvItr}{li?1][7<>=)(+*|!/\;:-,"_~^.'`

Those are all characters from ASCII character 33 (the exclamation) through 126 (the tilde).

You can also ask it to choose x somewhat evenly-spaced characters from the default or custom palette.

  • $ ~/bin/densitySort --font Courier --choose 10
  • ME$sj1|-^`

Here’s the guitar again, using that palette:

[toggle code]

  • $ ~/bin/asciiArt Guitar.jpg  --font Courier --palette 'ME$sj1|-^` '

Not as symbolic, but more contrast.

There are a couple of common palettes hardcoded into the script. If you want to limit your palette to numbers, use “numbers” in place of the palette.

  • $ ~/bin/densitySort --font Courier numbers
  • 8960452317

Here are all of the built-in palettes:

numbers1234567890
lettersabcdefghijklmnopqrstuvwxyz
LETTERSABCDEFGHIJKLMNOPQRSTUVWXYZ
math1234567890+-/*=
punctuation !\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~
short@%#*+=-:.
long$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'.

The “short” and “long” palettes are from Paul Bourke’s ASCII art palettes.

If there is a collection of characters you often use in different fonts, you can create your own built-in palette. Modify the “palettes” array in the script.

If you want to see what the calculations are for each character in the palette, add --tabulate:

  • $ ~/bin/densitySort --font Courier --choose 10 --tabulate
Coverage for font Courier
M100.0 77
E 88.3 69
$ 75.1 36
s 66.5115
j 54.5106
1 42.8 49
| 32.3124
- 17.1 45
^ 10.5 94
` 0.0 96

The first column of numbers is the percentage of coverage. It will be normalized if you’re using --choose. The lowest coverage character will be zero, and the highest 100. The second column of numbers is the ASCII value for the character.

And if you want to see the character images that the script is using, add --save to the command line. The script will save each image, using the font name and the character, to the current directory. (So you may wish to mkdir a new directory and cd into it before running the command.)

The density calculation is performed in the function calculateDensity.

[toggle code]

  • func calculateDensity(bitmap:NSBitmapImageRep) -> Double {
    • var density = 0.0
    • for row in 0 ... Int(bitmap.pixelsHigh-1) {
      • for column in 0 ... Int(bitmap.pixelsWide-1) {
        • guard let color = bitmap.colorAt(x: column, y: row) else {
          • print("Trouble getting color at", column, row, "from", bitmap.size)
          • exit(0)
        • }
        • //red, green, and blue are all the same, so only need to add one of them
        • density += Double(1-color.redComponent)
      • }
    • }
    • return density
  • }

It does nothing but add up the red value of each pixel in the character image—because the foreground color is set to black elsewhere, and the background to white, the red, green, and blue values are all the same. If you want to play around with different calculations, such as checking to see how dispersed the black pixels are, you can change that function to use your custom formula.

Here’s the complete code:

[toggle code]

  • #!/usr/bin/swift
  • // sort ASCII characters according to fake grayscale density
  • // Jerry Stratton astoundingscripts.com
  • import AppKit
  • var arguments = CommandLine.arguments
  • let commandName = arguments.removeFirst()
  • let fontSize:CGFloat = 48.0
  • var fontName = "Monaco"
  • //some common palettes to sort
  • let palettes = [
    • //Paul Bourke's palettes
    • "short": "@%#*+=-:.",
    • "long": "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/\\|()1{}[]?-_+~<>i!lI;:,\"^`'.",
    • //other sets
    • "numbers": "1234567890",
    • "letters": "abcdefghijklmnopqrstuvwxyz",
    • "LETTERS": "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
    • "math": "1234567890+-/*=",
    • "punctuation": " !\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~",
  • ]
  • func help(message: String = "") {
    • if message != "" {
      • print(message)
    • }
    • print("Syntax:", commandName, "[--options] [palette]")
    • print("\t--choose <x>: choose x characters from palette, evenly spaced")
    • print("\t--help: print this help and exit")
    • print("\t--font <name>: font name to calculate densities for")
    • print("\t--tabulate: display table of coverage percentages for each character in palette")
    • exit(0)
  • }
  • var characterAttributes = [
    • NSAttributedString.Key.font:NSFont(name: fontName, size: fontSize)!,
    • NSAttributedString.Key.backgroundColor: NSColor.white,
    • NSAttributedString.Key.foregroundColor: NSColor.black,
  • ]
  • func makeCharacter(asciiCharacter:Character) -> NSBitmapImageRep {
    • //create image of character and return a bitmap version
    • let character = NSAttributedString(string:String(asciiCharacter), attributes:characterAttributes)
    • let outputImage = NSImage(size:character.size(), flipped: false) { (outputRect) -> Bool in
      • character.draw(in: outputRect)
      • return true
    • }
    • //get bitmap
    • let imageData = outputImage.tiffRepresentation
    • guard let bitmap = NSBitmapImageRep(data: imageData!) else {
        • print("Unable to get bitmap version of letter.")
        • exit(0)
    • }
    • return bitmap;
  • }
  • //get character density
  • func calculateDensity(bitmap:NSBitmapImageRep) -> Double {
    • var density = 0.0
    • for row in 0 ... Int(bitmap.pixelsHigh-1) {
      • for column in 0 ... Int(bitmap.pixelsWide-1) {
        • guard let color = bitmap.colorAt(x: column, y: row) else {
          • print("Trouble getting color at", column, row, "from", bitmap.size)
          • exit(0)
        • }
        • //red, green, and blue are all the same, so only need to add one of them
        • density += Double(1-color.redComponent)
      • }
    • }
    • return density
  • }
  • //handle command-line arguments
  • var displayTable = false
  • var palette = ""
  • var chooseCount:UInt = 0
  • while arguments.count > 0 {
    • let argument = arguments.removeFirst()
    • switch argument {
      • case "--choose", "-c":
        • chooseCount = UInt(arguments.removeFirst()) ?? 0
      • case "--help", "-h":
        • help()
      • case "--font", "-f":
        • fontName = arguments.removeFirst()
        • guard let font = NSFont(name: fontName, size: fontSize) else {
          • print("Unknown font " + fontName)
          • exit(0)
        • }
        • if !font.isFixedPitch {
          • help(message:fontName + " is not a fixed pitch font.")
        • }
        • characterAttributes[NSAttributedString.Key.font] = font
      • case "--tabulate", "-t":
        • displayTable = true
      • default:
        • //if it matches a common palette, use as key
        • if palettes.keys.contains(argument) {
          • palette += palettes[argument]!
        • } else {
          • palette += argument
        • }
    • }
  • }
  • if palette == "" {
    • //if no palette specified, use full ASCII set
    • for character in 33...126 {
      • palette += String(Unicode.Scalar(character)!)
    • }
  • }
  • //calculate densities
  • var densities:[Character: Double] = [:]
  • var charWidth = 0
  • var charHeight = 0
  • var totalCoverage = 0.0
  • for character in palette {
    • let bitmapCharacter = makeCharacter(asciiCharacter:character)
    • //make sure that the character is comparable
    • if charWidth == 0 && charHeight == 0 {
      • charWidth = bitmapCharacter.pixelsWide
      • charHeight = bitmapCharacter.pixelsHigh
      • totalCoverage = Double(charWidth*charHeight)
    • } else {
      • if bitmapCharacter.pixelsHigh != charHeight {
        • print("Height is", bitmapCharacter.pixelsHigh, "not", charHeight)
        • exit(0)
      • }
      • if bitmapCharacter.pixelsWide != charWidth {
        • print("Width is", bitmapCharacter.pixelsWide, "not", charWidth)
        • exit(0)
      • }
    • }
    • let density = calculateDensity(bitmap:bitmapCharacter)
    • densities[character] = density
  • }
  • //sort densities
  • let sortedByDensity = densities.sorted(by: { (leftItem, rightItem) -> Bool in leftItem.value > rightItem.value })
  • var sortedPalette = ""
  • if displayTable {
    • print("Coverage for font", fontName)
  • }
  • //grade on a curve if choosing evenly spaced characters
  • var subtractor = 0.0
  • var multiplier = 1.0
  • if chooseCount > 0 {
    • subtractor = sortedByDensity.last!.value
    • multiplier = 100/(sortedByDensity[0].value - subtractor)
  • }
  • var previousCharacter = sortedByDensity[0]
  • var desiredCharacter = chooseCount
  • var desiredValue = 100.0
  • var previousCoverage = 100.0
  • for character in sortedByDensity {
    • var coverageValue = character.value
    • var useCharacter = false
    • if chooseCount > 0 {
      • coverageValue -= subtractor
      • coverageValue *= multiplier
      • if coverageValue <= desiredValue {
        • useCharacter = true
        • if desiredCharacter > 1 {
          • desiredCharacter -= 1
          • desiredValue = 100.0*Double(desiredCharacter-1)/Double(chooseCount-1)
        • }
      • }
    • } else {
      • coverageValue *= 100/totalCoverage
      • useCharacter = true
    • }
    • if useCharacter {
      • if displayTable {
        • print(String(character.key) + "\t" + String(format:"%5.1f\t%3i", coverageValue, character.key.asciiValue!))
      • }
      • sortedPalette += String(character.key)
    • }
    • previousCharacter = character
    • previousCoverage = coverageValue
  • }
  • if !displayTable {
    • print(sortedPalette)
  • }

Use toggle code to turn it into copyable tabbed text, and then paste it into a text editor. You might find the edit script helpful in doing this.

  1. <- Random ASCII colors