DEV Community

Markus
Markus

Posted on • Originally published at the-main-thread.com on

Quarkus Banner Studio: Build ASCII Art Banners with Qute and FIGlet

Hero image

Startup banners are small, memorable, and in your logs every day. This hands-on builds a tiny web app that designs width-safe ASCII banners for Quarkus. You’ll render FIGlet fonts in Java, fit them to a maximum width, convert uploaded images into ASCII using k-means clustering, and optionally ask a local model via LangChain4j for short banner ideas.

Why it matters even if it feels silly:

  • Teams keep consistent banners across services without touching app startup time.

  • You learn Qute templating, multipart uploads, and safe file handling.

We’ll keep everything fast and local. No startup delay, no network calls.

Prerequisites

  • Java 21

  • Quarkus CLI (latest)

  • FIGlet fonts (.flf) added as classpath resources

References:

  • Qute guide and reference. (Quarkus)

  • REST (Quarkus RESTEasy Reactive) multipart handling with @RestForm. (Quarkus)

  • FIGlet fonts and basics. (Art Scene, Figlet)

Bootstrap the project

quarkus create app org.acme:banner-studio:1.0.0 \
  -x qute,rest-jackson
cd banner-studio
Enter fullscreen mode Exit fullscreen mode

Dependencies (pom.xml)

Add the figlet dependency:

<dependencies> 
  <!-- FIGlet rendering -->
  <dependency>
    <groupId>com.github.lalyos</groupId>
    <artifactId>jfiglet</artifactId>
    <version>0.0.9</version>
  </dependency>
</dependencies>
Enter fullscreen mode Exit fullscreen mode

jfiglet:0.0.9 is available on Maven Central and stable. (Maven Central, Maven Repository)

Configuration (application.properties)

src/main/resources/application.properties:

# Accept small uploads; process in memory
quarkus.http.body.handle-file-uploads=true
quarkus.http.body.uploads-directory=uploads
quarkus.http.limits.max-form-attribute-size=10M
Enter fullscreen mode Exit fullscreen mode

If you ever decide to show the banner at startup in another app, the Quarkus banner can be configured with quarkus.banner.enabled and quarkus.banner.path. (Quarkus)

Add FIGlet fonts

Create src/main/resources/fonts/ and add a few .flf files such as:

small.flf
standard.flf
slant.flf
big.flf
Enter fullscreen mode Exit fullscreen mode

You can pull fonts from public FIGlet collections (e.g., Digital.flf, slant.flf). (GitHub)

FIGlet rendering service with width fitting

src/main/java/org/acme/banner/FigletRenderer.java:


package org.acme.banner;

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

import com.github.lalyos.jfiglet.FigletFont;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class FigletRenderer {

    // Available fonts - try narrow-ish fonts first to fit within a max width
    public static final List<String> FONTS = List.of(
            "Small.flf", "ANSIRegular.flf", "Slant.flf", "Digital.flf");

    // Try narrow-ish fonts first to fit within a max width
    private static final List<String> FONT_ORDER = FONTS;

    public RenderResult renderFitting(String text, int maxWidth) throws IOException {
        text = sanitize(text);
        text = filterAscii(text);
        text = limitLength(text, 40);

        for (String fontName : FONT_ORDER) {
            try {
                String ascii = render(text, fontName);
                int width = measureWidth(ascii);
                if (width <= maxWidth) {
                    return new RenderResult(ascii, fontName, width, true);
                }
            } catch (Exception e) {
                // Try next font
            }
        }

        // Nothing fits: return the narrowest anyway and flag it
        try {
            String fallback = render(text, FONT_ORDER.get(0));
            return new RenderResult(fallback, FONT_ORDER.get(0), measureWidth(fallback), false);
        } catch (Exception e) {
            return new RenderResult("[Error rendering text]", FONT_ORDER.get(0), 0, false);
        }
    }

    public String renderWithFont(String text, String fontName) throws IOException {
        text = sanitize(text);
        text = filterAscii(text);
        text = limitLength(text, 40);
        try {
            return render(text, fontName);
        } catch (Exception e) {
            return "[Error rendering text]";
        }
    }

    private String render(String text, String fontName) throws IOException {
        try (InputStream in = getClass().getResourceAsStream("/fonts/" + fontName)) {
            if (in == null) {
                System.err.println("[WARN] Font not found: " + fontName + " (expected at /fonts/" + fontName + ")");
                throw new IOException("Font not found: " + fontName);
            }
            return FigletFont.convertOneLine(in, text);
        } catch (Exception e) {
            System.err.println("[ERROR] Failed to render with font '" + fontName + "': " + e.getMessage());
            throw e;
        }
    }

    private static int measureWidth(String ascii) {
        int max = 0;
        for (String line : ascii.split("\\R")) {
            if (line.length() > max)
                max = line.length();
        }
        return max;
    }

    private static String sanitize(String text) {
        return text == null ? "" : text.replaceAll("\\R", " ").trim();
    }

    public record RenderResult(String ascii, String font, int width, boolean fits) {
    }

    // Only allow printable ASCII (32-126)
    private static String filterAscii(String text) {
        return text.replaceAll("[^\\u0020-\\u007E]", "?");
    }

    // Limit input length
    private static String limitLength(String text, int max) {
        if (text.length() > max) {
            return text.substring(0, max);
        }
        return text;
    }

    /**
     * Render multi-line text with a specific font. Each line is rendered separately
     * and joined.
     */
    public String renderMultilineWithFont(String text, String fontName) throws IOException {
        if (text == null)
            return "";
        String[] lines = text.split("\\R");
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < lines.length; i++) {
            String line = sanitize(lines[i]);
            line = filterAscii(line);
            line = limitLength(line, 40);
            try {
                sb.append(render(line, fontName));
            } catch (Exception e) {
                sb.append("[Error rendering line]");
            }
            if (i < lines.length - 1)
                sb.append("\n");
        }
        return sb.toString();
    }

    /**
     * Render multi-line text, fitting each line to the maxWidth using available
     * fonts.
     * Returns the result for the first line that fits, or the fallback for the
     * first line.
     * (For simplicity, all lines use the same font.)
     */
    public RenderResult renderMultilineFitting(String text, int maxWidth) throws IOException {
        if (text == null)
            return new RenderResult("", FONT_ORDER.get(0), 0, true);
        String[] lines = text.split("\\R");
        StringBuilder sb = new StringBuilder();
        String usedFont = FONT_ORDER.get(0);
        boolean fits = true;
        int maxLineWidth = 0;
        for (int i = 0; i < lines.length; i++) {
            RenderResult rr = renderFitting(lines[i], maxWidth);
            sb.append(rr.ascii());
            if (i < lines.length - 1)
                sb.append("\n");
            if (!rr.fits())
                fits = false;
            if (rr.width() > maxLineWidth)
                maxLineWidth = rr.width();
            usedFont = rr.font(); // Use the last font used (could be improved)
        }
        return new RenderResult(sb.toString(), usedFont, maxLineWidth, fits);
    }

}
Enter fullscreen mode Exit fullscreen mode

Why these lines matter:

  • FONT_ORDER lets you enforce a maximum width by trying fonts in order.

  • measureWidth checks the widest line to compare against maxWidth.

Image → ASCII conversion with k-means

We scale the image to a target width, quantize colors with k-means to emphasize shapes, compute luminance, and map to a dense ASCII ramp.

src/main/java/org/acme/banner/ImageAsciiService.java:

package org.acme.banner;

import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.InputStream;
import java.util.Random;

import javax.imageio.ImageIO;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class ImageAsciiService {

    // From light (left) to dark (right); we invert later so darker -> denser glyph
    private static final char[] RAMP = " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$"
            .toCharArray();

    public String convert(InputStream imageStream, int targetCols, int maxRows, int k) throws Exception {
        BufferedImage src = ImageIO.read(imageStream);
        if (src == null)
            throw new IllegalArgumentException("Unsupported image format");

        BufferedImage scaled = scaleToCols(src, targetCols, maxRows);
        BufferedImage quant = kMeansQuantize(scaled, k, 8);
        return toAscii(quant);
    }

    private static BufferedImage scaleToCols(BufferedImage src, int cols, int maxRows) {
        int w = src.getWidth(), h = src.getHeight();

        // Characters are roughly twice as tall as they are wide in terminals
        double charAspect = 2.0;
        double scale = (double) cols / w;
        int newW = cols;
        int newH = (int) Math.round(h * scale / charAspect);

        if (newH > maxRows) {
            double s2 = (double) maxRows / newH;
            newW = Math.max(1, (int) Math.round(newW * s2));
            newH = maxRows;
        }

        BufferedImage out = new BufferedImage(newW, newH, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = out.createGraphics();
        g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
        g.drawImage(src, 0, 0, newW, newH, null);
        g.dispose();
        return out;
    }

    private static BufferedImage kMeansQuantize(BufferedImage img, int k, int iterations) {
        int w = img.getWidth(), h = img.getHeight();
        int[] px = img.getRGB(0, 0, w, h, null, 0, w);

        int[] centroids = new int[k];
        Random rnd = new Random(42);
        for (int i = 0; i < k; i++)
            centroids[i] = px[rnd.nextInt(px.length)];

        int[] assign = new int[px.length];

        for (int it = 0; it < iterations; it++) {
            for (int i = 0; i < px.length; i++) {
                assign[i] = nearest(px[i], centroids);
            }
            long[] sumR = new long[k], sumG = new long[k], sumB = new long[k];
            int[] count = new int[k];
            for (int i = 0; i < px.length; i++) {
                int c = assign[i], rgb = px[i];
                int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
                sumR[c] += r;
                sumG[c] += g;
                sumB[c] += b;
                count[c]++;
            }
            for (int c = 0; c < k; c++) {
                if (count[c] == 0)
                    continue;
                int r = (int) (sumR[c] / count[c]);
                int g = (int) (sumG[c] / count[c]);
                int b = (int) (sumB[c] / count[c]);
                centroids[c] = (0xFF << 24) | (r << 16) | (g << 8) | b;
            }
        }

        for (int i = 0; i < px.length; i++)
            px[i] = centroids[assign[i]];

        BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
        out.setRGB(0, 0, w, h, px, 0, w);
        return out;
    }

    private static int nearest(int rgb, int[] centroids) {
        int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
        int best = 0;
        long bestD = Long.MAX_VALUE;
        for (int i = 0; i < centroids.length; i++) {
            int c = centroids[i];
            int cr = (c >> 16) & 0xFF, cg = (c >> 8) & 0xFF, cb = c & 0xFF;
            long dr = r - cr, dg = g - cg, db = b - cb;
            long d = dr * dr + dg * dg + db * db;
            if (d < bestD) {
                bestD = d;
                best = i;
            }
        }
        return best;
    }

    private static String toAscii(BufferedImage img) {
        StringBuilder sb = new StringBuilder(img.getHeight() * (img.getWidth() + 1));
        for (int y = 0; y < img.getHeight(); y++) {
            for (int x = 0; x < img.getWidth(); x++) {
                int rgb = img.getRGB(x, y);
                int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
                double lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; // 0..255
                int idx = (int) Math.round((RAMP.length - 1) * (lum / 255.0));
                idx = (RAMP.length - 1) - idx; // invert: darker -> denser glyph
                sb.append(RAMP[idx]);
            }
            sb.append('\n');
        }
        return sb.toString();
    }
}
Enter fullscreen mode Exit fullscreen mode

HTTP endpoints and Qute page

src/main/java/org/acme/banner/BannerResource.java:

package org.acme.banner;

import java.io.IOException;
import java.util.Map;

import io.quarkus.qute.Template;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/")
@RequestScoped
public class BannerResource {

    @Inject
    Template studio; // templates/studio.html
    @Inject
    FigletRenderer renderer;

    @GET
    @Produces(MediaType.TEXT_HTML)
    public String ui() {
        return studio.data("fonts", FigletRenderer.FONTS)
                .data("result", null)
                .data("input", Map.of("text", "Hello Quarkus", "font", "Small.flf", "maxWidth", 80, "fit", true))
                .data("imageAscii", null)
                .render();
    }

    @POST
    @Path("render")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces(MediaType.TEXT_HTML)
    public String render(@FormParam("text") String text,
            @FormParam("font") String font,
            @FormParam("maxWidth") @DefaultValue("80") int maxWidth,
            @FormParam("fit") @DefaultValue("true") boolean fit) throws IOException {

        String ascii = renderer.renderMultilineWithFont(text, font);
        int width = ascii.lines().mapToInt(String::length).max().orElse(0);
        FigletRenderer.RenderResult rr = new FigletRenderer.RenderResult(ascii, font, width, true);

        return studio.data("fonts", FigletRenderer.FONTS)
                .data("input", Map.of("text", text, "font", font, "maxWidth", maxWidth, "fit", fit))
                .data("result", rr)
                .data("imageAscii", null)
                .render();
    }

    @POST
    @Path("export")
    @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
    @Produces("text/plain")
    public Response export(@FormParam("ascii") String ascii) {
        return Response.ok(ascii)
                .header("Content-Disposition", "attachment; filename=banner.txt")
                .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

src/main/java/org/acme/banner/ImageAsciiResource.java:

package org.acme.banner;

import java.io.InputStream;
import java.util.Map;

import org.jboss.resteasy.reactive.RestForm;

import io.quarkus.qute.Template;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;

@Path("/image")
public class ImageAsciiResource {

    @Inject
    Template studio; // reuse same page
    @Inject
    ImageAsciiService service;

    @POST
    @Path("/convert")
    @Consumes(MediaType.MULTIPART_FORM_DATA)
    @Produces(MediaType.TEXT_HTML)
    public String convert(@RestForm("file") InputStream file,
            @RestForm("cols") @DefaultValue("80") int cols,
            @RestForm("maxRows") @DefaultValue("60") int maxRows,
            @RestForm("k") @DefaultValue("8") int k) {
        String ascii;
        try {
            ascii = service.convert(file, cols, maxRows, k);
        } catch (Exception e) {
            ascii = "Conversion failed: " + e.getMessage();
        }
        return studio.data("fonts", FigletRenderer.FONTS)
                .data("result", null)
                .data("input", Map.of("text", "Hello Quarkus", "font", "Small.flf", "maxWidth", 80, "fit", true))
                .data("imageAscii", ascii)
                .render();
    }
}
Enter fullscreen mode Exit fullscreen mode

Quarkus REST’s @RestForm is the supported way to access multipart parts. (Quarkus)

Qute template

src/main/resources/templates/studio.html: I spare you the design. Grab the original file from my Github repository.

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Quarkus Banner Studio</title>

</head>

<body>
    <div class="container">
        <div class="header">
            <h1>🎨 Quarkus Banner Studio</h1>
            <p>Render width-safe ASCII banners with FIGlet fonts. Convert images to ASCII. Export as banner.txt.</p>
        </div>

        <div class="content">
            <div class="section">
                <h2>📝 Text → ASCII</h2>
                <form method="post" action="/render">
                    <div class="form-row">
                        <div class="form-group">
                            <label>Text</label>
                            <textarea name="text" rows="3" placeholder="Enter your text here...">{input.text ?: 'Hello Quarkus'}</textarea>
                        </div>
                        <div class="form-group">
                            <label>Font</label>
                            <select name="font">
                                {#for f in fonts}
                                <option value="{f}" {#if input.font == f}selected{/if}>{f}</option>
                                {/for}
                            </select>
                            <div class="checkbox-group">
                                <input type="checkbox" id="fit" name="fit" value="true" {input.fit ?: true ? 'checked' : '' } />
                                <label for="fit">Auto-fit to width</label>
                            </div>
                            <label>Max width (columns)</label>
                            <input type="number" name="maxWidth" value="{input.maxWidth ?: 80}" min="20" max="200" />
                        </div>
                    </div>
                    <div class="button-group">
                        <button type="submit">🚀 Render ASCII</button>
                    </div>
                </form>

                {#if result}
                <div class="preview-header">
                    <h3>✨ Preview</h3>
                    <div class="preview-info">
                        {result.font} • {result.width} cols{#if !result.fits} • (did not fit; used narrowest){/if}
                    </div>
                </div>
                <pre>{result.ascii}</pre>
                <div class="button-group">
                    <form method="post" action="/export" style="display: inline;">
                        <input type="hidden" name="ascii" value="{result.ascii}" />
                        <button type="submit">💾 Download banner.txt</button>
                    </form>
                </div>
                {/if}
            </div>

            <div class="divider"></div>

            <div class="section">
                <h2>🖼️ Image → ASCII</h2>
                <div class="note">
                    Upload PNG/JPG images. We scale to N columns, quantize colors (k-means), and map brightness to glyphs.
                </div>

                <form method="post" action="/image/convert" enctype="multipart/form-data">
                    <div class="form-row">
                        <div class="form-group">
                            <label>Image file</label>
                            <div class="file-input-wrapper">
                                <input type="file" name="file" accept="image/png,image/jpeg" required />
                                <div class="file-input-display">
                                    <div class="file-input-text">📁 Choose an image file</div>
                                </div>
                            </div>
                        </div>
                        <div class="form-group">
                            <label>Output width (columns)</label>
                            <input type="number" name="cols" value="80" min="40" max="200" />
                            <label>K-means clusters (K)</label>
                            <input type="number" name="k" value="8" min="2" max="16" />
                            <label>Max height (rows)</label>
                            <input type="number" name="maxRows" value="60" min="20" max="200" />
                        </div>
                    </div>
                    <div class="button-group">
                        <button type="submit">🎨 Convert to ASCII</button>
                    </div>
                </form>

                {#if imageAscii}
                <div class="preview-header">
                    <h3>🖼️ Image Preview</h3>
                </div>
                <pre>{imageAscii}</pre>
                <div class="button-group">
                    <form method="post" action="/export" style="display: inline;">
                        <input type="hidden" name="ascii" value="{imageAscii}" />
                        <button type="submit">💾 Download banner.txt</button>
                    </form>
                </div>
                {/if}
            </div>
        </div>
    </div>

    <script>
        // Enhanced file input styling
        document.addEventListener('DOMContentLoaded', function() {
            const fileInput = document.querySelector('input[type="file"]');
            const fileDisplay = document.querySelector('.file-input-display');
            const fileText = document.querySelector('.file-input-text');

            if (fileInput && fileDisplay && fileText) {
                fileInput.addEventListener('change', function(e) {
                    const file = e.target.files[0];
                    if (file) {
                        fileDisplay.classList.add('has-file');
                        fileText.classList.add('has-file');
                        fileText.textContent = `📄 ${file.name}`;
                    } else {
                        fileDisplay.classList.remove('has-file');
                        fileText.classList.remove('has-file');
                        fileText.textContent = '📁 Choose an image file';
                    }
                });
            }
        });
    </script>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Qute auto-reloads templates in dev mode.

Run and verify

./mvnw quarkus:dev
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8080

Screenshot

Verify:

  • Type “Quarkus 3.0” (or anything), set max width to 80, hit Render. The preview shows FIGlet text. The header displays which font was used and the computed width. If it didn’t fit, you’ll see the narrowest font used.

  • Scroll down, upload a small logo or high-contrast image, keep cols=80, k=8, maxRows=60, click Convert. You should get recognizable ASCII shapes. Logos work best. Faces are abstract but visible.

Export:

  • Use “Download banner.txt” to save the ASCII and drop it into another app at src/main/resources/banner.txt.

  • In that other app, set quarkus.banner.enabled=true and quarkus.banner.path=banner.txt to use it (during dev or runtime as desired). (Quarkus)

Troubleshooting

  • “Font not found” : ensure .flf files are in src/main/resources/fonts/.

  • Ugly wrapping : your terminal or log sink might not be monospaced. Confirm column width and disable auto-wrap.

  • Multipart errors : verify the form has enctype="multipart/form-data" and the endpoint uses @Consumes(MediaType.MULTIPART_FORM_DATA) with @RestForm. (Quarkus)

  • Banner visible in tests : test resources load from src/test/resources. Put banner.txt there if you want it in test runs. (Community tip aligns with how Quarkus loads test resources.) (Stack Overflow)

What you could implement next

  • Add Floyd–Steinberg dithering before luminance to sharpen edges.

  • Offer multiple ASCII ramps, e.g. " .:-=+*#%@" for minimalist looks.

  • Add a two-line FIGlet mode for long phrases to improve fit.

  • Provide a JSON API to integrate this studio with pipeline tooling that stamps banners per build.

Links and Further Reading

  • Qute guide and reference. (Quarkus)

  • Quarkus REST multipart handling (@RestForm). (Quarkus)

  • FIGlet fonts and basics. (Figlet)

  • Quarkus banner configuration. (Quarkus)

Banners may look like a playful detail, but they show how much we can bend Java and Quarkus to make even the smallest corner of our work enjoyable. By turning logs into art and learning along the way, you’ve sharpened your skills with Qute, FIGlet, image processing, and even a touch of AI. Keep experimenting! Because every little spark of creativity makes you a stronger, more confident developer.

Subscribe now

Top comments (0)