feat: imgage feature
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.34
|
||||
docker>=7.0
|
||||
Pillow>=10.0
|
||||
md2txt @ git+https://codeberg.org/randogoth/md2txt
|
||||
|
||||
BIN
backend/test_logo.png
Normal file
BIN
backend/test_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 898 B |
@@ -117,6 +117,23 @@ def cmd_deploy(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_image(args: argparse.Namespace) -> int:
|
||||
"""Convert an image file to character art."""
|
||||
from uframe.imaging import convert_image
|
||||
try:
|
||||
lines = convert_image(args.file, mode=args.mode, width=args.width,
|
||||
dither=args.dither, invert=args.invert)
|
||||
for line in lines:
|
||||
print(line)
|
||||
except ImportError:
|
||||
print("Error: Pillow is required: pip install Pillow", file=sys.stderr)
|
||||
return 1
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Image not found: {args.file}", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="uframe",
|
||||
@@ -151,6 +168,14 @@ def main() -> int:
|
||||
p_deploy.add_argument("--width", type=int, default=64, help="Page width")
|
||||
p_deploy.add_argument("--theme", default="", help="Theme name")
|
||||
|
||||
# image (standalone conversion)
|
||||
p_image = sub.add_parser("image", help="Convert image to character art")
|
||||
p_image.add_argument("file", help="Path to image file")
|
||||
p_image.add_argument("--mode", default="braille", help="braille, block, ascii, halfblock")
|
||||
p_image.add_argument("--width", type=int, default=40, help="Output width")
|
||||
p_image.add_argument("--dither", default="floyd", help="floyd, threshold, none")
|
||||
p_image.add_argument("--invert", action="store_true", help="Invert light/dark")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
commands = {
|
||||
@@ -158,6 +183,7 @@ def main() -> int:
|
||||
"compile": cmd_compile,
|
||||
"check": cmd_check,
|
||||
"deploy": cmd_deploy,
|
||||
"image": cmd_image,
|
||||
}
|
||||
|
||||
return commands[args.command](args)
|
||||
|
||||
267
backend/uframe/imaging.py
Normal file
267
backend/uframe/imaging.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""µFrame Image Converter — convert images to character art.
|
||||
|
||||
Supports braille, block, ascii, and halfblock rendering modes
|
||||
with optional dithering and color output.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from uframe.chars import BRAILLE_BASE, BRAILLE_LEFT, BRAILLE_RIGHT
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
HAS_PIL = True
|
||||
except ImportError:
|
||||
HAS_PIL = False
|
||||
|
||||
|
||||
# ASCII brightness ramp (light → dark)
|
||||
ASCII_RAMP = " .:-=+*#%@"
|
||||
# Block shade ramp (light → dark)
|
||||
BLOCK_RAMP = " ░▒▓█"
|
||||
|
||||
|
||||
def _load_image(path: str) -> "Image.Image":
|
||||
"""Load an image from file path."""
|
||||
if not HAS_PIL:
|
||||
raise ImportError("Pillow is required for image conversion: pip install Pillow")
|
||||
resolved = Path(path).expanduser()
|
||||
if not resolved.is_file():
|
||||
raise FileNotFoundError(f"Image not found: {path}")
|
||||
return Image.open(str(resolved))
|
||||
|
||||
|
||||
def _floyd_steinberg(pixels: list[list[float]], w: int, h: int, levels: int = 2) -> list[list[int]]:
|
||||
"""Apply Floyd-Steinberg dithering to a grayscale pixel array.
|
||||
|
||||
Returns quantized values in range [0, levels-1].
|
||||
"""
|
||||
result = [[0] * w for _ in range(h)]
|
||||
err = [row[:] for row in pixels] # copy
|
||||
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
old = err[y][x]
|
||||
new = round(old * (levels - 1)) / (levels - 1) if levels > 1 else (1.0 if old > 0.5 else 0.0)
|
||||
result[y][x] = int(round(new * (levels - 1)))
|
||||
quant_err = old - new
|
||||
|
||||
if x + 1 < w:
|
||||
err[y][x + 1] += quant_err * 7 / 16
|
||||
if y + 1 < h:
|
||||
if x - 1 >= 0:
|
||||
err[y + 1][x - 1] += quant_err * 3 / 16
|
||||
err[y + 1][x] += quant_err * 5 / 16
|
||||
if x + 1 < w:
|
||||
err[y + 1][x + 1] += quant_err * 1 / 16
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def convert_braille(img: "Image.Image", width: int, dither: str = "floyd",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to braille character art.
|
||||
|
||||
Each character encodes a 2×4 pixel block. Resolution: 2x horizontal, 4x vertical.
|
||||
"""
|
||||
# Resize: each output char = 2 pixels wide × 4 pixels tall
|
||||
pixel_w = width * 2
|
||||
aspect = img.height / img.width
|
||||
pixel_h = int(pixel_w * aspect / 2) # /2 for terminal cell aspect
|
||||
pixel_h = max(pixel_h, 4)
|
||||
# Round up to multiple of 4
|
||||
pixel_h = ((pixel_h + 3) // 4) * 4
|
||||
|
||||
img_resized = img.resize((pixel_w, pixel_h)).convert("L")
|
||||
|
||||
# Get pixel data as 0.0–1.0 floats
|
||||
pixels = []
|
||||
for y in range(pixel_h):
|
||||
row = []
|
||||
for x in range(pixel_w):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
row.append(v)
|
||||
pixels.append(row)
|
||||
|
||||
# Dither to binary
|
||||
if dither == "floyd":
|
||||
binary = _floyd_steinberg(pixels, pixel_w, pixel_h, levels=2)
|
||||
else:
|
||||
binary = [[1 if p > 0.5 else 0 for p in row] for row in pixels]
|
||||
|
||||
# Map 2×4 blocks to braille characters
|
||||
lines: list[str] = []
|
||||
for by in range(0, pixel_h, 4):
|
||||
line = ""
|
||||
for bx in range(0, pixel_w, 2):
|
||||
code = BRAILLE_BASE
|
||||
for row in range(4):
|
||||
py = by + row
|
||||
if py < pixel_h:
|
||||
# Left column
|
||||
px_l = bx
|
||||
if px_l < pixel_w and binary[py][px_l]:
|
||||
code |= BRAILLE_LEFT[row]
|
||||
# Right column
|
||||
px_r = bx + 1
|
||||
if px_r < pixel_w and binary[py][px_r]:
|
||||
code |= BRAILLE_RIGHT[row]
|
||||
line += chr(code)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_block(img: "Image.Image", width: int, dither: str = "none",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to block shade characters (░▒▓█)."""
|
||||
aspect = img.height / img.width
|
||||
height = max(1, int(width * aspect / 2)) # /2 for terminal cell aspect
|
||||
|
||||
img_resized = img.resize((width, height)).convert("L")
|
||||
|
||||
pixels = []
|
||||
for y in range(height):
|
||||
row = []
|
||||
for x in range(width):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
row.append(v)
|
||||
pixels.append(row)
|
||||
|
||||
if dither == "floyd":
|
||||
quantized = _floyd_steinberg(pixels, width, height, levels=len(BLOCK_RAMP))
|
||||
else:
|
||||
quantized = [[int(p * (len(BLOCK_RAMP) - 1)) for p in row] for row in pixels]
|
||||
|
||||
lines: list[str] = []
|
||||
for row in quantized:
|
||||
line = "".join(BLOCK_RAMP[min(v, len(BLOCK_RAMP) - 1)] for v in row)
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_ascii(img: "Image.Image", width: int, dither: str = "none",
|
||||
invert: bool = False) -> list[str]:
|
||||
"""Convert image to classic ASCII art using brightness ramp."""
|
||||
aspect = img.height / img.width
|
||||
height = max(1, int(width * aspect / 2))
|
||||
|
||||
img_resized = img.resize((width, height)).convert("L")
|
||||
|
||||
lines: list[str] = []
|
||||
for y in range(height):
|
||||
line = ""
|
||||
for x in range(width):
|
||||
v = img_resized.getpixel((x, y)) / 255.0
|
||||
if invert:
|
||||
v = 1.0 - v
|
||||
idx = int(v * (len(ASCII_RAMP) - 1))
|
||||
line += ASCII_RAMP[min(idx, len(ASCII_RAMP) - 1)]
|
||||
lines.append(line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_halfblock(img: "Image.Image", width: int,
|
||||
invert: bool = False, use_color: bool = False) -> list[tuple[str, str | None, str | None]]:
|
||||
"""Convert image to half-block characters with optional color.
|
||||
|
||||
Uses ▄ with foreground (bottom pixel) and background (top pixel) colors.
|
||||
Returns list of (line_text, fg_colors, bg_colors) tuples.
|
||||
Each fg/bg color string has one 3-digit hex per character, or None for mono.
|
||||
"""
|
||||
aspect = img.height / img.width
|
||||
height = max(2, int(width * aspect / 2))
|
||||
# Round up to even
|
||||
height = height + (height % 2)
|
||||
|
||||
img_resized = img.resize((width, height))
|
||||
|
||||
if use_color:
|
||||
img_rgb = img_resized.convert("RGB")
|
||||
img_gray = img_resized.convert("L")
|
||||
|
||||
lines: list[tuple[str, str | None, str | None]] = []
|
||||
for y in range(0, height, 2):
|
||||
chars = ""
|
||||
fgs = "" if use_color else None
|
||||
bgs = "" if use_color else None
|
||||
|
||||
for x in range(width):
|
||||
top_v = img_gray.getpixel((x, y)) / 255.0
|
||||
bot_v = img_gray.getpixel((x, y + 1)) / 255.0 if y + 1 < height else 0
|
||||
|
||||
if invert:
|
||||
top_v = 1.0 - top_v
|
||||
bot_v = 1.0 - bot_v
|
||||
|
||||
if use_color:
|
||||
top_rgb = img_rgb.getpixel((x, y))
|
||||
bot_rgb = img_rgb.getpixel((x, y + 1)) if y + 1 < height else (0, 0, 0)
|
||||
# Quantize to 3-digit hex
|
||||
fg_hex = f"{round(bot_rgb[0]*15/255):x}{round(bot_rgb[1]*15/255):x}{round(bot_rgb[2]*15/255):x}"
|
||||
bg_hex = f"{round(top_rgb[0]*15/255):x}{round(top_rgb[1]*15/255):x}{round(top_rgb[2]*15/255):x}"
|
||||
fgs += fg_hex
|
||||
bgs += bg_hex
|
||||
|
||||
chars += "▄"
|
||||
|
||||
lines.append((chars, fgs, bgs))
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def convert_image(path: str, mode: str = "braille", width: int = 30,
|
||||
dither: str = "floyd", invert: bool = False,
|
||||
use_color: bool = False) -> list[str]:
|
||||
"""High-level image conversion — returns list of character art lines.
|
||||
|
||||
Args:
|
||||
path: image file path
|
||||
mode: "braille", "block", "ascii", "halfblock"
|
||||
width: output width in characters
|
||||
dither: "floyd", "threshold", "none"
|
||||
invert: flip light/dark
|
||||
use_color: preserve colors (halfblock only for now)
|
||||
|
||||
Returns:
|
||||
List of strings, one per output line.
|
||||
"""
|
||||
img = _load_image(path)
|
||||
|
||||
if mode == "braille":
|
||||
return convert_braille(img, width, dither, invert)
|
||||
elif mode == "block":
|
||||
return convert_block(img, width, dither, invert)
|
||||
elif mode == "ascii":
|
||||
return convert_ascii(img, width, dither, invert)
|
||||
elif mode == "halfblock":
|
||||
hb_lines = convert_halfblock(img, width, invert, use_color)
|
||||
# For non-color mode, just return the character strings
|
||||
return [line[0] for line in hb_lines]
|
||||
else:
|
||||
return convert_braille(img, width, dither, invert)
|
||||
|
||||
|
||||
def get_image_height(path: str, mode: str = "braille", width: int = 30) -> int:
|
||||
"""Calculate the output height for an image without full conversion."""
|
||||
try:
|
||||
img = _load_image(path)
|
||||
except (ImportError, FileNotFoundError):
|
||||
return 1
|
||||
|
||||
aspect = img.height / img.width
|
||||
|
||||
if mode == "braille":
|
||||
pixel_h = int(width * 2 * aspect / 2)
|
||||
return max(1, ((pixel_h + 3) // 4))
|
||||
else:
|
||||
return max(1, int(width * aspect / 2))
|
||||
@@ -361,6 +361,18 @@ class StateDecl(IRNode):
|
||||
# Components (Phase 8)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ImageNode(IRNode):
|
||||
"""Image converted to character art."""
|
||||
path: str = ""
|
||||
mode: str = "braille" # braille, block, ascii, halfblock
|
||||
img_width: int = 30
|
||||
dither: str = "floyd" # floyd, threshold, none
|
||||
invert: bool = False
|
||||
use_color: bool = False
|
||||
caption: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BigTitle(IRNode):
|
||||
"""Large multi-line ASCII art text."""
|
||||
|
||||
@@ -20,7 +20,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle,
|
||||
BigTitle, ImageNode,
|
||||
ComponentDef, ComponentUse,
|
||||
)
|
||||
from uframe.themes import BUILTIN_THEMES
|
||||
@@ -120,6 +120,25 @@ register_keyword("columns", section="",
|
||||
# Big Text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
register_keyword("image", node_class=ImageNode, section="Content",
|
||||
detail='image "path.png" braille 30',
|
||||
snippet='image "${path}" ${mode:braille} ${width:30}',
|
||||
highlight_values=["braille", "block", "ascii", "halfblock"],
|
||||
is_leaf=True)
|
||||
|
||||
register_keyword("dither", section="",
|
||||
detail="", snippet="",
|
||||
highlight_values=["floyd", "threshold", "none"],
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("invert", section="",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("caption", section="",
|
||||
detail="", snippet="",
|
||||
is_style_directive=True)
|
||||
|
||||
register_keyword("bigtitle", node_class=BigTitle, section="Content",
|
||||
detail='bigtitle "TEXT" block',
|
||||
snippet='bigtitle "${text}" ${font:block}',
|
||||
|
||||
@@ -8,7 +8,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle,
|
||||
BigTitle, ImageNode,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ def layout(node: IRNode, x: int, y: int, w: int, h: int) -> int:
|
||||
elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle)):
|
||||
BigTitle, ImageNode)):
|
||||
node.rect.h = node.pref_height
|
||||
return node.pref_height
|
||||
|
||||
|
||||
@@ -8,9 +8,10 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle,
|
||||
BigTitle, ImageNode,
|
||||
)
|
||||
from uframe.fonts import FONT_HEIGHTS, get_text_width
|
||||
from uframe.imaging import get_image_height
|
||||
|
||||
|
||||
def _text_height(text: str, width: int) -> int:
|
||||
@@ -245,6 +246,15 @@ def measure(node: IRNode, available_width: int) -> None:
|
||||
node.pref_height = 1
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, ImageNode):
|
||||
h = get_image_height(node.path, node.mode, node.img_width)
|
||||
if node.caption:
|
||||
h += 1 # extra line for caption
|
||||
node.pref_width = available_width
|
||||
node.min_width = node.img_width
|
||||
node.pref_height = h
|
||||
node.min_height = 1
|
||||
|
||||
elif isinstance(node, BigTitle):
|
||||
# Try the requested font, fall back to smaller if too wide
|
||||
tw = get_text_width(node.text, node.font)
|
||||
|
||||
@@ -19,11 +19,12 @@ from uframe.ir import (
|
||||
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
||||
Gauge, Sparkline, Status, Table,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
BigTitle,
|
||||
BigTitle, ImageNode,
|
||||
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
|
||||
)
|
||||
from uframe.themes import ThemeDef, THEME_DEFAULT
|
||||
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS
|
||||
from uframe.imaging import convert_image
|
||||
|
||||
|
||||
def _align_text(text: str, width: int, align: Align) -> str:
|
||||
@@ -222,6 +223,31 @@ def paint(node: IRNode, grid: CharGrid, theme: ThemeDef | None = None) -> None:
|
||||
grid.put(x, y, char, style=CellStyle(fg=color))
|
||||
grid.put_text(x + 2, y, node.label)
|
||||
|
||||
elif isinstance(node, ImageNode):
|
||||
style = CellStyle(fg=node.style.fg or th.palette.accent)
|
||||
try:
|
||||
lines = convert_image(node.path, node.mode, node.img_width,
|
||||
node.dither, node.invert, node.use_color)
|
||||
# Center if aligned
|
||||
offset = 0
|
||||
actual_w = len(lines[0]) if lines else 0
|
||||
if node.style.align == Align.CENTER:
|
||||
offset = max(0, (w - actual_w) // 2)
|
||||
elif node.style.align == Align.RIGHT:
|
||||
offset = max(0, w - actual_w)
|
||||
|
||||
for row_i, line in enumerate(lines):
|
||||
if y + row_i < grid.height:
|
||||
grid.put_text(x + offset, y + row_i, line, style=style)
|
||||
|
||||
# Caption
|
||||
if node.caption and y + len(lines) < grid.height:
|
||||
cap_style = CellStyle(fg=th.palette.label, italic=True)
|
||||
grid.put_text(x + offset, y + len(lines), node.caption, style=cap_style)
|
||||
except (ImportError, FileNotFoundError) as e:
|
||||
# Render placeholder if image can't be loaded
|
||||
grid.put_text(x, y, f"[image: {node.path}]", style=CellStyle(fg=th.palette.muted))
|
||||
|
||||
elif isinstance(node, BigTitle):
|
||||
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from uframe.ir import (
|
||||
Gauge, Sparkline, Status, Table, TextSpan,
|
||||
Form, Field, Password, Radio, Checkbox, FormButton,
|
||||
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
||||
BigTitle,
|
||||
BigTitle, ImageNode,
|
||||
ComponentDef, ComponentUse,
|
||||
SourceType,
|
||||
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
||||
@@ -427,6 +427,22 @@ def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
||||
content = " ".join([keyword] + args)
|
||||
return Text(content=content, source_line=line_num)
|
||||
|
||||
elif keyword == "image":
|
||||
path = args[0] if args else ""
|
||||
mode = args[1] if len(args) > 1 else "braille"
|
||||
img_width = int(args[2]) if len(args) > 2 else 30
|
||||
return ImageNode(path=path, mode=mode, img_width=img_width, source_line=line_num)
|
||||
|
||||
elif keyword == "dither":
|
||||
# Style directive for image node
|
||||
return _StyleDirective("dither_val", args[0] if args else "floyd", line_num)
|
||||
|
||||
elif keyword == "invert":
|
||||
return _StyleDirective("invert_val", True, line_num)
|
||||
|
||||
elif keyword == "caption":
|
||||
return _StyleDirective("caption_val", args[0] if args else "", line_num)
|
||||
|
||||
elif keyword == "bigtitle":
|
||||
text = args[0] if args else ""
|
||||
font = args[1] if len(args) > 1 else "block"
|
||||
@@ -776,6 +792,16 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
|
||||
if isinstance(node, _StyleDirective):
|
||||
if stack:
|
||||
parent = stack[-1][1]
|
||||
# Image-specific directives
|
||||
if node.attr == "dither_val" and isinstance(parent, ImageNode):
|
||||
parent.dither = node.value
|
||||
elif node.attr == "invert_val" and isinstance(parent, ImageNode):
|
||||
parent.invert = node.value
|
||||
elif node.attr == "caption_val" and isinstance(parent, ImageNode):
|
||||
parent.caption = node.value
|
||||
elif node.attr in ("dither_val", "invert_val", "caption_val"):
|
||||
pass # ignore if not on ImageNode
|
||||
else:
|
||||
setattr(parent.style, node.attr, node.value)
|
||||
continue
|
||||
|
||||
|
||||
@@ -280,6 +280,43 @@ export const EXAMPLES: Example[] = [
|
||||
divider heavy
|
||||
link "Home" "/page/index.mu"`,
|
||||
},
|
||||
{
|
||||
name: "Image Art",
|
||||
description: "Convert images to braille/block/ascii character art",
|
||||
source: `page "Gallery" 60
|
||||
|
||||
heading 1 "Image Embedding"
|
||||
|
||||
spacer
|
||||
|
||||
row 2
|
||||
col 26
|
||||
image "backend/test_logo.png" braille 24
|
||||
align center
|
||||
caption "Braille mode"
|
||||
col 30
|
||||
heading 2 "Modes"
|
||||
label "braille" "2x4 dot matrix"
|
||||
label "block" "shade chars"
|
||||
label "ascii" "classic ramp"
|
||||
label "halfblock" "color blocks"
|
||||
spacer
|
||||
heading 2 "Options"
|
||||
label "dither" "floyd | threshold"
|
||||
label "invert" "flip light/dark"
|
||||
|
||||
spacer
|
||||
|
||||
image "backend/test_logo.png" block 24
|
||||
align center
|
||||
caption "Block mode"
|
||||
|
||||
spacer
|
||||
|
||||
image "backend/test_logo.png" ascii 24
|
||||
align center
|
||||
caption "ASCII mode"`,
|
||||
},
|
||||
{
|
||||
name: "Big Title",
|
||||
description: "Large ASCII art text in block, thin, and pixel fonts",
|
||||
|
||||
Reference in New Issue
Block a user