Updated on 2023-10-23
Create ASCII art from common image formats and text
(Original SVG by Spartan at nationalzero.com)
I wrote this tool to test getting my graphics library working with Microsoft's latest C++ compiler. Turns out it wasn't a big deal, but I was left with this fun little command line tool that takes an image or a font and some text and spits ASCII art for it.
Update: Added text output support
Run the fetch_deps.cmd in the root directory and then right click on the CMakeLists.txt in VS Code and click Build All Projects. Finally, the run.cmd is set up for MSVC Debug builds and will run the project with default arguments. Otherwise, run 2ascii.exe manually.
2ascii.exe takes a filename as the first argument, and an optional second argument that is an integer value from 1 to 1000 representing the scale as a percentage of the original. It then spits the resulting ASCII to stdout.
2ascii.exe takes a TTF or OTF font filename as the first argument, the text line height as the 2nd argument, and the text as the 3rd argument - it's best to put that in double quotes. It then spits the resulting ASCII. All of the arguments are required.
Disclaimer: The library I am using to make the magic is designed for IoT and embedded, and as such, it may not process every possible font or image out there. JPG, for example, has many different formats, and this library only supports common formats. SVGs and fonts face similar challenges. That said, this should work with many files. If it doesn't work with your JPG, one workaround is to open it in mspaint and then save it as JPG again.
All the magic is in main.cpp.
First, we include some headers. Despite this being C++, the graphics library is IoT/embedded and targets platforms that have incomplete/non-compliant implementations of the STL. In addition, little devices do not have the RAM to make using the STL viable without a heap fragmentation struggle. That's why you'll see C headers instead of C++ below:
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <gfx.hpp>
using namespace gfx;
You'll note above in addition to standard headers, we've included gfx.hpp and imported the gfx namespace. This is htcw_gfx or GFX - the graphics library that does the heavy lifting.
Next, we have the routine that takes a GFX "draw source" and prints it to the console as ASCII. A GFX draw source is basically anything that can provide random access to pixel data it contains, like a bitmap. GFX makes it easy to do this. The idea here is we move through the draw source, top to bottom, left to right. At each point, we read the color value of the pixel at that location. The pixel format for the draw source is referred to in this routine as Source::pixel_type. Every draw target (source or destination) exposes certain members, and pixel_type is one. dimensions() is another. Draw sources also expose point() to read a pixel. This uses all of that. Anyway, we convert the source pixel using the convert<>() function to 4-bit grayscale, which yields values between 0 and 15 from its lone Luminosity channel. We then use that channel value as an index into a string with our "color table." The color table is just a series of characters that are increasingly "dark" (black on white) or "light" (white on black). Currently, this is the string: " .,-~;+=x!1%$O@#". Note how there are 16 characters (including the initial space). Every time we increment y, we write a newline:
// prints a source as 4-bit grayscale ASCII
template <typename Source>
void print_ascii(const Source& src) {
// the color table
static const char* col_table = " .,-~;+=x!1%$O@#";
// move through the draw source
for (int y = 0; y < src.dimensions().height; ++y) {
for (int x = 0; x < src.dimensions().width; ++x) {
typename Source::pixel_type px;
// get the pixel at the current point
src.point(point16(x, y), &px);
// convert it to 4-bit grayscale (0-15)
const auto px2 = convert<typename Source::pixel_type, gsc_pixel<4>>(px);
// get the solitary "L" (luminosity) channel value off the pixel
size_t i = px2.template channel<channel_name::L>();
// use it as an index into the color table
putchar(col_table[i]);
}
putchar('\r');
putchar('\n');
}
}
In main(), the first thing we do is check arguments and parse the 2nd one:
if (argc > 1) { // at least 1 param
float scale = 1; // scale of image
if (argc > 2) { // 2nd arg is scale percentage
int pct = atoi(argv[2]);
if (pct > 0 && pct <= 1000) {
scale = ((float)pct / 100.0f);
}
}
At that point, our scale reflects the percentage passed in if any, scaled to a floating point value where 1 is 1:1 scaling and .5 is 1:2 scaling.
Now, we open the file named in argv[1] and get the length of it which we'll need later. We also prepare a couple of flags. Finally, we make sure our filename is longer than 4 characters, counting the . and the extension:
// open the file
file_stream fs(argv[1]);
size_t arglen = strlen(argv[1]);
bool png = false;
bool jpg = false;
if (arglen > 4) {
If it's an SVG, we use GFX to create and read an svg_doc out of the file_stream. Then we create a bitmap the final size of the scaled output. Next we draw the SVG to the bitmap at the specified scale before printing the bitmap as ASCII. Finally, we free the bitmap and return 0 indicating success:
if (0 == stricmp_i(argv[1] + arglen - 4, ".svg")) {
svg_doc doc;
// read it
svg_doc::read(&fs, &doc);
fs.close();
// create a bitmap the size of our final scaled SVG
auto bmp = create_bitmap<gsc_pixel<4>>(
{uint16_t(doc.dimensions().width * scale),
uint16_t(doc.dimensions().height * scale)});
// if not out of mem allocating bitmap
if (bmp.begin()) {
// clear it
bmp.clear(bmp.bounds());
// draw the SVG
draw::svg(bmp, bmp.bounds(), doc, scale);
// dump as ascii
print_ascii(bmp);
// free the bmp
free(bmp.begin());
return 0;
}
return 1;
Otherwise, if it's a JPG or a PNG, we set the appropriate flag:
} else if (0 == stricmp_i(argv[1] + arglen - 4, ".jpg")) {
jpg = true;
} else if (0 == stricmp_i(argv[1] + arglen - 4, ".png")) {
png = true;
}
If it's a JPG or a PNG, the code is largely the same, so it relies on the same handling code. For a scale of 1, we simply create a bitmap the size of the image, draw the image to it, and then free() the bitmap before returning. If the scale is not 1, we must do extra work. The first thing we do is allocate a bitmap of the final scaled size. Then we allocate another bitmap the size of the image. We draw the image to the 2nd bitmap, and then resample it to the first bitmap. If it's larger, we use linear resampling. If it's smaller, we use bicubic resampling. Finally, we spit the image to ASCII, free the bitmaps and return 0, indicating success:
int result = 1;
size16 dim;
if (gfx_result::success ==
(jpg ? jpeg_image::dimensions(&fs, &dim)
: png_image::dimensions(&fs, &dim))) {
fs.seek(0);
auto bmp_original = create_bitmap<gsc_pixel<4>>(
{uint16_t(dim.width),
uint16_t(dim.height)});
if (bmp_original.begin()) {
bmp_original.clear(bmp_original.bounds());
draw::image(bmp_original, bmp_original.bounds(), &fs);
fs.close();
if (scale != 1) {
// create a bitmap the size of our final scaled image
auto bmp = create_bitmap<gsc_pixel<4>>(
{uint16_t(dim.width * scale),
uint16_t(dim.height * scale)});
// if not out of mem allocating bitmap
if (bmp.begin()) {
// clear it
bmp.clear(bmp.bounds());
// draw the SVG
if (scale < 1) {
draw::bitmap(bmp,
bmp.bounds(),
bmp_original,
bmp_original.bounds(),
bitmap_resize::resize_bicubic);
} else {
draw::bitmap(bmp,
bmp.bounds(),
bmp_original,
bmp_original.bounds(),
bitmap_resize::resize_bilinear);
}
result = 0;
// dump as ascii
print_ascii(bmp);
// free the bmp
free(bmp.begin());
}
} else {
result = 0;
// dump as ascii
print_ascii(bmp_original);
}
free(bmp_original.begin());
return result;
}
}
Astute readers may have noticed that our bitmaps are in gsc_pixel<4> format. That's 4-bit grayscale, and it's to save memory because we don't need it at a higher color depth than that, and that way, we can pack 2 pixels per byte, instead of requiring 3 bytes per pixel at full color depth.
That's really all there is to it. Hopefully, you find the graphics library useful and approachable. Documentation is at the provided link from above. It's pretty powerful for IoT and embedded, but can even be fun on a PC. Enjoy!