Spaces:
Sleeping
Sleeping
Commit ·
ffe3e3c
0
Parent(s):
Initial: DitherForge API endpoints
Browse files- README.md +21 -0
- app.py +198 -0
- effects.py +94 -0
- palettes.py +10 -0
- requirements.txt +4 -0
- svg_builder.py +96 -0
README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: DitherForge API
|
| 3 |
+
emoji: ⬛
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "5.6.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# DitherForge API
|
| 14 |
+
|
| 15 |
+
API endpoints for generating ASCII/binary/braille art as SVG — designed for GitHub README embeds.
|
| 16 |
+
|
| 17 |
+
## Endpoints
|
| 18 |
+
|
| 19 |
+
- `/api/text` — Convert text to styled SVG
|
| 20 |
+
- `/api/avatar` — Convert image URL to ASCII art SVG
|
| 21 |
+
- `/api/banner` — Generate banner from preset parameters
|
app.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DitherForge API — Gradio app with text, avatar, and banner endpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
import gradio as gr
|
| 5 |
+
import requests
|
| 6 |
+
from io import BytesIO
|
| 7 |
+
from PIL import Image
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from effects import binary_effect, ascii_effect, braille_effect, image_to_gray
|
| 11 |
+
from svg_builder import text_to_svg
|
| 12 |
+
|
| 13 |
+
STYLES = {"binary": binary_effect, "ascii": ascii_effect, "braille": braille_effect}
|
| 14 |
+
ANIMATIONS = ["none", "reveal", "scanline", "flicker", "matrix"]
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def text_endpoint(
|
| 18 |
+
text: str,
|
| 19 |
+
style: str = "ascii",
|
| 20 |
+
color: str = "#00ff41",
|
| 21 |
+
bg_color: str = "#0d1117",
|
| 22 |
+
animation: str = "none",
|
| 23 |
+
duration: float = 2.0,
|
| 24 |
+
font_size: int = 14,
|
| 25 |
+
) -> tuple[str, str]:
|
| 26 |
+
"""Convert text string to styled SVG. Returns (html_preview, svg_source)."""
|
| 27 |
+
effect_fn = STYLES.get(style, ascii_effect)
|
| 28 |
+
|
| 29 |
+
lines = text.split("\n") if "\n" in text else [text]
|
| 30 |
+
max_len = max(len(line) for line in lines)
|
| 31 |
+
h_pixels = len(lines) * 16
|
| 32 |
+
w_pixels = max_len * 10
|
| 33 |
+
|
| 34 |
+
gray = np.zeros((h_pixels, w_pixels), dtype=np.uint8)
|
| 35 |
+
for row_idx, line in enumerate(lines):
|
| 36 |
+
for col_idx, char in enumerate(line):
|
| 37 |
+
brightness = (ord(char) * 37) % 256
|
| 38 |
+
y0, y1 = row_idx * 16, (row_idx + 1) * 16
|
| 39 |
+
x0, x1 = col_idx * 10, (col_idx + 1) * 10
|
| 40 |
+
if y1 <= h_pixels and x1 <= w_pixels:
|
| 41 |
+
gray[y0:y1, x0:x1] = brightness
|
| 42 |
+
|
| 43 |
+
art_text = effect_fn(gray, cols=max_len)
|
| 44 |
+
svg = text_to_svg(art_text, font_size, color, bg_color, animation, duration)
|
| 45 |
+
return svg, svg
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def avatar_endpoint(
|
| 49 |
+
image: Image.Image | None = None,
|
| 50 |
+
image_url: str = "",
|
| 51 |
+
style: str = "ascii",
|
| 52 |
+
cols: int = 60,
|
| 53 |
+
color: str = "#00ff41",
|
| 54 |
+
bg_color: str = "#0d1117",
|
| 55 |
+
animation: str = "none",
|
| 56 |
+
duration: float = 2.0,
|
| 57 |
+
) -> tuple[str, str]:
|
| 58 |
+
"""Convert uploaded image or image URL to ASCII art SVG."""
|
| 59 |
+
if image is None and image_url:
|
| 60 |
+
resp = requests.get(image_url, timeout=10)
|
| 61 |
+
resp.raise_for_status()
|
| 62 |
+
image = Image.open(BytesIO(resp.content))
|
| 63 |
+
|
| 64 |
+
if image is None:
|
| 65 |
+
svg = text_to_svg("No image provided", 14, color, bg_color)
|
| 66 |
+
return svg, svg
|
| 67 |
+
|
| 68 |
+
gray = image_to_gray(image)
|
| 69 |
+
effect_fn = STYLES.get(style, ascii_effect)
|
| 70 |
+
art_text = effect_fn(gray, cols=cols)
|
| 71 |
+
svg = text_to_svg(art_text, 14, color, bg_color, animation, duration)
|
| 72 |
+
return svg, svg
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def banner_endpoint(
|
| 76 |
+
text: str = "DitherForge",
|
| 77 |
+
style: str = "binary",
|
| 78 |
+
cols: int = 40,
|
| 79 |
+
color: str = "#00ff41",
|
| 80 |
+
bg_color: str = "#0d1117",
|
| 81 |
+
animation: str = "reveal",
|
| 82 |
+
duration: float = 2.0,
|
| 83 |
+
) -> tuple[str, str]:
|
| 84 |
+
"""Generate a banner SVG from parameters."""
|
| 85 |
+
from PIL import ImageDraw, ImageFont
|
| 86 |
+
|
| 87 |
+
font_size = 40
|
| 88 |
+
char_w = font_size * 0.6
|
| 89 |
+
img_w = max(int(len(text) * char_w) + 20, 200)
|
| 90 |
+
img_h = font_size + 20
|
| 91 |
+
|
| 92 |
+
img = Image.new("L", (img_w, img_h), 255)
|
| 93 |
+
draw = ImageDraw.Draw(img)
|
| 94 |
+
try:
|
| 95 |
+
font = ImageFont.truetype(
|
| 96 |
+
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", font_size
|
| 97 |
+
)
|
| 98 |
+
except (OSError, IOError):
|
| 99 |
+
font = ImageFont.load_default()
|
| 100 |
+
|
| 101 |
+
draw.text((10, 10), text, fill=0, font=font)
|
| 102 |
+
gray = np.array(img, dtype=np.uint8)
|
| 103 |
+
|
| 104 |
+
effect_fn = STYLES.get(style, binary_effect)
|
| 105 |
+
art_text = effect_fn(gray, cols=cols)
|
| 106 |
+
svg = text_to_svg(art_text, 12, color, bg_color, animation, duration)
|
| 107 |
+
return svg, svg
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# --- Gradio UI ---
|
| 111 |
+
|
| 112 |
+
with gr.Blocks(title="DitherForge API") as demo:
|
| 113 |
+
gr.Markdown(
|
| 114 |
+
"# DitherForge API\n"
|
| 115 |
+
"Generate ASCII/binary/braille art as SVG for GitHub README embeds."
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
with gr.Tab("Text to SVG"):
|
| 119 |
+
with gr.Row():
|
| 120 |
+
with gr.Column():
|
| 121 |
+
txt_input = gr.Textbox(label="Text", value="Hello World")
|
| 122 |
+
txt_style = gr.Dropdown(
|
| 123 |
+
list(STYLES.keys()), value="ascii", label="Style"
|
| 124 |
+
)
|
| 125 |
+
txt_color = gr.ColorPicker(label="Text Color", value="#00ff41")
|
| 126 |
+
txt_bg = gr.ColorPicker(label="Background", value="#0d1117")
|
| 127 |
+
txt_anim = gr.Dropdown(ANIMATIONS, value="none", label="Animation")
|
| 128 |
+
txt_dur = gr.Slider(
|
| 129 |
+
0.5, 5.0, value=2.0, step=0.1, label="Duration (s)"
|
| 130 |
+
)
|
| 131 |
+
txt_btn = gr.Button("Generate", variant="primary")
|
| 132 |
+
with gr.Column():
|
| 133 |
+
txt_out = gr.HTML(label="Preview")
|
| 134 |
+
txt_svg = gr.Code(label="SVG Source", language="xml")
|
| 135 |
+
|
| 136 |
+
txt_btn.click(
|
| 137 |
+
text_endpoint,
|
| 138 |
+
[txt_input, txt_style, txt_color, txt_bg, txt_anim, txt_dur],
|
| 139 |
+
[txt_out, txt_svg],
|
| 140 |
+
api_name="text",
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
with gr.Tab("Image to ASCII SVG"):
|
| 144 |
+
with gr.Row():
|
| 145 |
+
with gr.Column():
|
| 146 |
+
img_input = gr.Image(type="pil", label="Upload Image")
|
| 147 |
+
img_url = gr.Textbox(
|
| 148 |
+
label="Or paste image URL", placeholder="https://..."
|
| 149 |
+
)
|
| 150 |
+
img_style = gr.Dropdown(
|
| 151 |
+
list(STYLES.keys()), value="ascii", label="Style"
|
| 152 |
+
)
|
| 153 |
+
img_cols = gr.Slider(20, 120, value=60, step=1, label="Columns")
|
| 154 |
+
img_color = gr.ColorPicker(label="Text Color", value="#00ff41")
|
| 155 |
+
img_bg = gr.ColorPicker(label="Background", value="#0d1117")
|
| 156 |
+
img_anim = gr.Dropdown(ANIMATIONS, value="none", label="Animation")
|
| 157 |
+
img_btn = gr.Button("Generate", variant="primary")
|
| 158 |
+
with gr.Column():
|
| 159 |
+
img_out = gr.HTML(label="Preview")
|
| 160 |
+
img_svg = gr.Code(label="SVG Source", language="xml")
|
| 161 |
+
|
| 162 |
+
img_btn.click(
|
| 163 |
+
avatar_endpoint,
|
| 164 |
+
[img_input, img_url, img_style, img_cols, img_color, img_bg, img_anim],
|
| 165 |
+
[img_out, img_svg],
|
| 166 |
+
api_name="avatar",
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
with gr.Tab("Banner"):
|
| 170 |
+
with gr.Row():
|
| 171 |
+
with gr.Column():
|
| 172 |
+
ban_text = gr.Textbox(label="Banner Text", value="DitherForge")
|
| 173 |
+
ban_style = gr.Dropdown(
|
| 174 |
+
list(STYLES.keys()), value="binary", label="Style"
|
| 175 |
+
)
|
| 176 |
+
ban_cols = gr.Slider(20, 100, value=40, step=1, label="Columns")
|
| 177 |
+
ban_color = gr.ColorPicker(label="Color", value="#00ff41")
|
| 178 |
+
ban_bg = gr.ColorPicker(label="Background", value="#0d1117")
|
| 179 |
+
ban_anim = gr.Dropdown(
|
| 180 |
+
ANIMATIONS, value="reveal", label="Animation"
|
| 181 |
+
)
|
| 182 |
+
ban_dur = gr.Slider(
|
| 183 |
+
0.5, 5.0, value=2.0, step=0.1, label="Duration (s)"
|
| 184 |
+
)
|
| 185 |
+
ban_btn = gr.Button("Generate", variant="primary")
|
| 186 |
+
with gr.Column():
|
| 187 |
+
ban_out = gr.HTML(label="Preview")
|
| 188 |
+
ban_svg = gr.Code(label="SVG Source", language="xml")
|
| 189 |
+
|
| 190 |
+
ban_btn.click(
|
| 191 |
+
banner_endpoint,
|
| 192 |
+
[ban_text, ban_style, ban_cols, ban_color, ban_bg, ban_anim, ban_dur],
|
| 193 |
+
[ban_out, ban_svg],
|
| 194 |
+
api_name="banner",
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
if __name__ == "__main__":
|
| 198 |
+
demo.launch()
|
effects.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-Python ports of DitherForge core text effects."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
import numpy as np
|
| 5 |
+
from PIL import Image
|
| 6 |
+
|
| 7 |
+
DEFAULT_ASCII_CHARSET = " .:-=+*#%@"
|
| 8 |
+
|
| 9 |
+
BRAILLE_BASE = 0x2800
|
| 10 |
+
DOT_BITS = [
|
| 11 |
+
[0x01, 0x08],
|
| 12 |
+
[0x02, 0x10],
|
| 13 |
+
[0x04, 0x20],
|
| 14 |
+
[0x40, 0x80],
|
| 15 |
+
]
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def image_to_gray(img: Image.Image) -> np.ndarray:
|
| 19 |
+
"""Convert PIL Image to 2D grayscale numpy array (0-255)."""
|
| 20 |
+
return np.array(img.convert("L"), dtype=np.uint8)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def binary_effect(gray: np.ndarray, cols: int = 80, threshold: int = 128) -> str:
|
| 24 |
+
"""Convert grayscale image to binary (0/1) text."""
|
| 25 |
+
h, w = gray.shape
|
| 26 |
+
cols = max(1, min(cols, w))
|
| 27 |
+
block_w = w / cols
|
| 28 |
+
block_h = block_w * 2
|
| 29 |
+
rows = max(1, int(h / block_h))
|
| 30 |
+
|
| 31 |
+
lines = []
|
| 32 |
+
for row in range(rows):
|
| 33 |
+
line = ""
|
| 34 |
+
for col in range(cols):
|
| 35 |
+
x0 = int(col * block_w)
|
| 36 |
+
x1 = int((col + 1) * block_w)
|
| 37 |
+
y0 = int(row * block_h)
|
| 38 |
+
y1 = min(int((row + 1) * block_h), h)
|
| 39 |
+
avg = gray[y0:y1, x0:x1].mean()
|
| 40 |
+
line += "1" if avg >= threshold else "0"
|
| 41 |
+
lines.append(line)
|
| 42 |
+
return "\n".join(lines)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def ascii_effect(
|
| 46 |
+
gray: np.ndarray, cols: int = 80, charset: str = DEFAULT_ASCII_CHARSET
|
| 47 |
+
) -> str:
|
| 48 |
+
"""Convert grayscale image to ASCII art."""
|
| 49 |
+
h, w = gray.shape
|
| 50 |
+
cols = max(1, min(cols, w))
|
| 51 |
+
block_w = w / cols
|
| 52 |
+
block_h = block_w * 2
|
| 53 |
+
rows = max(1, int(h / block_h))
|
| 54 |
+
|
| 55 |
+
lines = []
|
| 56 |
+
for row in range(rows):
|
| 57 |
+
line = ""
|
| 58 |
+
for col in range(cols):
|
| 59 |
+
x0 = int(col * block_w)
|
| 60 |
+
x1 = int((col + 1) * block_w)
|
| 61 |
+
y0 = int(row * block_h)
|
| 62 |
+
y1 = min(int((row + 1) * block_h), h)
|
| 63 |
+
avg = gray[y0:y1, x0:x1].mean()
|
| 64 |
+
idx = min(int((avg / 255) * len(charset)), len(charset) - 1)
|
| 65 |
+
line += charset[idx]
|
| 66 |
+
lines.append(line)
|
| 67 |
+
return "\n".join(lines)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def braille_effect(
|
| 71 |
+
gray: np.ndarray, cols: int = 40, threshold: int = 128
|
| 72 |
+
) -> str:
|
| 73 |
+
"""Convert grayscale image to Unicode braille art."""
|
| 74 |
+
h, w = gray.shape
|
| 75 |
+
cols = max(1, min(cols, w // 2))
|
| 76 |
+
pix_w = cols * 2
|
| 77 |
+
scale = w / pix_w
|
| 78 |
+
pix_h = int(h / scale)
|
| 79 |
+
rows = max(1, pix_h // 4)
|
| 80 |
+
|
| 81 |
+
lines = []
|
| 82 |
+
for row in range(rows):
|
| 83 |
+
line = ""
|
| 84 |
+
for col in range(cols):
|
| 85 |
+
cp = 0
|
| 86 |
+
for dy in range(4):
|
| 87 |
+
for dx in range(2):
|
| 88 |
+
sx = min(int((col * 2 + dx) * scale), w - 1)
|
| 89 |
+
sy = min(int((row * 4 + dy) * scale), h - 1)
|
| 90 |
+
if gray[sy, sx] < threshold:
|
| 91 |
+
cp |= DOT_BITS[dy][dx]
|
| 92 |
+
line += chr(BRAILLE_BASE + cp)
|
| 93 |
+
lines.append(line)
|
| 94 |
+
return "\n".join(lines)
|
palettes.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PALETTES = {
|
| 2 |
+
"mono": ["#000000", "#ffffff"],
|
| 3 |
+
"gameboy": ["#0f380f", "#306230", "#8bac0f", "#9bbc0f"],
|
| 4 |
+
"cga": [
|
| 5 |
+
"#000000", "#0000aa", "#00aa00", "#00aaaa",
|
| 6 |
+
"#aa0000", "#aa00aa", "#aa5500", "#aaaaaa",
|
| 7 |
+
"#555555", "#5555ff", "#55ff55", "#55ffff",
|
| 8 |
+
"#ff5555", "#ff55ff", "#ffff55", "#ffffff",
|
| 9 |
+
],
|
| 10 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
Pillow>=10.0.0
|
| 3 |
+
requests>=2.31.0
|
| 4 |
+
numpy>=1.24.0
|
svg_builder.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SVG generation with optional CSS animations."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
from html import escape
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def text_to_svg(
|
| 8 |
+
text: str,
|
| 9 |
+
font_size: int = 14,
|
| 10 |
+
color: str = "#00ff41",
|
| 11 |
+
bg_color: str = "#0d1117",
|
| 12 |
+
animation: str = "none",
|
| 13 |
+
duration: float = 2.0,
|
| 14 |
+
) -> str:
|
| 15 |
+
lines = text.split("\n")
|
| 16 |
+
cols = max((len(line) for line in lines), default=0)
|
| 17 |
+
char_w = font_size * 0.6
|
| 18 |
+
line_h = font_size * 1.2
|
| 19 |
+
svg_w = int(cols * char_w)
|
| 20 |
+
svg_h = int(len(lines) * line_h)
|
| 21 |
+
|
| 22 |
+
parts = [
|
| 23 |
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" '
|
| 24 |
+
f'viewBox="0 0 {svg_w} {svg_h}">',
|
| 25 |
+
f'<rect width="100%" height="100%" fill="{bg_color}"/>',
|
| 26 |
+
]
|
| 27 |
+
|
| 28 |
+
css = _animation_css(animation, duration, len(lines))
|
| 29 |
+
if css:
|
| 30 |
+
parts.append(f"<style>{css}</style>")
|
| 31 |
+
|
| 32 |
+
use_row_class = animation in ("reveal", "matrix")
|
| 33 |
+
|
| 34 |
+
parts.append(
|
| 35 |
+
f'<text font-family="monospace" font-size="{font_size}" fill="{color}">'
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
for i, line in enumerate(lines):
|
| 39 |
+
y = (i + 1) * line_h - (line_h - font_size) / 2
|
| 40 |
+
cls = f' class="row row-{i}"' if use_row_class else ""
|
| 41 |
+
parts.append(f'<tspan x="0" y="{y:.1f}"{cls}>{escape(line)}</tspan>')
|
| 42 |
+
|
| 43 |
+
parts.append("</text>")
|
| 44 |
+
|
| 45 |
+
if animation == "scanline":
|
| 46 |
+
parts.append(
|
| 47 |
+
f'<rect class="scanline" x="0" y="0" width="100%" '
|
| 48 |
+
f'height="{line_h}" fill="{color}" opacity="0.15"/>'
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
parts.append("</svg>")
|
| 52 |
+
return "".join(parts)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _animation_css(animation: str, duration: float, rows: int) -> str:
|
| 56 |
+
if animation == "reveal":
|
| 57 |
+
delays = "\n".join(
|
| 58 |
+
f".row-{i} {{ animation-delay: {i * duration / rows:.2f}s; }}"
|
| 59 |
+
for i in range(rows)
|
| 60 |
+
)
|
| 61 |
+
return (
|
| 62 |
+
".row { opacity: 0; animation: reveal 0.15s ease-out forwards; }\n"
|
| 63 |
+
"@keyframes reveal { from { opacity: 0; } to { opacity: 1; } }\n"
|
| 64 |
+
+ delays
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
if animation == "scanline":
|
| 68 |
+
return (
|
| 69 |
+
f"@keyframes scan {{ 0% {{ transform: translateY(-5%); }} "
|
| 70 |
+
f"100% {{ transform: translateY(105%); }} }}\n"
|
| 71 |
+
f".scanline {{ animation: scan {duration}s linear infinite; }}"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
if animation == "flicker":
|
| 75 |
+
return (
|
| 76 |
+
"@keyframes flicker { 0%,100% { opacity:1; } "
|
| 77 |
+
"92% { opacity:1; } 93% { opacity:0.3; } "
|
| 78 |
+
"94% { opacity:1; } 96% { opacity:0.5; } 97% { opacity:1; } }\n"
|
| 79 |
+
f"text {{ animation: flicker {duration}s ease-in-out infinite; }}"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
if animation == "matrix":
|
| 83 |
+
delays = "\n".join(
|
| 84 |
+
f".row-{i} {{ animation-delay: {i * duration / rows:.2f}s; }}"
|
| 85 |
+
for i in range(rows)
|
| 86 |
+
)
|
| 87 |
+
return (
|
| 88 |
+
f".row {{ opacity: 0; animation: matrix {duration * 0.3:.2f}s ease-out forwards; }}\n"
|
| 89 |
+
"@keyframes matrix { "
|
| 90 |
+
"0% { opacity:0; transform:translateY(-20px); } "
|
| 91 |
+
"60% { opacity:1; } "
|
| 92 |
+
"100% { opacity:1; transform:translateY(0); } }\n"
|
| 93 |
+
+ delays
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
return ""
|