Add bundled bus fonts and shared font favorites

This commit is contained in:
2026-10-12 15:00:40 +08:00
parent c9dd03c23b
commit 4be74a34f9
181 changed files with 2078575 additions and 76 deletions
@@ -0,0 +1,162 @@
"""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))