This tool was developed to test compatibility with Microsoft's latest C++ compiler, resulting in a command-line utility that converts images to ASCII art, supporting various image formats, including SVG, JPG, and PNG, with the option to specify a scale. It also supports rendering text using TrueType and OpenType fonts.
(Original SVG by Spartan at nationalzero.com)
Introduction
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
Prerequisites
- You'll need VS Code w/ the Microsoft CMake and C++ extensions installed.
- You'll need to have git installed from git-scm.org and in your path.
- You'll need a C++ compiler. I've tested with GCC and MSVC. Clang should work but it has been a long time since I ran the code through it.
Building 2ascii.exe
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.
Using 2ascii.exe
For Images
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
.
For Text
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.
Coding this Mess
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 L
uminosity 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:
template <typename Source>
void print_ascii(const Source& src) {
static const char* col_table = " .,-~;+=x!1%$O@#";
for (int y = 0; y < src.dimensions().height; ++y) {
for (int x = 0; x < src.dimensions().width; ++x) {
typename Source::pixel_type px;
src.point(point16(x, y), &px);
const auto px2 = convert<typename Source::pixel_type, gsc_pixel<4>>(px);
size_t i = px2.template channel<channel_name::L>();
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) { float scale = 1; if (argc > 2) { 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:
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;
svg_doc::read(&fs, &doc);
fs.close();
auto bmp = create_bitmap<gsc_pixel<4>>(
{uint16_t(doc.dimensions().width * scale),
uint16_t(doc.dimensions().height * scale)});
if (bmp.begin()) {
bmp.clear(bmp.bounds());
draw::svg(bmp, bmp.bounds(), doc, scale);
print_ascii(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) {
auto bmp = create_bitmap<gsc_pixel<4>>(
{uint16_t(dim.width * scale),
uint16_t(dim.height * scale)});
if (bmp.begin()) {
bmp.clear(bmp.bounds());
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;
print_ascii(bmp);
free(bmp.begin());
}
} else {
result = 0;
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!
History
- 5th October, 2023 - Initial submission
- 23rd October, 2023 - Added text output