1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
|
#!/usr/bin/env python3
# build: assemble the installable GRUB theme into dist/udt/.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# Licensed under the GNU General Public License v2 only.
"""Assemble dist/udt/ from the theme.txt udt-palette rendered.
Usage: build [--selftest]
Writes the PNG slices, the .pf2 fonts and a GRUB-readable copy of the
current wallpaper next to theme.txt. Everything is staged in a temporary
directory and swapped in at the end, so a failed run leaves the previous
dist/udt/ untouched. Never touches /boot: root copies dist/udt/ there.
"""
import re
import struct
import sys
import zlib
from pathlib import Path
PNG_SIG = b"\x89PNG\r\n\x1a\n"
REPO = Path(__file__).resolve().parent
PLACEHOLDERS = {f"@{r}@" for r in (
"SCHEME", "BG", "BG_ALT", "BG_DEEP", "SURFACE", "FG", "FG_DIM", "FG_FAINT", "ACCENT")}
# file name: (fc-match pattern, size, the name grub-mkfont records and theme.txt uses)
FONTS = {
"noto-sans-regular-24.pf2": ("Noto Sans:regular", 24, "Noto Sans Regular 24"),
"noto-sans-bold-32.pf2": ("Noto Sans:bold", 32, "Noto Sans Bold 32"),
"inconsolata-18.pf2": ("Inconsolata Nerd Font Mono", 18,
"Inconsolata Nerd Font Mono Regular 18"),
}
def die(msg):
sys.exit(f"build: {msg}")
def read_colours(text):
"""The colours on the '# udt:' line, as RGB tuples, from a rendered theme."""
left = sorted(set(re.findall(r"@[A-Z_]+@", text)))
if left:
die(f"theme.txt still has placeholders {' '.join(left)}; rerun udt-palette")
m = re.search(r"^# udt: (.+)$", text, re.M)
if not m:
die("theme.txt has no '# udt:' colour line")
return {k: tuple(bytes.fromhex(v.lstrip("#")))
for k, v in (kv.split("=") for kv in m.group(1).split())}
def chunk(tag, data):
return (struct.pack(">I", len(data)) + tag + data
+ struct.pack(">I", zlib.crc32(tag + data)))
def png(w, h, rgba):
"""A w x h PNG of one colour: 8-bit RGBA, not interlaced, which GRUB reads."""
row = b"\0" + bytes(rgba) * w # filter byte 0, then the pixels
return (PNG_SIG
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 6, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(row * h))
+ chunk(b"IEND", b""))
def unpng(data):
"""Decode what png() writes: (width, height, set of RGBA pixels)."""
assert data[:8] == PNG_SIG
pos, idat = 8, b""
while pos < len(data):
n, tag = struct.unpack(">I4s", data[pos:pos + 8])
body = data[pos + 8:pos + 8 + n]
assert struct.unpack(">I", data[pos + 8 + n:pos + 12 + n])[0] == zlib.crc32(tag + body)
if tag == b"IHDR":
w, h = struct.unpack(">II", body[:8])
elif tag == b"IDAT":
idat += body
pos += 12 + n
raw = zlib.decompress(idat)
stride = 1 + 4 * w
px = set()
for y in range(h):
row = raw[y * stride:(y + 1) * stride]
assert row[0] == 0
px |= {tuple(row[1 + 4 * x:5 + 4 * x]) for x in range(w)}
return w, h, px
def selftest():
w, h, px = unpng(png(3, 2, (0x12, 0x34, 0x56, 0xd9)))
assert (w, h, px) == (3, 2, {(0x12, 0x34, 0x56, 0xd9)}), (w, h, px)
good = "# udt: bg_alt=#1e2030 accent=#b7bdf8\ncolor = \"#cad3f5\"\n"
assert read_colours(good) == {"bg_alt": (0x1e, 0x20, 0x30), "accent": (0xb7, 0xbd, 0xf8)}
for bad in (good + 'color = "@FG@"\n', 'color = "#cad3f5"\n'):
try:
read_colours(bad)
except SystemExit:
pass
else:
raise AssertionError(f"accepted: {bad!r}")
tpl = (REPO / "theme.txt.in").read_text()
assert set(re.findall(r"@[A-Z_]+@", tpl)) <= PLACEHOLDERS, "template token udt does not fill"
for _, _, name in FONTS.values():
assert f'"{name}"' in tpl, f"template does not use font {name}"
print("selftest ok")
def main():
if sys.argv[1:] == ["--selftest"]:
return selftest()
if __name__ == "__main__":
main()
|