#!/usr/bin/env python3
"""Build the 192x144 lossless JPEG XL flag pack from flag-icons."""
import argparse
import concurrent.futures
import json
import math
import shutil
import subprocess
import tempfile
import urllib.request
import zipfile
from pathlib import Path
from PIL import Image
from generate_art_links import encode_url, make_art
WIDTH = 192
HEIGHT = 144
COLUMNS = 16
SOURCE_REF = "086f7e97d657358203916dbe84f61c2bccaa81eb"
SOURCE_URL = f"https://github.com/lipis/flag-icons/archive/{SOURCE_REF}.zip"
def run(command, **kwargs):
return subprocess.run(command, check=True, **kwargs)
def find_program(candidates):
for candidate in candidates:
path = shutil.which(candidate)
if path:
return path
raise RuntimeError(f"required program not found: {' or '.join(candidates)}")
def source_tree(source_dir, workspace):
if source_dir:
return source_dir.resolve()
archive = workspace / "flag-icons.zip"
urllib.request.urlretrieve(SOURCE_URL, archive)
with zipfile.ZipFile(archive) as source_zip:
source_zip.extractall(workspace / "source")
matches = list((workspace / "source").glob("flag-icons-*"))
if len(matches) != 1:
raise RuntimeError("could not locate the extracted flag-icons tree")
return matches[0]
def render_sheet(chromium, source, items, workspace):
rows = math.ceil(len(items) / COLUMNS)
tags = "".join(
f'
'
for item in items
)
document = f"""
{tags}
"""
sheet_html = workspace / "sheet.html"
sheet_png = workspace / "sheet.png"
sheet_html.write_text(document)
run(
[
chromium,
"--headless",
"--no-sandbox",
"--disable-gpu",
"--hide-scrollbars",
"--allow-file-access-from-files",
"--default-background-color=00000000",
"--force-device-scale-factor=1",
f"--window-size={COLUMNS * WIDTH},{rows * HEIGHT + 87}",
f"--screenshot={sheet_png}",
sheet_html.as_uri(),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
sheet = Image.open(sheet_png).convert("RGBA")
required_size = (COLUMNS * WIDTH, rows * HEIGHT)
if sheet.width < required_size[0] or sheet.height < required_size[1]:
raise RuntimeError(f"rendered sheet is {sheet.size}, need at least {required_size}")
return sheet
def encode_flag(cjxl, png, output):
run(
[cjxl, str(png), str(output), "--distance=0", "--effort=9"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def validate_art_source(jxl_from_tree, tree, output):
run(
[jxl_from_tree, str(tree), str(output)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def palette_icon(image):
alpha = image.getchannel("A")
icon = image.convert("RGB").quantize(
colors=16,
method=Image.Quantize.FASTOCTREE,
dither=Image.Dither.NONE,
).convert("RGBA")
icon.putalpha(alpha)
if alpha.getextrema() == (255, 255):
return icon.convert("RGB")
return icon
def verify_flag(djxl, expected_png, jxl, decoded_png):
run(
[djxl, str(jxl), str(decoded_png)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
expected = Image.open(expected_png).convert("RGBA")
actual = Image.open(decoded_png).convert("RGBA")
if expected.size != actual.size or expected.tobytes() != actual.tobytes():
raise RuntimeError(f"lossless verification failed for {jxl.name}")
def create_archive(root, manifest, source_license):
archive_path = root / "jxl-flags-192x144.zip"
readme = """JPEG XL flags, 192x144
===========================
271 lossless 4:3 JPEG XL flag icons: 249 ISO entries and 22 regional,
organization, or special entries. See manifest.json for names and byte sizes.
Source artwork: flag-icons 7.5.0
https://flagicons.lipis.dev/
https://github.com/lipis/flag-icons
The source artwork is MIT licensed; see LICENSE-flag-icons.txt.
Each flag keeps the full 192x144 source layout with a 16-colour palette and is
encoded losslessly in JPEG XL modular mode. The site manifest also includes an
editable, reduced-grid jxl-art DSL sketch for every flag.
"""
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_STORED) as pack:
pack.writestr("jxl-flags-192x144/README.txt", readme)
pack.writestr("jxl-flags-192x144/LICENSE-flag-icons.txt", source_license)
pack_manifest = [
{key: value for key, value in item.items() if key not in {"art", "artCodeBytes"}}
for item in manifest
]
pack.writestr(
"jxl-flags-192x144/manifest.json",
json.dumps(pack_manifest, ensure_ascii=True, indent=2) + "\n",
compress_type=zipfile.ZIP_DEFLATED,
compresslevel=9,
)
for item in manifest:
path = root / "flags" / f"{item['code']}.jxl"
pack.write(path, f"jxl-flags-192x144/flags/{path.name}")
return archive_path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source-dir", type=Path, help="local flag-icons checkout")
parser.add_argument("--output-dir", type=Path, default=Path(__file__).parent)
parser.add_argument("--jxl-from-tree", type=Path, help="path to jxl_from_tree")
parser.add_argument("--jobs", type=int, default=4)
args = parser.parse_args()
chromium = find_program(["chromium", "chromium-browser", "google-chrome"])
cjxl = find_program(["cjxl"])
jxl_from_tree = (
str(args.jxl_from_tree.resolve())
if args.jxl_from_tree
else find_program(["jxl_from_tree"])
)
djxl = find_program(["djxl"])
root = args.output_dir.resolve()
flags_dir = root / "flags"
flags_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="jxl-flags-") as temporary:
workspace = Path(temporary)
source = source_tree(args.source_dir, workspace)
items = json.loads((source / "country.json").read_text())
if len(items) != 271 or sum(bool(item["iso"]) for item in items) != 249:
raise RuntimeError("unexpected flag-icons inventory")
sheet = render_sheet(chromium, source, items, workspace)
tree_dir = workspace / "tree"
expected_dir = workspace / "expected"
decoded_dir = workspace / "decoded"
art_validation_dir = workspace / "art-validation"
tree_dir.mkdir()
expected_dir.mkdir()
decoded_dir.mkdir()
art_validation_dir.mkdir()
art_sources = {}
art_overrides = set()
overrides_dir = Path(__file__).parent / "art-overrides"
for index, item in enumerate(items):
x = (index % COLUMNS) * WIDTH
y = (index // COLUMNS) * HEIGHT
tile = sheet.crop((x, y, x + WIDTH, y + HEIGHT))
if not tile.getchannel("A").getbbox():
raise RuntimeError(f"empty render for {item['code']}")
generated_source, _ = make_art(tile)
code = item["code"]
override = overrides_dir / f"{code}.tree"
art_source = override.read_text().strip() if override.exists() else generated_source
if override.exists():
art_overrides.add(code)
art_sources[code] = art_source
(tree_dir / f"{code}.tree").write_text(art_source + "\n")
palette_icon(tile).save(expected_dir / f"{code}.png", optimize=True)
def encode(item):
code = item["code"]
encode_flag(
cjxl,
expected_dir / f"{code}.png",
flags_dir / f"{code}.jxl",
)
if code not in art_overrides:
validate_art_source(
jxl_from_tree,
tree_dir / f"{code}.tree",
art_validation_dir / f"{code}.jxl",
)
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
list(pool.map(encode, items))
def verify(item):
code = item["code"]
verify_flag(
djxl,
expected_dir / f"{code}.png",
flags_dir / f"{code}.jxl",
decoded_dir / f"{code}.png",
)
with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
list(pool.map(verify, items))
manifest = []
for item in items:
code = item["code"]
continent = item.get("continent") or (
"Antarctica" if item["iso"] else "Other"
)
manifest.append(
{
"code": code,
"name": item["name"],
"continent": continent,
"iso": bool(item["iso"]),
"bytes": (flags_dir / f"{code}.jxl").stat().st_size,
"mode": "palette-residual",
"art": encode_url(art_sources[code]),
"artCodeBytes": len(art_sources[code].encode()),
}
)
(root / "manifest.json").write_text(
json.dumps(manifest, ensure_ascii=True, separators=(",", ":")) + "\n"
)
source_license = (source / "LICENSE").read_text()
(root / "LICENSE-flag-icons.txt").write_text(source_license)
archive = create_archive(root, manifest, source_license)
total = sum(item["bytes"] for item in manifest)
print(f"built and verified {len(manifest)} flags ({total:,} bytes)")
print(archive)
if __name__ == "__main__":
main()