mirror of
git://f0xx.org/ac/ac-be-hub
synced 2026-07-29 04:18:53 +03:00
197 lines
5.8 KiB
Python
197 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Vectorize LCD TTF fonts into SVG symbol sets for the hub clock widget."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from fontTools.pens.boundsPen import BoundsPen
|
|
from fontTools.pens.svgPathPen import SVGPathPen
|
|
from fontTools.ttLib import TTFont
|
|
|
|
CHARS = (
|
|
[" "]
|
|
+ [str(d) for d in range(10)]
|
|
+ [chr(c) for c in range(ord("A"), ord("Z") + 1)]
|
|
+ [":", "+", "-", "/", "."]
|
|
)
|
|
|
|
TARGET_HEIGHT = 32.0
|
|
PADDING_X = 1.5
|
|
PADDING_Y = 1.0
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FontSpec:
|
|
ttf: str
|
|
prefix: str
|
|
outfile: str
|
|
comment: str
|
|
|
|
|
|
FONTS: dict[str, FontSpec] = {
|
|
"dseg14-classic": FontSpec(
|
|
ttf="DSEG14Classic-Regular.ttf",
|
|
prefix="d14c",
|
|
outfile="dseg14-classic-alphabet.svg",
|
|
comment="DSEG14 Classic (OFL) — vectorized for hub clock",
|
|
),
|
|
"dseg14-modern": FontSpec(
|
|
ttf="DSEG14Modern-Regular.ttf",
|
|
prefix="d14m",
|
|
outfile="dseg14-modern-alphabet.svg",
|
|
comment="DSEG14 Modern (OFL) — vectorized for hub clock",
|
|
),
|
|
"ds-digital": FontSpec(
|
|
ttf="DS-Digital.ttf",
|
|
prefix="dsd",
|
|
outfile="ds-digital-alphabet.svg",
|
|
comment="DS-Digital by Dusit Supasawat (shareware) — vectorized for hub clock",
|
|
),
|
|
"digital-7-mono-italic": FontSpec(
|
|
ttf="digital-7-mono-italic.ttf",
|
|
prefix="d7mi",
|
|
outfile="digital-7-mono-italic-alphabet.svg",
|
|
comment="Digital-7 Mono Italic by Style-7 (personal use) — vectorized for hub clock",
|
|
),
|
|
}
|
|
|
|
|
|
def symbol_id(prefix: str, ch: str) -> str:
|
|
if ch == " ":
|
|
return f"{prefix}-space"
|
|
if ch == ":":
|
|
return f"{prefix}-colon"
|
|
if ch == "+":
|
|
return f"{prefix}-plus"
|
|
if ch == "-":
|
|
return f"{prefix}-minus"
|
|
if ch == "/":
|
|
return f"{prefix}-slash"
|
|
if ch == ".":
|
|
return f"{prefix}-dot"
|
|
if len(ch) == 1 and "0" <= ch <= "9":
|
|
return f"{prefix}-{ch}"
|
|
if len(ch) == 1 and "A" <= ch <= "Z":
|
|
return f"{prefix}-{ch}"
|
|
return f"{prefix}-u{ord(ch):04x}"
|
|
|
|
|
|
def glyph_path(font: TTFont, ch: str) -> tuple[str, float, float, float, float] | None:
|
|
cmap = font.getBestCmap()
|
|
code = ord(ch)
|
|
if code not in cmap:
|
|
return None
|
|
glyph_name = cmap[code]
|
|
glyph_set = font.getGlyphSet()
|
|
if glyph_name not in glyph_set:
|
|
return None
|
|
glyph = glyph_set[glyph_name]
|
|
|
|
pen = SVGPathPen(glyph_set)
|
|
bounds_pen = BoundsPen(glyph_set)
|
|
glyph.draw(bounds_pen)
|
|
if bounds_pen.bounds is None:
|
|
return None
|
|
x_min, y_min, x_max, y_max = bounds_pen.bounds
|
|
glyph.draw(pen)
|
|
path = pen.getCommands()
|
|
if not path.strip():
|
|
return None
|
|
return path, x_min, y_min, x_max, y_max
|
|
|
|
|
|
def char_width(font: TTFont, ch: str, fallback: float) -> float:
|
|
cmap = font.getBestCmap()
|
|
code = ord(ch)
|
|
if code not in cmap:
|
|
return fallback
|
|
glyph_name = cmap[code]
|
|
if glyph_name in font["hmtx"].metrics:
|
|
adv, _ = font["hmtx"].metrics[glyph_name]
|
|
return float(adv)
|
|
return fallback
|
|
|
|
|
|
def build_alphabet(spec: FontSpec, fonts_dir: Path, out_dir: Path) -> None:
|
|
ttf_path = fonts_dir / spec.ttf
|
|
font = TTFont(ttf_path)
|
|
units_per_em = float(font["head"].unitsPerEm or 1000)
|
|
|
|
lines = [
|
|
'<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" '
|
|
'style="position:absolute;width:0;height:0;overflow:hidden">',
|
|
f" <!-- {spec.comment} -->",
|
|
" <defs>",
|
|
]
|
|
|
|
for ch in CHARS:
|
|
sid = symbol_id(spec.prefix, ch)
|
|
if ch == " ":
|
|
width = max(8.0, TARGET_HEIGHT * 0.35)
|
|
lines.append(
|
|
f' <symbol id="{sid}" viewBox="0 0 {width:.2f} {TARGET_HEIGHT:.2f}"></symbol>'
|
|
)
|
|
continue
|
|
|
|
parsed = glyph_path(font, ch)
|
|
if parsed is None and ch.isalpha():
|
|
parsed = glyph_path(font, ch.lower())
|
|
if parsed is None:
|
|
width = char_width(font, "0", 20.0) * (TARGET_HEIGHT / units_per_em)
|
|
lines.append(
|
|
f' <symbol id="{sid}" viewBox="0 0 {width:.2f} {TARGET_HEIGHT:.2f}"></symbol>'
|
|
)
|
|
continue
|
|
|
|
path, x_min, y_min, x_max, y_max = parsed
|
|
glyph_w = max(x_max - x_min, 1.0)
|
|
glyph_h = max(y_max - y_min, 1.0)
|
|
scale = (TARGET_HEIGHT - PADDING_Y * 2) / glyph_h
|
|
tx = PADDING_X - x_min * scale
|
|
ty = TARGET_HEIGHT - PADDING_Y - y_max * scale
|
|
width = max(glyph_w * scale + PADDING_X * 2, char_width(font, ch, glyph_w) * scale * 0.55)
|
|
|
|
# Flip Y: font coords are bottom-up; SVG is top-down.
|
|
transform = f"translate({tx:.3f} {ty:.3f}) scale({scale:.6f} {scale:.6f})"
|
|
lines.append(f' <symbol id="{sid}" viewBox="0 0 {width:.2f} {TARGET_HEIGHT:.2f}">')
|
|
lines.append(f' <path class="lcd-active" d="{path}" transform="{transform}" />')
|
|
lines.append(" </symbol>")
|
|
|
|
lines.extend([" </defs>", "</svg>", ""])
|
|
out_path = out_dir / spec.outfile
|
|
out_path.write_text("\n".join(lines), encoding="utf-8")
|
|
print(f"Wrote {out_path}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--fonts-dir",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parent / "fonts",
|
|
)
|
|
parser.add_argument(
|
|
"--out-dir",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parent,
|
|
)
|
|
parser.add_argument(
|
|
"--only",
|
|
choices=sorted(FONTS.keys()),
|
|
action="append",
|
|
help="Build one font (repeatable). Default: all.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
selected = args.only or sorted(FONTS.keys())
|
|
for key in selected:
|
|
build_alphabet(FONTS[key], args.fonts_dir, args.out_dir)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|