From 528d2d85197853de3c7938e7eec36c6cf3120054 Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 1 Apr 2026 12:31:23 +0200 Subject: [PATCH] feat: imgage feature --- backend/requirements.txt | 1 + backend/test_logo.png | Bin 0 -> 898 bytes backend/uframe/cli.py | 26 ++ backend/uframe/imaging.py | 267 +++++++++++++++++++++ backend/uframe/ir.py | 12 + backend/uframe/keywords.py | 21 +- backend/uframe/layout.py | 4 +- backend/uframe/measure.py | 12 +- backend/uframe/paint.py | 28 ++- backend/uframe/parser.py | 30 ++- frontend/src/components/editor/examples.ts | 37 +++ 11 files changed, 431 insertions(+), 7 deletions(-) create mode 100644 backend/test_logo.png create mode 100644 backend/uframe/imaging.py diff --git a/backend/requirements.txt b/backend/requirements.txt index 67ebee9..0b98d19 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ fastapi>=0.115 uvicorn[standard]>=0.34 docker>=7.0 +Pillow>=10.0 md2txt @ git+https://codeberg.org/randogoth/md2txt diff --git a/backend/test_logo.png b/backend/test_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1156f87f0fe15102d9226d161920b2c11a3a8368 GIT binary patch literal 898 zcmV-|1AY97P)Lfb^87QZ+xe( zS*!=;s1DE+*HGD)oveQ+lPxABiPqy$qq*jif0&IUKiSp$wps-`2hz`fMisZsB!zB% zSVwm{vP1KP+mGaAceCpX;M(#zr=(zWA1OU+$j{j<0cYHj%q!fA%sQNOeK`SB)^%lq zVQWA*t}Ph(*r*2`N04mnW;T;#U4JG)V0#LT&Qi8a0Br0i!1eYs#jtrEM9#{(tZm7d z0J@G$5v&I6x<{1@t3<*zWEl#P7N=aa%^Vv^SC{2l7PmOFes+Vnf*5>@sCnk)0ZRWe&Nah||=xQ?Qa6k5@-K#9{1-JDfatUy+OE z{jlURSIb!1&(*(wCC?J^IbvK}VRiFdn?ZLE0Ty3iIpMy9HL64TkxpIUJ;UB-Y-Wze zw(O+OdSiu$ny?&4?~(~$e%!P?wpVoc8k4erOme$R4(0-Op$R#<_4bMlYf+GqJ6w*Hv<$7%F_0)JV|Vn7ASoGKhGi&OLPl3G7&BlQUT0@x z>!NJBLyWM|QIKr+v&67P0CMKR5t}LUviHm$f~6+s-cn#KMfQoQ=UVwVCHPNFQ%`=` z$di>FR}a>UB`JGu`hvdUa^7v;1wAYm+~&WWpf1f0vUMDJZgb8~KidFu?Oh)V?}N=r z@V>X3n*Fo8j-p;eO=x$3@j^uSXuk2)ma0{Xw$m zg-|&XhCPn64Sfanq|LNv=gVJt+ZeIs@4Zo8SyEgIaGM{MF0Q*07*qoM6N<$f*ahvCIA2c literal 0 HcmV?d00001 diff --git a/backend/uframe/cli.py b/backend/uframe/cli.py index 4c688bf..e26a4a9 100644 --- a/backend/uframe/cli.py +++ b/backend/uframe/cli.py @@ -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) diff --git a/backend/uframe/imaging.py b/backend/uframe/imaging.py new file mode 100644 index 0000000..4560d29 --- /dev/null +++ b/backend/uframe/imaging.py @@ -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)) diff --git a/backend/uframe/ir.py b/backend/uframe/ir.py index 6defb70..90309e8 100644 --- a/backend/uframe/ir.py +++ b/backend/uframe/ir.py @@ -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.""" diff --git a/backend/uframe/keywords.py b/backend/uframe/keywords.py index d719db1..902c38a 100644 --- a/backend/uframe/keywords.py +++ b/backend/uframe/keywords.py @@ -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}', diff --git a/backend/uframe/layout.py b/backend/uframe/layout.py index 697824e..bc3bf59 100644 --- a/backend/uframe/layout.py +++ b/backend/uframe/layout.py @@ -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 diff --git a/backend/uframe/measure.py b/backend/uframe/measure.py index 4bc125e..8052a22 100644 --- a/backend/uframe/measure.py +++ b/backend/uframe/measure.py @@ -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) diff --git a/backend/uframe/paint.py b/backend/uframe/paint.py index 38e2fa8..19780d8 100644 --- a/backend/uframe/paint.py +++ b/backend/uframe/paint.py @@ -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) diff --git a/backend/uframe/parser.py b/backend/uframe/parser.py index 330d7ef..cef1ab1 100644 --- a/backend/uframe/parser.py +++ b/backend/uframe/parser.py @@ -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): diff --git a/frontend/src/components/editor/examples.ts b/frontend/src/components/editor/examples.ts index 38a3fa0..523f2e9 100644 --- a/frontend/src/components/editor/examples.ts +++ b/frontend/src/components/editor/examples.ts @@ -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",