#!/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()