ascii_banner
Draw text as a large banner in a bundled 5-row block font (A-Z, 0-9 and common punctuation; an original font, MIT) with a chosen fill character, letter spacing and word wrapping, optionally inside a border, or put plain text in a box with ASCII, single, double, rounded or heavy line borders and left, centred or right alignment.
Install
/tools install ascii_banner /tools install ascii_banner@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · ascii_banner 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool ascii_banner; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
text | string | yes | Newlines start new lines. length ≤ 2000 | |
mode | string | no | "banner" | one of "banner", "box" |
fill | string | no | "#" | One character, or "block" for a full block |
spacing | integer | no | 1 | ≥ 0; ≤ 4 |
max_width | integer | no | 120 | ≥ 10; ≤ 1000 |
border | string | no | Default none for banner, ascii for box. one of "none", "ascii", "single", "double", "rounded", "heavy" | |
padding | integer | no | 1 | ≥ 0; ≤ 4 |
align | string | no | "left" | one of "left", "center", "right" |
unknown | string | no | "replace" | one of "replace", "error" |
Input schema (JSON)
{
"type": "object",
"required": [
"text"
],
"additionalProperties": false,
"properties": {
"text": {
"type": "string",
"maxLength": 2000,
"description": "Newlines start new lines"
},
"mode": {
"type": "string",
"enum": [
"banner",
"box"
],
"default": "banner"
},
"fill": {
"type": "string",
"default": "#",
"description": "One character, or \"block\" for a full block"
},
"spacing": {
"type": "integer",
"minimum": 0,
"maximum": 4,
"default": 1
},
"max_width": {
"type": "integer",
"minimum": 10,
"maximum": 1000,
"default": 120
},
"border": {
"type": "string",
"enum": [
"none",
"ascii",
"single",
"double",
"rounded",
"heavy"
],
"description": "Default none for banner, ascii for box"
},
"padding": {
"type": "integer",
"minimum": 0,
"maximum": 4,
"default": 1
},
"align": {
"type": "string",
"enum": [
"left",
"center",
"right"
],
"default": "left"
},
"unknown": {
"type": "string",
"enum": [
"replace",
"error"
],
"default": "replace"
}
}
}
Example
Input
{
"text": "HI"
}
Output (stdout)
{
"width": 9,
"height": 5,
"lines": [
"# # ###",
"# # # ",
"##### # ",
"# # # ",
"# # ###"
]
}
Files
The command the install card registers is python3 ascii_banner.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
ascii_banner.py · sha256 4ac5ab95791ec8bb4785ec10380edd7d61470d93863c216054e6ccf6aeaa9313
#!/usr/bin/env python3
"""Text banners in a bundled 5-row block font (A-Z, 0-9 and common punctuation, an original
font of this tool, MIT), with an optional border, or plain text in a box.
Input (JSON on stdin):
text the text; "\\n" starts a new line
mode "banner" (default) or "box" (the text itself inside a border)
fill banner: the character drawing the letters, default "#" ("block" for U+2588)
spacing banner: blank columns between letters, default 1 (0-4)
max_width banner: wrap words onto further banner lines past this many columns, default 120
border "none" (default for banner) | "ascii" | "single" | "double" | "rounded" | "heavy"
(box mode default: "ascii")
padding spaces between the border and the content, default 1 (0-4)
align box: "left" (default) | "center" | "right"
unknown "replace" (default: a character the font lacks becomes ?) or "error"
"""
import json
import sys
ROWS = 5
# Each glyph: five strings of equal width; '#' marks ink. Original design for this tool.
FONT = {
"A": [" ### ", "# #", "#####", "# #", "# #"],
"B": ["#### ", "# #", "#### ", "# #", "#### "],
"C": [" ####", "# ", "# ", "# ", " ####"],
"D": ["#### ", "# #", "# #", "# #", "#### "],
"E": ["#####", "# ", "#### ", "# ", "#####"],
"F": ["#####", "# ", "#### ", "# ", "# "],
"G": [" ####", "# ", "# ##", "# #", " ####"],
"H": ["# #", "# #", "#####", "# #", "# #"],
"I": ["###", " # ", " # ", " # ", "###"],
"J": [" ###", " # ", " # ", "# # ", " ## "],
"K": ["# #", "# # ", "### ", "# # ", "# #"],
"L": ["# ", "# ", "# ", "# ", "#####"],
"M": ["# #", "## ##", "# # #", "# #", "# #"],
"N": ["# #", "## #", "# # #", "# ##", "# #"],
"O": [" ### ", "# #", "# #", "# #", " ### "],
"P": ["#### ", "# #", "#### ", "# ", "# "],
"Q": [" ### ", "# #", "# # #", "# # ", " ## #"],
"R": ["#### ", "# #", "#### ", "# # ", "# #"],
"S": [" ####", "# ", " ### ", " #", "#### "],
"T": ["#####", " # ", " # ", " # ", " # "],
"U": ["# #", "# #", "# #", "# #", " ### "],
"V": ["# #", "# #", "# #", " # # ", " # "],
"W": ["# #", "# #", "# # #", "## ##", "# #"],
"X": ["# #", " # # ", " # ", " # # ", "# #"],
"Y": ["# #", " # # ", " # ", " # ", " # "],
"Z": ["#####", " # ", " # ", " # ", "#####"],
"0": [" ### ", "# ##", "# # #", "## #", " ### "],
"1": [" # ", "## ", " # ", " # ", "###"],
"2": [" ### ", "# #", " ## ", " # ", "#####"],
"3": ["#### ", " #", " ### ", " #", "#### "],
"4": ["# #", "# #", "#####", " #", " #"],
"5": ["#####", "# ", "#### ", " #", "#### "],
"6": [" ### ", "# ", "#### ", "# #", " ### "],
"7": ["#####", " #", " # ", " # ", " # "],
"8": [" ### ", "# #", " ### ", "# #", " ### "],
"9": [" ### ", "# #", " ####", " #", " ### "],
" ": [" ", " ", " ", " ", " "],
".": [" ", " ", " ", " ", "#"],
",": [" ", " ", " ", " #", "# "],
"!": ["#", "#", "#", " ", "#"],
"?": [" ### ", "# #", " ## ", " ", " # "],
"-": [" ", " ", "###", " ", " "],
"+": [" ", " # ", "###", " # ", " "],
"=": [" ", "###", " ", "###", " "],
":": [" ", "#", " ", "#", " "],
";": [" ", " #", " ", " #", "# "],
"'": ["#", "#", " ", " ", " "],
'"': ["# #", "# #", " ", " ", " "],
"/": [" #", " # ", " # ", " # ", "# "],
"\\": ["# ", " # ", " # ", " # ", " #"],
"(": [" #", "# ", "# ", "# ", " #"],
")": ["# ", " #", " #", " #", "# "],
"[": ["##", "# ", "# ", "# ", "##"],
"]": ["##", " #", " #", " #", "##"],
"_": [" ", " ", " ", " ", "####"],
"*": [" ", "# # #", " ### ", "# # #", " "],
"#": [" # # ", "#####", " # # ", "#####", " # # "],
"<": [" #", " # ", "# ", " # ", " #"],
">": ["# ", " # ", " #", " # ", "# "],
"%": ["# #", " # ", " # ", " # ", "# #"],
}
BORDERS = {
"ascii": ("+", "-", "+", "|", "|", "+", "-", "+"),
"single": ("┌", "─", "┐", "│", "│", "└", "─", "┘"),
"double": ("╔", "═", "╗", "║", "║", "╚", "═", "╝"),
"rounded": ("╭", "─", "╮", "│", "│", "╰", "─", "╯"),
"heavy": ("┏", "━", "┓", "┃", "┃", "┗", "━", "┛"),
}
def fail(message):
print(json.dumps({"error": message}, ensure_ascii=False))
sys.exit(2)
def integer(request, field, default, low, high):
value = request.get(field, default)
if isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high:
fail(f"{field} must be an integer from {low} to {high}")
return value
def render_line(text, fill, spacing):
rows = [""] * ROWS
for i, ch in enumerate(text):
glyph = FONT[ch]
for r in range(ROWS):
rows[r] += ("" if i == 0 else " " * spacing) + glyph[r].replace("#", fill)
return rows
def glyph_width(word, spacing):
return sum(len(FONT[ch][0]) for ch in word) + spacing * max(0, len(word) - 1)
def wrap(line, spacing, max_width):
"""Split a line into pieces whose banners fit max_width, at spaces where possible."""
words, pieces, current = line.split(" "), [], ""
for word in words:
candidate = word if not current else current + " " + word
if glyph_width(candidate, spacing) <= max_width:
current = candidate
continue
if current:
pieces.append(current)
current = ""
while glyph_width(word, spacing) > max_width: # a single word too wide: cut it
cut = len(word)
while cut > 1 and glyph_width(word[:cut], spacing) > max_width:
cut -= 1
pieces.append(word[:cut])
word = word[cut:]
current = word
if current or not pieces:
pieces.append(current)
return pieces
def frame(lines, border, padding, align="left"):
width = max((len(line) for line in lines), default=0)
if align == "center":
body = [line.center(width) for line in lines]
elif align == "right":
body = [line.rjust(width) for line in lines]
else:
body = [line.ljust(width) for line in lines]
if border == "none":
return body
tl, top, tr, left, right, bl, bottom, br = BORDERS[border]
inner = width + 2 * padding
out = [tl + top * inner + tr]
out += [left + " " * inner + right for _ in range(padding // 2)]
out += [left + " " * padding + line + " " * padding + right for line in body]
out += [left + " " * inner + right for _ in range(padding // 2)]
out.append(bl + bottom * inner + br)
return out
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
extra = set(request) - {"text", "mode", "fill", "spacing", "max_width", "border", "padding", "align", "unknown"}
if extra:
fail(f"unknown field(s): {', '.join(sorted(extra))}")
text = request.get("text")
if not isinstance(text, str) or not text.strip():
fail("text must be a non-empty string")
if len(text) > 2000:
fail("text may be at most 2000 characters")
mode = request.get("mode", "banner")
if mode not in ("banner", "box"):
fail("mode must be banner or box")
border = request.get("border", "ascii" if mode == "box" else "none")
if border not in ("none", *BORDERS):
fail("border must be none, ascii, single, double, rounded or heavy")
padding = integer(request, "padding", 1, 0, 4)
lines = text.replace("\r\n", "\n").replace("\t", " ").split("\n")
warnings = []
if mode == "box":
align = request.get("align", "left")
if align not in ("left", "center", "right"):
fail("align must be left, center or right")
out = frame(lines, border, padding, align)
else:
fill = request.get("fill", "#")
if fill == "block":
fill = "█"
if not isinstance(fill, str) or len(fill) != 1 or fill.isspace():
fail("fill must be one visible character, or \"block\"")
spacing = integer(request, "spacing", 1, 0, 4)
max_width = integer(request, "max_width", 120, 10, 1000)
unknown = request.get("unknown", "replace")
if unknown not in ("replace", "error"):
fail("unknown must be replace or error")
clean = []
for line in lines:
line = line.upper()
missing = sorted({ch for ch in line if ch not in FONT})
if missing:
if unknown == "error":
fail(f"the font has no glyph for: {' '.join(missing)}")
warnings.append(f"replaced with ?: {' '.join(missing)}")
line = "".join(ch if ch in FONT else "?" for ch in line)
clean.append(line)
rows = []
for n, line in enumerate(clean):
for k, piece in enumerate(wrap(line.strip() or " ", spacing, max_width)):
if rows:
rows.append("")
rows += render_line(piece, fill, spacing)
width = max(len(r) for r in rows)
rows = [r.ljust(width) for r in rows]
out = frame(rows, border, padding)
print(json.dumps({"mode": mode, "lines": out, "text": "\n".join(out) + "\n", "width": max(len(r) for r in out),
"height": len(out), "warnings": warnings, "font": "AXLR block 5 (original, MIT)"}, ensure_ascii=False))
if __name__ == "__main__":
try:
main()
except SystemExit:
raise
except Exception as error: # never a traceback
fail(f"{type(error).__name__}: {error}")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/ascii_banner/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/ascii_banner/1.0.0/ascii_banner.py