feat: imgage feature

This commit is contained in:
2026-04-01 12:31:23 +02:00
parent 175de893aa
commit 528d2d8519
11 changed files with 431 additions and 7 deletions

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 898 B

View File

@@ -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
View 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.01.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))

View File

@@ -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."""

View File

@@ -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}',

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,7 +792,17 @@ def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Pag
if isinstance(node, _StyleDirective):
if stack:
parent = stack[-1][1]
setattr(parent.style, node.attr, node.value)
# 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
if isinstance(node, _ThemeDirective):

View File

@@ -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",