Quarkus Banner Studio: Build ASCII Art Banners with Qute and FIGlet
Turn startup logs into creative canvases while mastering Qute templating and image-to-ASCII rendering
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)
Bootstrap the project
quarkus create app org.acme:banner-studio:1.0.0 \
-x qute,rest-jackson
cd banner-studio
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>
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
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
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);
}
}
Why these lines matter:
FONT_ORDER
lets you enforce a maximum width by trying fonts in order.measureWidth
checks the widest line to compare againstmaxWidth
.
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();
}
}
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();
}
}
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();
}
}
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>
Qute auto-reloads templates in dev mode.
Run and verify
./mvnw quarkus:dev
Open http://localhost:8080
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
andquarkus.banner.path=banner.txt
to use it (during dev or runtime as desired). (Quarkus)
Troubleshooting
“Font not found”: ensure
.flf
files are insrc/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
. Putbanner.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.