Click here to Skip to main content
15,904,287 members
Articles / Programming Languages / C++

2ascii: Render JPGs PNGs, SVGs and text to ASCII

Rate me:
Please Sign up or sign in to vote.
5.00/5 (16 votes)
23 Oct 2023MIT5 min read 11.8K   298   19   10
Create ASCII art from common image formats and text
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.

2ascii

(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:

C++
#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:

C++
// 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:

C++
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:

C++
// 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:

C++
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:

C++
} 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:

C++
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!

History

  • 5th October, 2023 - Initial submission
  • 23rd October, 2023 - Added text output

License

This article, along with any associated source code and files, is licensed under The MIT License


Written By
United States United States
Just a shiny lil monster. Casts spells in C++. Mostly harmless.

Comments and Discussions

 
QuestionChosen Image Pin
TFranklinH14-Nov-23 9:41
TFranklinH14-Nov-23 9:41 
AnswerRe: Chosen Image Pin
honey the codewitch14-Nov-23 9:58
mvahoney the codewitch14-Nov-23 9:58 
GeneralRe: Chosen Image Pin
TFranklinH14-Nov-23 11:46
TFranklinH14-Nov-23 11:46 
QuestionNice tool Pin
Salam Elias1-Nov-23 1:36
Salam Elias1-Nov-23 1:36 
AnswerRe: Nice tool Pin
honey the codewitch1-Nov-23 2:12
mvahoney the codewitch1-Nov-23 2:12 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA14-Oct-23 20:23
professionalȘtefan-Mihai MOGA14-Oct-23 20:23 
QuestionCompiled under Linux Pin
colins210-Oct-23 5:52
colins210-Oct-23 5:52 
AnswerRe: Compiled under Linux Pin
honey the codewitch11-Oct-23 2:29
mvahoney the codewitch11-Oct-23 2:29 
GeneralRe: Compiled under Linux Pin
colins211-Oct-23 3:05
colins211-Oct-23 3:05 
Just a couple of minor things to note:

Maybe you should add that people have to #define GFX_BIG/LITTLE_ENDIAN before compiling - well at least on Linux anyway.
The 2nd point is that I don't have stricmp or strcmpi in my string.h header. I changed the statements to just strcmp and just have to remember to use lower case for the filenames.
GeneralRe: Compiled under Linux Pin
honey the codewitch11-Oct-23 3:20
mvahoney the codewitch11-Oct-23 3:20 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.