Files
matrix-screen-controller/核桃派软件源代码/scripts/build_bus_font.py
T

163 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))