Merge branch 'feat-weather' of https://github.com/jpirnay/crosspoint-reader into mybuild

This commit is contained in:
jpirnay
2026-04-02 18:37:55 +02:00
35 changed files with 3284 additions and 28 deletions
+103 -24
View File
@@ -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()
+280
View File
@@ -0,0 +1,280 @@
#!/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 = 64
# Higher threshold slightly thickens dark icon strokes after antialiasing.
THRESHOLD = 160
# Keep zero margin so rendered glyphs can use the full 48x48 canvas.
CONTENT_MARGIN = 0
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",
"// clang-format off",
"",
]
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()