"""Offline build of two independent bus fonts from Unicode bitmap masters.""" from __future__ import annotations import argparse import hashlib import json from pathlib import Path from fontTools.fontBuilder import FontBuilder from fontTools.pens.ttGlyphPen import TTGlyphPen from bus_font_design import parse_bdf, generate_large, improve_small ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / "font_sources/walnut-bus-8x16" OUTPUT = ROOT / "app/fonts/bundled" LARGE_SOURCE = ROOT / "font_sources/walnut-bus-16x32" OLD_DIGEST = "d38bf29d6408a6f731865a1ca760a80b5156ebf6ab96d5ad8d649df28163564c" def gb2312_codepoints() -> set[int]: result = set(range(32, 127)) for first in range(0xA1, 0xF8): for second in range(0xA1, 0xFF): try: cp = ord(bytes((first, second)).decode("gb2312")) if 0x4E00 <= cp <= 0x9FFF: result.add(cp) except UnicodeDecodeError: pass result.update(map(ord, " ,。!?;:、‘’“”()《》【】…—¥℃→←↑↓")) return result def elongate(rows: list[int]) -> list[int]: """Keep horizontal stems thin; extend vertical/diagonal stems between rows.""" output = [0] * 16 for y, row in enumerate(rows): output[2 * y] = row if y < 7: following = rows[y + 1] isolated = row & ~((row << 1) | (row >> 1)) output[2 * y + 1] = (row & following) | (isolated & ((following << 1) | (following >> 1))) return output def seed() -> None: upstream = SOURCE / "upstream/fusion-pixel-8px-monospaced-zh_hans.bdf" original = parse_bdf(upstream) required = gb2312_codepoints() missing = sorted(required - original.keys()) if missing: raise ValueError(f"upstream missing {len(missing)} target codepoints: {missing[:12]}") glyphs = {f"{cp:04X}": [f"{row:02X}" for row in elongate(original[cp])] for cp in sorted(required)} (SOURCE / "glyphs.json").write_text(json.dumps(glyphs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def build_face(source: Path, width: int, height: int, output: Path) -> dict: masters = json.loads((source / "glyphs.json").read_text(encoding="utf-8")) overrides = json.loads((source / "revisions.json").read_text(encoding="utf-8")) for key, revision in overrides.items(): masters[key] = revision["rows"] required = gb2312_codepoints() if not required <= {int(key, 16) for key in masters}: raise ValueError("incomplete GB2312/ASCII masters") builder = FontBuilder(1024, isTTF=True) order = [".notdef"] + [f"uni{key}" for key in sorted(masters)] builder.setupGlyphOrder(order) glyphs = {} for name in order: pen = TTGlyphPen(None) rows = [0] * height if name == ".notdef" else [int(row, 16) for row in masters[name[3:]]] if len(rows) != height or any(row < 0 or row >= 1 << width for row in rows): raise ValueError(f"invalid bitmap {name}") # Cancel shared cell edges, then trace the union boundary. Separate # touching rectangles produce 192-alpha seams in FreeType even on-grid. edges = set() for y, row in enumerate(rows): for x in range(width): if not row & (1 << (width - 1 - x)): continue step = 1024 // height left, right = x * step, (x + 1) * step bottom, top = (height - 1 - y) * step, (height - y) * step points = [(left, bottom), (left, top), (right, top), (right, bottom)] for a, b in zip(points, points[1:] + points[:1]): if (b, a) in edges: edges.remove((b, a)) else: edges.add((a, b)) outgoing = {} for a, b in edges: outgoing.setdefault(a, set()).add(b) while edges: a, b = min(edges) edges.remove((a, b)) outgoing[a].remove(b) start = a pen.moveTo(a) while b != start: pen.lineTo(b) candidates = [(b, end) for end in sorted(outgoing[b])] if not candidates: raise ValueError(f"open outline {name}") dx, dy = b[0] - a[0], b[1] - a[1] def turn(edge): nx, ny = edge[1][0] - b[0], edge[1][1] - b[1] cross = dx * ny - dy * nx return 0 if cross < 0 else 1 if dx * nx + dy * ny > 0 else 2 a, b = min(candidates, key=turn) edges.remove((a, b)) outgoing[a].remove(b) pen.closePath() glyphs[name] = pen.glyph() builder.setupGlyf(glyphs) builder.setupHorizontalMetrics({name: (512, getattr(glyphs[name], "xMin", 0)) for name in order}) builder.setupHorizontalHeader(ascent=1024, descent=0, lineGap=0) builder.setupCharacterMap({int(key, 16): f"uni{key}" for key in masters}) builder.setupNameTable({ "familyName": f"类似公交窄体(推荐{height}px)", "styleName": "Regular", "uniqueFontIdentifier": f"SimilarBus{width}x{height}-2.0", "fullName": f"类似公交窄体(推荐{height}px)", "psName": f"SimilarBus{width}x{height}-Regular", "version": "Version 2.000", "copyright": ("Derived from GNU Unifont 18.0.01: Copyright (C) 1998-2026 Roman Czyborra, Paul Hardy, Qianqian Fang, Andrew Miller, Johnnie Weaver, David Corbett, Aella Chiana Moskopp, Rebecca Bettencourt, Minseo Lee, Ho-Seok Ee, et al.; Latin skeletons derived from project bus font." if height == 32 else "Derived from Fusion Pixel Font; see accompanying OFL and upstream licenses."), "licenseDescription": "SIL Open Font License, Version 1.1", "licenseInfoURL": "https://openfontlicense.org/", }) builder.setupOS2(sTypoAscender=1024, sTypoDescender=0, sTypoLineGap=0, usWinAscent=1024, usWinDescent=0, fsType=0) builder.setupPost() builder.setupMaxp() builder.font.recalcTimestamp = False builder.font["head"].created = builder.font["head"].modified = 3864153600 output.mkdir(parents=True, exist_ok=True) target = output / f"SimilarBus{width}x{height}.ttf" builder.save(target) digest = hashlib.sha256(target.read_bytes()).hexdigest() manifest = {"id": f"builtin:walnut-bus-{width}x{height}", "file": target.name, "sha256": digest, "label": f"类似公交窄体(推荐{height}px)", "family": f"类似公交窄体(推荐{height}px)", "style": "Regular", "glyph_count": len(masters), "recommended_size": height, "cell": [width, height]} return manifest def build(output: Path = OUTPUT) -> dict: fonts = [build_face(SOURCE, 8, 16, output), build_face(LARGE_SOURCE, 16, 32, output)] manifest = {"version": 2, "fonts": fonts, "obsolete": [OLD_DIGEST], "aliases": {f"user:{OLD_DIGEST}:0": fonts[0]["id"]}} (output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return manifest if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--seed", action="store_true", help="regenerate initial masters (discards master edits)") parser.add_argument("--design", action="store_true", help="generate structure-specific masters/overrides for both faces") parser.add_argument("--output", type=Path, default=OUTPUT) args = parser.parse_args() if args.seed: seed() if args.design: improve_small(SOURCE) generate_large(LARGE_SOURCE, SOURCE, gb2312_codepoints()) print(json.dumps(build(args.output), ensure_ascii=False))