Improve look and feel
This commit is contained in:
+103
-24
@@ -3,36 +3,113 @@ import os
|
||||
from PIL import Image
|
||||
import cairosvg
|
||||
import io
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
threshold = 128
|
||||
|
||||
|
||||
def parse_svg_intrinsic_size(svg_data):
|
||||
try:
|
||||
root = ET.fromstring(svg_data)
|
||||
except ET.ParseError:
|
||||
return None, None
|
||||
|
||||
viewbox = root.get("viewBox") or root.get("viewbox")
|
||||
if viewbox:
|
||||
parts = viewbox.replace(",", " ").split()
|
||||
if len(parts) == 4:
|
||||
try:
|
||||
vb_w = float(parts[2])
|
||||
vb_h = float(parts[3])
|
||||
if vb_w > 0 and vb_h > 0:
|
||||
return vb_w, vb_h
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def parse_len(value):
|
||||
if not value:
|
||||
return None
|
||||
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in ".-")
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
parsed = float(cleaned)
|
||||
return parsed if parsed > 0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
w = parse_len(root.get("width"))
|
||||
h = parse_len(root.get("height"))
|
||||
return w, h
|
||||
|
||||
|
||||
def fit_inside_canvas(src_w, src_h, dst_w, dst_h):
|
||||
if src_w <= 0 or src_h <= 0 or dst_w <= 0 or dst_h <= 0:
|
||||
return dst_w, dst_h
|
||||
|
||||
src_ratio = src_w / src_h
|
||||
dst_ratio = dst_w / dst_h
|
||||
|
||||
if src_ratio >= dst_ratio:
|
||||
fit_w = dst_w
|
||||
fit_h = max(1, int(round(fit_w / src_ratio)))
|
||||
else:
|
||||
fit_h = dst_h
|
||||
fit_w = max(1, int(round(fit_h * src_ratio)))
|
||||
|
||||
return fit_w, fit_h
|
||||
|
||||
|
||||
def svg_to_png_bytes(svg_path, width, height):
|
||||
with open(svg_path, 'rb') as f:
|
||||
with open(svg_path, "rb") as f:
|
||||
svg_data = f.read()
|
||||
png_bytes = cairosvg.svg2png(bytestring=svg_data, output_width=width, output_height=height)
|
||||
|
||||
src_w, src_h = parse_svg_intrinsic_size(svg_data)
|
||||
render_w, render_h = fit_inside_canvas(
|
||||
src_w or width, src_h or height, width, height
|
||||
)
|
||||
|
||||
png_bytes = cairosvg.svg2png(
|
||||
bytestring=svg_data, output_width=render_w, output_height=render_h
|
||||
)
|
||||
return png_bytes
|
||||
|
||||
|
||||
def center_on_canvas(img, width, height):
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
|
||||
x = (width - img.width) // 2
|
||||
y = (height - img.height) // 2
|
||||
canvas.paste(img, (x, y), img)
|
||||
return canvas
|
||||
|
||||
|
||||
def load_image(path, width, height):
|
||||
ext = os.path.splitext(path)[1].lower()
|
||||
if ext == '.svg':
|
||||
if ext == ".svg":
|
||||
png_bytes = svg_to_png_bytes(path, width, height)
|
||||
img = Image.open(io.BytesIO(png_bytes))
|
||||
img = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||||
img = center_on_canvas(img, width, height)
|
||||
else:
|
||||
img = Image.open(path)
|
||||
img = img.convert('RGBA')
|
||||
img = img.resize((width, height), Image.LANCZOS)
|
||||
img = Image.open(path).convert("RGBA")
|
||||
# Keep source aspect ratio and fit inside requested canvas.
|
||||
fit = img.copy()
|
||||
fit.thumbnail((width, height), Image.LANCZOS)
|
||||
img = center_on_canvas(fit, width, height)
|
||||
# Flatten alpha: paste on white background
|
||||
background = Image.new('RGBA', img.size, (255, 255, 255, 255))
|
||||
background = Image.new("RGBA", img.size, (255, 255, 255, 255))
|
||||
background.paste(img, mask=img.split()[3])
|
||||
img = background
|
||||
# Rotate 90 degrees counterclockwise
|
||||
img = img.rotate(90, expand=True)
|
||||
return img
|
||||
|
||||
|
||||
def image_to_c_array(img, array_name):
|
||||
# Convert to grayscale, then threshold to get white=1, black=0
|
||||
# Convert to grayscale
|
||||
img = img.convert('L')
|
||||
img = img.convert("L")
|
||||
width, height = img.size
|
||||
pixels = list(img.getdata())
|
||||
packed = []
|
||||
@@ -44,37 +121,39 @@ def image_to_c_array(img, array_name):
|
||||
v = pixels[y * width + x + b]
|
||||
# 1 for white, 0 for black
|
||||
bit = 1 if v >= threshold else 0
|
||||
byte |= (bit << (7 - b))
|
||||
byte |= bit << (7 - b)
|
||||
packed.append(byte)
|
||||
# Format as C array
|
||||
c = f'#pragma once\n#include <cstdint>\n\n'
|
||||
c += f'// size: {width}x{height}\n'
|
||||
c += f'static const uint8_t {array_name}[] = {{\n '
|
||||
c = f"#pragma once\n#include <cstdint>\n\n"
|
||||
c += f"// size: {width}x{height}\n"
|
||||
c += f"static const uint8_t {array_name}[] = {{\n "
|
||||
for i, v in enumerate(packed):
|
||||
c += f'0x{v:02X}, '
|
||||
c += f"0x{v:02X}, "
|
||||
if (i + 1) % 16 == 0:
|
||||
c += '\n '
|
||||
c = c.rstrip(', \n') + '\n};\n'
|
||||
c += "\n "
|
||||
c = c.rstrip(", \n") + "\n};\n"
|
||||
return c
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 5:
|
||||
print('Usage: python convert_image.py input.png output_name width height')
|
||||
print("Usage: python convert_image.py input.png output_name width height")
|
||||
sys.exit(1)
|
||||
input_path, output_name, width, height = sys.argv[1:5]
|
||||
array_name = output_name.capitalize() + 'Icon'
|
||||
array_name = output_name.capitalize() + "Icon"
|
||||
width, height = int(width), int(height)
|
||||
img = load_image(input_path, width, height)
|
||||
c_array = image_to_c_array(img, array_name)
|
||||
|
||||
# Always save to src/components/icons/[output_name].h relative to project root
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
output_dir = os.path.join(project_root, 'src', 'components', 'icons')
|
||||
output_dir = os.path.join(project_root, "src", "components", "icons")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
output_path = os.path.join(output_dir, f'{output_name}.h')
|
||||
with open(output_path, 'w') as f:
|
||||
output_path = os.path.join(output_dir, f"{output_name}.h")
|
||||
with open(output_path, "w") as f:
|
||||
f.write(c_array)
|
||||
print(f'Wrote {output_path}')
|
||||
print(f"Wrote {output_path}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate lib/Weather/WeatherIcons48.h from SVG sources.
|
||||
|
||||
By default, this script loads SVG files from assets/weather-icons/svg.
|
||||
Use --fetch to download missing files from erikflowers/weather-icons.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
import cairosvg # type: ignore
|
||||
except Exception:
|
||||
cairosvg = None
|
||||
|
||||
|
||||
SIZE = 48
|
||||
THRESHOLD = 128
|
||||
CONTENT_MARGIN = 1
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
DEFAULT_SVG_DIR = PROJECT_ROOT / "assets" / "weather-icons" / "svg"
|
||||
DEFAULT_OUT = PROJECT_ROOT / "lib" / "Weather" / "WeatherIcons48.h"
|
||||
UPSTREAM_BASE = "https://raw.githubusercontent.com/erikflowers/weather-icons/master/svg"
|
||||
RESVG_ZIP_URL = (
|
||||
"https://github.com/linebender/resvg/releases/latest/download/resvg-win64.zip"
|
||||
)
|
||||
RESVG_EXE = PROJECT_ROOT / ".cache" / "resvg" / "resvg.exe"
|
||||
|
||||
ICON_SOURCES = {
|
||||
"WI48_CLEAR_DAY": "wi-day-sunny.svg",
|
||||
"WI48_CLEAR_NIGHT": "wi-night-clear.svg",
|
||||
"WI48_PARTLY_CLOUDY_DAY": "wi-day-cloudy.svg",
|
||||
"WI48_PARTLY_CLOUDY_NIGHT": "wi-night-alt-cloudy.svg",
|
||||
"WI48_OVERCAST": "wi-cloudy.svg",
|
||||
"WI48_FOG": "wi-fog.svg",
|
||||
"WI48_DRIZZLE": "wi-sprinkle.svg",
|
||||
"WI48_RAIN": "wi-rain.svg",
|
||||
"WI48_SNOW": "wi-snow.svg",
|
||||
"WI48_THUNDERSTORM": "wi-thunderstorm.svg",
|
||||
}
|
||||
|
||||
|
||||
def parse_svg_intrinsic_size(svg_data):
|
||||
try:
|
||||
root = ET.fromstring(svg_data)
|
||||
except ET.ParseError:
|
||||
return None, None
|
||||
|
||||
viewbox = root.get("viewBox") or root.get("viewbox")
|
||||
if viewbox:
|
||||
parts = viewbox.replace(",", " ").split()
|
||||
if len(parts) == 4:
|
||||
try:
|
||||
vb_w = float(parts[2])
|
||||
vb_h = float(parts[3])
|
||||
if vb_w > 0 and vb_h > 0:
|
||||
return vb_w, vb_h
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def parse_len(value):
|
||||
if not value:
|
||||
return None
|
||||
cleaned = "".join(ch for ch in value if ch.isdigit() or ch in ".-")
|
||||
if not cleaned:
|
||||
return None
|
||||
try:
|
||||
parsed = float(cleaned)
|
||||
return parsed if parsed > 0 else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return parse_len(root.get("width")), parse_len(root.get("height"))
|
||||
|
||||
|
||||
def fit_inside_canvas(src_w, src_h, dst_w, dst_h):
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
return dst_w, dst_h
|
||||
|
||||
src_ratio = src_w / src_h
|
||||
dst_ratio = dst_w / dst_h
|
||||
if src_ratio >= dst_ratio:
|
||||
fit_w = dst_w
|
||||
fit_h = max(1, int(round(fit_w / src_ratio)))
|
||||
else:
|
||||
fit_h = dst_h
|
||||
fit_w = max(1, int(round(fit_h * src_ratio)))
|
||||
return fit_w, fit_h
|
||||
|
||||
|
||||
def ensure_resvg_binary():
|
||||
if shutil.which("resvg"):
|
||||
return Path(shutil.which("resvg"))
|
||||
|
||||
if RESVG_EXE.exists():
|
||||
return RESVG_EXE
|
||||
|
||||
RESVG_EXE.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive_path = RESVG_EXE.parent / "resvg.zip"
|
||||
with urllib.request.urlopen(RESVG_ZIP_URL) as response:
|
||||
archive_path.write_bytes(response.read())
|
||||
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
zf.extractall(RESVG_EXE.parent)
|
||||
|
||||
found = list(RESVG_EXE.parent.rglob("resvg.exe"))
|
||||
if not found:
|
||||
raise RuntimeError("resvg.exe not found after extraction")
|
||||
|
||||
if found[0] != RESVG_EXE:
|
||||
RESVG_EXE.write_bytes(found[0].read_bytes())
|
||||
|
||||
return RESVG_EXE
|
||||
|
||||
|
||||
def render_svg_with_resvg(svg_data, render_w, render_h):
|
||||
exe = ensure_resvg_binary()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
in_svg = tmp_path / "icon.svg"
|
||||
out_png = tmp_path / "icon.png"
|
||||
in_svg.write_bytes(svg_data)
|
||||
|
||||
cmd = [
|
||||
str(exe),
|
||||
"--width",
|
||||
str(render_w),
|
||||
"--height",
|
||||
str(render_h),
|
||||
str(in_svg),
|
||||
str(out_png),
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
return out_png.read_bytes()
|
||||
|
||||
|
||||
def render_svg_contain(svg_data, width, height):
|
||||
src_w, src_h = parse_svg_intrinsic_size(svg_data)
|
||||
render_w, render_h = fit_inside_canvas(
|
||||
src_w or width, src_h or height, width, height
|
||||
)
|
||||
|
||||
# Render larger first so trimming and re-fit preserve detail quality.
|
||||
oversample = 4
|
||||
render_w *= oversample
|
||||
render_h *= oversample
|
||||
|
||||
if cairosvg is not None:
|
||||
png_bytes = cairosvg.svg2png(
|
||||
bytestring=svg_data, output_width=render_w, output_height=render_h
|
||||
)
|
||||
else:
|
||||
png_bytes = render_svg_with_resvg(svg_data, render_w, render_h)
|
||||
|
||||
icon = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
|
||||
|
||||
# Trim transparent/empty margins so symbols use available icon area better.
|
||||
alpha_bbox = icon.split()[3].getbbox()
|
||||
if alpha_bbox is not None:
|
||||
icon = icon.crop(alpha_bbox)
|
||||
|
||||
max_w = max(1, width - 2 * CONTENT_MARGIN)
|
||||
max_h = max(1, height - 2 * CONTENT_MARGIN)
|
||||
icon.thumbnail((max_w, max_h), Image.Resampling.LANCZOS)
|
||||
|
||||
canvas = Image.new("RGBA", (width, height), (255, 255, 255, 255))
|
||||
off_x = (width - icon.width) // 2
|
||||
off_y = (height - icon.height) // 2
|
||||
canvas.paste(icon, (off_x, off_y), icon)
|
||||
|
||||
# Flatten alpha on white and convert to monochrome-friendly grayscale.
|
||||
flat = Image.new("RGBA", canvas.size, (255, 255, 255, 255))
|
||||
flat.paste(canvas, mask=canvas.split()[3])
|
||||
return flat.convert("L")
|
||||
|
||||
|
||||
def image_to_packed_bits(img):
|
||||
width, height = img.size
|
||||
pixels = img.tobytes()
|
||||
packed = []
|
||||
for y in range(height):
|
||||
for x in range(0, width, 8):
|
||||
out = 0
|
||||
for b in range(8):
|
||||
px = x + b
|
||||
lum = pixels[y * width + px] if px < width else 255
|
||||
# 1-bit means white/clear (not drawn), 0-bit means black/drawn.
|
||||
bit = 1 if lum >= THRESHOLD else 0
|
||||
out |= bit << (7 - b)
|
||||
packed.append(out)
|
||||
return packed
|
||||
|
||||
|
||||
def format_array(name, data):
|
||||
lines = []
|
||||
per_line = 16
|
||||
for i in range(0, len(data), per_line):
|
||||
chunk = data[i : i + per_line]
|
||||
lines.append(" " + ", ".join(f"0x{v:02X}" for v in chunk) + ",")
|
||||
body = "\n".join(lines)
|
||||
return f"static const uint8_t {name}[] = {{\n{body}\n}};\n"
|
||||
|
||||
|
||||
def ensure_svg(path, fetch):
|
||||
if path.exists():
|
||||
return
|
||||
if not fetch:
|
||||
raise FileNotFoundError(f"Missing SVG: {path}")
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
url = f"{UPSTREAM_BASE}/{path.name}"
|
||||
with urllib.request.urlopen(url) as response:
|
||||
data = response.read()
|
||||
path.write_bytes(data)
|
||||
|
||||
|
||||
def generate(svg_dir, output_path, fetch):
|
||||
arrays = []
|
||||
for symbol, filename in ICON_SOURCES.items():
|
||||
svg_path = svg_dir / filename
|
||||
ensure_svg(svg_path, fetch)
|
||||
svg_data = svg_path.read_bytes()
|
||||
img = render_svg_contain(svg_data, SIZE, SIZE)
|
||||
packed = image_to_packed_bits(img)
|
||||
arrays.append(format_array(symbol, packed))
|
||||
|
||||
header = [
|
||||
"#pragma once",
|
||||
"#include <cstdint>",
|
||||
"",
|
||||
"// Generated from erikflowers/weather-icons SVGs.",
|
||||
"// 48x48, 1-bit, MSB-first, row-major.",
|
||||
"// Regenerate with: python scripts/generate_weather_icons.py --fetch",
|
||||
"",
|
||||
]
|
||||
header.extend(arrays)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text("\n".join(header) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate WeatherIcons48.h from SVG files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--svg-dir",
|
||||
type=Path,
|
||||
default=DEFAULT_SVG_DIR,
|
||||
help="Directory containing source SVG files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", type=Path, default=DEFAULT_OUT, help="Output header path"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fetch", action="store_true", help="Fetch missing SVG files from upstream"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
generate(args.svg_dir, args.output, args.fetch)
|
||||
rel_out = os.path.relpath(args.output, PROJECT_ROOT)
|
||||
print(f"Wrote {rel_out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user