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 fastapi>=0.115
uvicorn[standard]>=0.34 uvicorn[standard]>=0.34
docker>=7.0 docker>=7.0
Pillow>=10.0
md2txt @ git+https://codeberg.org/randogoth/md2txt 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 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: def main() -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="uframe", 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("--width", type=int, default=64, help="Page width")
p_deploy.add_argument("--theme", default="", help="Theme name") 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() args = parser.parse_args()
commands = { commands = {
@@ -158,6 +183,7 @@ def main() -> int:
"compile": cmd_compile, "compile": cmd_compile,
"check": cmd_check, "check": cmd_check,
"deploy": cmd_deploy, "deploy": cmd_deploy,
"image": cmd_image,
} }
return commands[args.command](args) 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) # 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 @dataclass
class BigTitle(IRNode): class BigTitle(IRNode):
"""Large multi-line ASCII art text.""" """Large multi-line ASCII art text."""

View File

@@ -20,7 +20,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, BigTitle, ImageNode,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
) )
from uframe.themes import BUILTIN_THEMES from uframe.themes import BUILTIN_THEMES
@@ -120,6 +120,25 @@ register_keyword("columns", section="",
# Big Text # 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", register_keyword("bigtitle", node_class=BigTitle, section="Content",
detail='bigtitle "TEXT" block', detail='bigtitle "TEXT" block',
snippet='bigtitle "${text}" ${font:block}', snippet='bigtitle "${text}" ${font:block}',

View File

@@ -8,7 +8,7 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, 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, elif isinstance(node, (Heading, Text, Label, Divider, Link, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Field, Password, Radio, Checkbox, FormButton, Field, Password, Radio, Checkbox, FormButton,
BigTitle)): BigTitle, ImageNode)):
node.rect.h = node.pref_height node.rect.h = node.pref_height
return node.pref_height return node.pref_height

View File

@@ -8,9 +8,10 @@ from uframe.ir import (
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, BigTitle, ImageNode,
) )
from uframe.fonts import FONT_HEIGHTS, get_text_width from uframe.fonts import FONT_HEIGHTS, get_text_width
from uframe.imaging import get_image_height
def _text_height(text: str, width: int) -> int: 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.pref_height = 1
node.min_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): elif isinstance(node, BigTitle):
# Try the requested font, fall back to smaller if too wide # Try the requested font, fall back to smaller if too wide
tw = get_text_width(node.text, node.font) 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, Heading, Text, Label, Divider, Link, ListNode, ListItem,
Gauge, Sparkline, Status, Table, Gauge, Sparkline, Status, Table,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
BigTitle, BigTitle, ImageNode,
HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, BorderWeight,
) )
from uframe.themes import ThemeDef, THEME_DEFAULT from uframe.themes import ThemeDef, THEME_DEFAULT
from uframe.fonts import render_big_text, get_text_width, FONT_HEIGHTS 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: 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(x, y, char, style=CellStyle(fg=color))
grid.put_text(x + 2, y, node.label) 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): elif isinstance(node, BigTitle):
style = CellStyle(fg=node.style.fg or th.palette.accent, bold=True) 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, Gauge, Sparkline, Status, Table, TextSpan,
Form, Field, Password, Radio, Checkbox, FormButton, Form, Field, Password, Radio, Checkbox, FormButton,
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl, Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
BigTitle, BigTitle, ImageNode,
ComponentDef, ComponentUse, ComponentDef, ComponentUse,
SourceType, SourceType,
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style, 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) content = " ".join([keyword] + args)
return Text(content=content, source_line=line_num) 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": elif keyword == "bigtitle":
text = args[0] if args else "" text = args[0] if args else ""
font = args[1] if len(args) > 1 else "block" 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 isinstance(node, _StyleDirective):
if stack: if stack:
parent = stack[-1][1] 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 continue
if isinstance(node, _ThemeDirective): if isinstance(node, _ThemeDirective):

View File

@@ -280,6 +280,43 @@ export const EXAMPLES: Example[] = [
divider heavy divider heavy
link "Home" "/page/index.mu"`, 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", name: "Big Title",
description: "Large ASCII art text in block, thin, and pixel fonts", description: "Large ASCII art text in block, thin, and pixel fonts",