Last active
April 21, 2026 10:36
-
-
Save arky/70dfb3ad750bace4132839ba9cd718e0 to your computer and use it in GitHub Desktop.
Building Iconforge, a Shiny app that uploads images, resizes them, compresses them, and outputs a base64 HTML img tag for Quicklink icons.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import base64 | |
| import io | |
| import json | |
| import os | |
| import shinyswatch | |
| from PIL import Image | |
| from shiny import reactive | |
| from shiny.express import input, render, ui | |
| from shiny.types import FileInfo, SilentException | |
| ui.page_opts(title="Iconforge", theme=shinyswatch.theme.darkly()) | |
| MIME_MAP = { | |
| "ICO": "image/x-icon", | |
| "JPEG": "image/jpeg", | |
| "JPG": "image/jpeg", | |
| "PNG": "image/png", | |
| "GIF": "image/gif", | |
| "WEBP": "image/webp", | |
| } | |
| SIZE_CHOICES = {"16": "16x16", "32": "32x32", "48": "48x48", "256": "256x256"} | |
| def format_bytes(n: int) -> str: | |
| if n < 1024: | |
| return f"{n} B" | |
| elif n < 1024 * 1024: | |
| return f"{n / 1024:.1f} KB" | |
| else: | |
| return f"{n / 1024 / 1024:.1f} MB" | |
| @reactive.calc | |
| def processed(): | |
| file_infos: list[FileInfo] = input.file() | |
| if not file_infos: | |
| raise SilentException() | |
| file_info = file_infos[0] | |
| original_size = os.path.getsize(file_info["datapath"]) | |
| px = int(input.output_size()) | |
| try: | |
| pil_img = Image.open(file_info["datapath"]) | |
| pil_img.verify() | |
| except Exception: | |
| raise ValueError(f"'{file_info['name']}' is not a recognised image format.") | |
| with Image.open(file_info["datapath"]) as img: | |
| fmt = img.format | |
| if not fmt or fmt not in MIME_MAP: | |
| raise ValueError(f"Unsupported image format: {fmt or 'unknown'}.") | |
| img = img.resize((px, px), Image.LANCZOS) | |
| buffer = io.BytesIO() | |
| if fmt in ("JPEG", "JPG"): | |
| img.save(buffer, format="JPEG", optimize=True, quality=85) | |
| else: | |
| img.save(buffer, format=fmt, optimize=True) | |
| compressed_data = buffer.getvalue() | |
| compressed_size = len(compressed_data) | |
| savings = original_size - compressed_size | |
| savings_pct = (savings / original_size * 100) if original_size > 0 else 0 | |
| mime = MIME_MAP.get(fmt, f"image/{fmt.lower()}") | |
| b64 = base64.b64encode(compressed_data).decode() | |
| return { | |
| "original_size": original_size, | |
| "compressed_size": compressed_size, | |
| "savings": savings, | |
| "savings_pct": savings_pct, | |
| "mime": mime, | |
| "b64": b64, | |
| "px": px, | |
| } | |
| with ui.layout_columns(col_widths={"sm": (2, 8, 2)}): | |
| ui.HTML("") | |
| with ui.card(): | |
| # Header | |
| ui.p( | |
| "Upload an image, resize and compress it, then copy the base64 " | |
| "JSON code for use as a Quicklink icon attributes.", | |
| class_="text-muted small mb-3", | |
| ) | |
| # Inputs | |
| ui.input_file("file", "Choose an image to upload:", multiple=False) | |
| ui.input_radio_buttons( | |
| "output_size", | |
| "Output size:", | |
| choices=SIZE_CHOICES, | |
| selected="48", | |
| inline=True, | |
| ) | |
| with ui.layout_columns(col_widths=(6, 6)): | |
| ui.input_text("icon_id", "ID:", placeholder="your-icon-id") | |
| ui.input_text("icon_title", "Title:", placeholder="your-icon-title") | |
| ui.input_checkbox("icon_default", "Default", value=True) | |
| ui.hr() | |
| # Preview + size stats side by side | |
| @render.ui | |
| def results(): | |
| try: | |
| data = processed() | |
| except ValueError as e: | |
| return ui.div({"class": "alert alert-danger"}, str(e)) | |
| orig = format_bytes(data["original_size"]) | |
| comp = format_bytes(data["compressed_size"]) | |
| savings = format_bytes(abs(data["savings"])) | |
| pct = data["savings_pct"] | |
| label = "Savings" if data["savings"] >= 0 else "Increase" | |
| px = data["px"] | |
| preview = ui.img( | |
| src=f"data:{data['mime']};base64,{data['b64']}", | |
| style=f"width:{px}px;height:{px}px;", | |
| ) | |
| stats = ui.tags.table( | |
| {"class": "table table-sm table-borderless w-auto mb-0"}, | |
| ui.tags.tr(ui.tags.td("Original:"), ui.tags.td({"class": "ps-3"}, orig)), | |
| ui.tags.tr(ui.tags.td("Compressed:"), ui.tags.td({"class": "ps-3"}, comp)), | |
| ui.tags.tr( | |
| ui.tags.td(f"{label}:"), | |
| ui.tags.td({"class": "ps-3"}, f"{savings} ({abs(pct):.1f}%)"), | |
| ), | |
| ) | |
| return ui.div( | |
| {"class": "d-flex align-items-center gap-4 mb-3"}, | |
| preview, | |
| stats, | |
| ) | |
| # JSON output with copy button | |
| # Credit Garrick of Shiny for Python Developer Team! | |
| @render.ui | |
| def html_out(): | |
| try: | |
| data = processed() | |
| except ValueError: | |
| raise SilentException() | |
| payload = { | |
| "id": input.icon_id() or "your-icon-id", | |
| "icon": { | |
| "src": f"data:{data['mime']};base64,{data['b64']}", | |
| "height": data["px"], | |
| }, | |
| "title": input.icon_title() or "your-icon-title", | |
| "default": input.icon_default(), | |
| } | |
| json_out = json.dumps(payload, indent=2) | |
| return ui.div( | |
| ui.tags.button( | |
| "Copy", | |
| onclick=( | |
| "navigator.clipboard.writeText(" | |
| "document.getElementById('img-tag-code').innerText" | |
| ").then(() => {" | |
| " this.textContent = 'Copied!';" | |
| " setTimeout(() => this.textContent = 'Copy', 1500);" | |
| "})" | |
| ), | |
| class_="btn btn-sm btn-outline-secondary mb-2 float-end", | |
| ), | |
| ui.pre(ui.code({"id": "img-tag-code"}, json_out)), | |
| ) | |
| ui.HTML("") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| shiny | |
| shinyswatch | |
| Pillow |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment