DEV Community

Nicole Akinyi
Nicole Akinyi

Posted on

How ASCII Art Banner Rendering Actually Works: Parsing, Alignment, and the Edge Cases That Break It

ASCII art generators look trivial from the outside: take some text, print it in a blocky font made of characters, done. Having built one as a terminal application and then again as a browser-accessible web server in Go, I can say the actual difficulty is almost entirely in the parsing and edge-case handling, not in the printing. This article covers the real mechanics of turning a banner font file into rendered text output, and the specific failure modes that a naive implementation runs into.

What a banner file actually is

A banner file is a plain text file that encodes an entire font, one character at a time, using ASCII characters arranged in a fixed-height block. A typical format reserves a fixed number of lines per character, commonly 8 lines representing the printable ASCII range from space through tilde, with each character's glyph occupying that many consecutive lines before the next character's glyph begins.

A tiny excerpt for the letter A might look like:

 _
/ \
|_|
| |
Enter fullscreen mode Exit fullscreen mode

with each line being one row of the glyph, and the glyph itself spanning a fixed number of rows regardless of how many of those rows are actually "filled in" for that particular character. A period or a space typically renders as mostly or entirely blank rows, but it still occupies the full row count, because the whole scheme depends on every character's glyph having identical height so that lines can be assembled predictably.

The core rendering algorithm

The naive-sounding approach is actually correct here, which is a nice change from the max-flow situation in a different project: for each line of input text, you render that line by, for each row index from 0 to the glyph height, concatenating the corresponding row of every character's glyph in sequence, left to right, then moving to the next row index, until all rows for that line of text have been printed.

const glyphHeight = 8

func RenderLine(text string, font map[rune][]string) string {
    if text == "" {
        return strings.Repeat("\n", glyphHeight-1) + "\n"
    }

    var rows [glyphHeight]strings.Builder
    for _, ch := range text {
        glyph, ok := font[ch]
        if !ok {
            continue
        }
        for row := 0; row < glyphHeight; row++ {
            rows[row].WriteString(glyph[row])
        }
    }

    var out strings.Builder
    for row := 0; row < glyphHeight; row++ {
        out.WriteString(rows[row].String())
        out.WriteByte('\n')
    }
    return out.String()
}
Enter fullscreen mode Exit fullscreen mode

This is the entire rendering logic, and it is genuinely almost that simple. Everything that makes an ASCII art tool hard to get right lives in the parsing step that builds the font map[rune][]string, and in a handful of edge cases around input handling that are easy to overlook until a test case exposes them.

Parsing the banner file correctly

The parsing challenge is that the banner file is just a flat sequence of lines with no explicit markers saying where one character's glyph ends and the next begins. You have to derive that boundary purely from position, using the fact that the printable ASCII range starts at space (decimal 32) and that each glyph occupies a known, fixed number of lines.

func ParseFont(data []byte) (map[rune][]string, error) {
    lines := strings.Split(string(data), "\n")

    font := make(map[rune][]string)
    current := rune(32) // space is the first printable character

    for i := 0; i+glyphHeight <= len(lines); i += glyphHeight {
        glyph := lines[i : i+glyphHeight]
        font[current] = glyph
        current++
        if current > '~' {
            break
        }
    }

    if len(font) == 0 {
        return nil, fmt.Errorf("font file contained no complete glyphs")
    }
    return font, nil
}
Enter fullscreen mode Exit fullscreen mode

The off-by-one errors here are the most common source of bugs, and they are the kind of bug that is very easy to introduce and very hard to spot by eye, because a font that is shifted by even one line still renders something that looks like plausible ASCII art, just for the wrong characters. If your banner file has a leading blank line before the first glyph, or a trailing blank line after the last one, or uses a blank-line separator between glyphs that you are not accounting for in your height calculation, every single character in your rendered output silently shifts to represent the wrong glyph, and the bug can go unnoticed for a long time because the output still looks like art, just art for a different string than the one you asked for.

The defense against this is not cleverness, it is a specific, boring kind of test: render one known character, by itself, and diff it byte-for-byte against the exact expected glyph copied straight out of the font file. Doing this for the first and last characters in the printable range in particular catches most indexing bugs immediately, because those are the positions most likely to be affected by a miscounted offset.

Edge case one: newlines inside the input string

The most common real bug report on a project like this is "\n doesn't work," referring to a user passing a literal newline character inside their input text, expecting it to start a new banner line, and it just not being handled, because the renderer as written above treats the entire input as one line to be rendered as one block of glyph rows.

The fix is to split on newlines before rendering, and render each resulting segment as its own independent block:

func Render(text string, font map[rune][]string) string {
    segments := strings.Split(text, "\n")
    var out strings.Builder
    for _, seg := range segments {
        out.WriteString(RenderLine(seg, font))
    }
    return out.String()
}
Enter fullscreen mode Exit fullscreen mode

The subtlety worth calling out is what happens with an empty segment, which occurs whenever the input has two consecutive newlines or a trailing newline. An empty segment needs to still produce a blank line in the output, specifically glyphHeight worth of newline characters representing an empty banner row, rather than producing nothing at all, or the visual spacing of multi-line input silently collapses and no longer matches what the user typed.

Edge case two: characters outside the supported range

Input text will eventually contain a character that is not in your font map, whether that is an unsupported symbol, an emoji, or a character from outside the ASCII range entirely. The naive approach in the render loop above, silently skipping unknown characters, is a defensible choice, but it is a choice, and it needs to be a deliberate one rather than an accident, because the alternative, a program that panics or produces garbled output on unexpected input, is a much worse experience for anyone actually using the tool.

func RenderLine(text string, font map[rune][]string) (string, []rune) {
    var unsupported []rune
    var rows [glyphHeight]strings.Builder

    for _, ch := range text {
        glyph, ok := font[ch]
        if !ok {
            unsupported = append(unsupported, ch)
            continue
        }
        for row := 0; row < glyphHeight; row++ {
            rows[row].WriteString(glyph[row])
        }
    }

    var out strings.Builder
    for row := 0; row < glyphHeight; row++ {
        out.WriteString(rows[row].String())
        out.WriteByte('\n')
    }
    return out.String(), unsupported
}
Enter fullscreen mode Exit fullscreen mode

Returning the list of unsupported characters alongside the rendered output, rather than swallowing that information, lets the caller decide how to surface it, whether that is a warning message in a CLI tool or a validation error in a web form, instead of baking that decision into the rendering function itself.

Taking it from CLI to a web server

The move from a terminal tool to an HTTP server version of the same renderer sounds like it should just be wrapping the existing function in a handler, and functionally, it mostly is:

func RenderHandler(font map[rune][]string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        text := r.FormValue("text")
        if text == "" {
            http.Error(w, "text parameter is required", http.StatusBadRequest)
            return
        }
        if len(text) > 1000 {
            http.Error(w, "input too long", http.StatusBadRequest)
            return
        }
        output, unsupported := RenderLineSet(text, font)
        if len(unsupported) > 0 {
            w.Header().Set("X-Unsupported-Characters", fmt.Sprintf("%d", len(unsupported)))
        }
        w.Header().Set("Content-Type", "text/plain; charset=utf-8")
        fmt.Fprint(w, output)
    }
}
Enter fullscreen mode Exit fullscreen mode

What actually changes meaningfully between the CLI and web versions is input trust. A CLI tool run by the person who wrote it can reasonably assume well-formed input. An HTTP handler accepting form input from arbitrary requests cannot, which is why the length check and the required-field check exist in the handler above and did not need to exist in the original command-line version at all. The rendering algorithm itself does not change; the boundary of what you are willing to trust does.

The general lesson

The rendering step in a project like this is close to trivial once you have the font parsed correctly, which is a pattern that shows up constantly in software that manipulates structured text or binary formats: the transformation logic is usually the easy part, and the actual engineering effort goes into correctly parsing the input format and handling the edge cases at its boundaries. It is worth noticing when a piece of code you are about to write fits that pattern, because it changes where you should actually spend your debugging and testing effort. Test the parser exhaustively against known-good output first. The renderer built on top of a correctly parsed font will very often just work.

Top comments (0)