Cozette/build.py
Hugo Osvaldo Barrera 3c0f4b2e95 Make Regular and Bold fonts variants
The Regular and Bold fonts have different names, so programs treat them
as different fonts. When using the font in a terminal, it cannot find a
bold variant for the regular one, and does not render bold text as such.

Set the same name for both fonts, and properly set the style of each one
so that they both form part of the font family.

Fixes: https://github.com/the-moonwitch/Cozette/issues/183
2026-02-08 14:32:22 +01:00

335 lines
10 KiB
Python

import argparse
import re
import subprocess
from dataclasses import dataclass
from pathlib import Path
from pickle import BUILD
from shlex import quote
from shutil import rmtree
from typing import Optional, Sequence, cast
from tempfile import NamedTemporaryFile
import crayons # type: ignore
from cozette_builder.changeloggen import get_changelog, get_last_ver
from cozette_builder.hidpi import double_size
from cozette_builder.imagegen import (
add_margins,
read_sample,
save_charlist,
save_sample,
)
from cozette_builder.scanner import (
find_missing_codepoints,
print_codepoints_for_changelog,
scan_for_codepoints,
)
REPO_ROOT = Path(__file__).resolve().parent
BUILD_DIR = REPO_ROOT / "build"
FONTNAME = "Cozette"
SFDPATH = REPO_ROOT / "Cozette" / "Cozette.sfd"
@dataclass
class Generate:
filename: str
bitmap_fmt: Optional[str] = None
def __str__(self):
return (
f'Generate("{self.filename}", "{self.bitmap_fmt}")'
if self.bitmap_fmt
else f'Generate("{self.filename}")'
)
def save_images():
print(crayons.yellow("Saving character map"))
save_charlist(FONTNAME, SFDPATH, REPO_ROOT / "img")
print(crayons.yellow("Saving sample image"))
save_sample(
FONTNAME,
read_sample(REPO_ROOT / "img" / "sample.txt"),
REPO_ROOT / "img" / "sample.png",
)
add_margins(REPO_ROOT / "img" / "sample.png")
def fontforge(open: Path, generate: Sequence[Generate]):
BUILD_DIR.mkdir(exist_ok=True)
script = "; ".join(
[
f'Open("{open}")',
# 'RenameGlyphs("AGL with PUA")',
# 'Reencode("unicode")',
]
+ [str(gen) for gen in generate]
)
# No idea why this doesn't work without shell=True
subprocess.run(
[f"fontforge -lang ff -c {quote(script)}"], cwd=BUILD_DIR, shell=True
)
def rename_single(dir: Path, pattern: str, newname: str) -> Path:
return cast(Path, next(dir.glob(pattern)).rename(dir / newname))
def gen_bitmap_formats(sfd_path: Path, prefix: str) -> Path:
fontforge(
open=sfd_path,
generate=[
Generate(f"{prefix}.", bitmap_fmt="bdf"),
],
)
bdf_path = rename_single(BUILD_DIR, f"{prefix}-*.bdf", f"{prefix}.bdf")
fontforge(
open=sfd_path,
generate=[
Generate(f"{prefix}.", "otb"),
Generate(f"{prefix}.", "psf"),
Generate(f"{prefix}.", "fnt"),
Generate(f"{prefix}.dfont", "sbit"),
],
)
rename_single(BUILD_DIR, f"{prefix}-*.fnt", f"{prefix}.fnt")
return bdf_path
def fix_ttf(ttfpath: Path, name: str):
print(crayons.yellow(f"Generating TTF for {name}..."))
version = "1.0"
with SFDPATH.open() as f:
for line in f.readlines():
if line.startswith("Version "):
version = line.split()[1]
break
if name.endswith("Bold"):
family_name = name.removesuffix("Bold")
style_name = "Bold"
weight = 700
else:
family_name = name
style_name = "Regular"
weight = 400
with NamedTemporaryFile() as sfd:
subprocess.run(
[
f"fontforge -c '"
f'f = open("{ttfpath}"); '
f"f.os2_version = 4; "
f"f.os2_weight_width_slope_only = True; "
f'f.save("{sfd.name}")\''
],
cwd=BUILD_DIR,
shell=True,
check=True,
)
script = ";\n".join(
[
f'Open("{sfd.name}")',
"SelectWorthOutputting()",
"RemoveOverlap()",
"CorrectDirection()",
"ScaleToEm(2048)",
'RenameGlyphs("AGL with PUA")',
'Reencode("unicode")',
f'SetTTFName(0x409, 1, "{family_name}")',
f'SetTTFName(0x409, 2, "{style_name}")',
f'SetTTFName(0x409, 3, "{family_name} {style_name}")', # Unique font identifier
f'SetTTFName(0x409, 4, "{family_name} {style_name}")', # Full font name
f'SetTTFName(0x409, 5, "{version}")',
f'SetTTFName(0x409, 6, "{family_name}-{style_name}")', # PostScript name
f'SetTTFName(0x409, 8, "Ines <ines@moonwit.ch>")',
f'SetTTFName(0x409, 9, "Ines <ines@moonwit.ch>")',
f'SetTTFName(0x409, 11, "https://github.com/the-moonwitch/Cozette")',
f'SetTTFName(0x409, 13, LoadStringFromFile({repr(str((REPO_ROOT / "LICENSE").resolve()))}))',
'SetTTFName(0x409, 14, "https://github.com/the-moonwitch/Cozette/blob/master/LICENSE")',
f'SetOS2Value("Weight", {weight})',
f'Generate("{name}.dfont")',
f'Generate("{name}.otf")',
f'Generate("{name}.ttf")',
f'Generate("{name}.woff")',
f'Generate("{name}.woff2")',
]
)
with NamedTemporaryFile(mode="w+", suffix=".pe") as f:
print(f.name)
f.write(script)
f.flush()
f.seek(0)
subprocess.run(
[f"fontforge -script {f.name}"],
cwd=BUILD_DIR,
shell=True,
check=True,
)
# No idea why this doesn't work without shell=True
ttfpath.unlink()
def make_hidpi(bdf_path: Path, out_path: Path):
print(crayons.yellow("Generating hidpi font..."))
with bdf_path.open() as i:
with out_path.open("w") as o:
double_size(i, o)
print(crayons.green("Done!"))
BDF_CODEPOINT_RE = re.compile(r"BDFChar: \S+ (\d+)")
def variant(
sfd_path: Path, variant_name: str, variant_source: Path
) -> tuple[Path, str]:
outpath = BUILD_DIR / f"{variant_name}.sfd"
replacements = {}
with variant_source.open() as f:
for line in f:
if codepoint := BDF_CODEPOINT_RE.match(line):
replacements[int(codepoint.group(1))] = next(f)
with outpath.open("w") as f:
with sfd_path.open() as src:
replacement = None
for line in src:
if replacement is not None:
f.write(replacement)
replacement = None
elif line.startswith("FAMILY_NAME"):
f.write(line)
else:
f.write(line.replace("Cozette", variant_name))
if codepoint := BDF_CODEPOINT_RE.match(line):
if int(codepoint.group(1)) in replacements:
replacement = replacements[int(codepoint.group(1))]
return (outpath, variant_name)
def gen_versions(bdf_path: Path, font_name: str, filename_prefix: str):
hidpi_path = BUILD_DIR / f"{filename_prefix}_hidpi.bdf"
def bnp_invoc_ttf(name: str, format: str):
return [
REPO_ROOT / "bitsnpicas.sh",
"convertbitmap",
"-f",
format,
"-o",
BUILD_DIR / f"{name}_tmp.{format}",
"-s",
"Cozette",
"-r",
name,
"-T",
]
subprocess.run(
[
BUILD_DIR.parent / "bitsnpicas.sh",
"convertbitmap",
"-f",
"psf",
"-o",
BUILD_DIR / f"{filename_prefix}.psf",
bdf_path,
],
check=True,
)
subprocess.run(
bnp_invoc_ttf(f"{font_name}Vector", "ttf") + [bdf_path], check=True
)
subprocess.run(
bnp_invoc_ttf(f"{font_name}VectorBold", "ttf") + ["-b", bdf_path],
check=True,
)
print(crayons.yellow("Fixing TTF variants..."))
fix_ttf(BUILD_DIR / f"{font_name}Vector_tmp.ttf", f"{font_name}Vector")
fix_ttf(
BUILD_DIR / f"{font_name}VectorBold_tmp.ttf", f"{font_name}VectorBold"
)
print(crayons.green("Done!"))
make_hidpi(bdf_path, hidpi_path)
fontforge(
open=hidpi_path,
generate=[
Generate(f"{hidpi_path.stem}.", "otb"),
Generate(f"{hidpi_path.stem}.", "fnt"),
Generate(f"{hidpi_path.stem}.dfont", "sbit"),
],
)
rename_single(BUILD_DIR, "*-26.fnt", f"{filename_prefix}_hidpi.fnt")
subprocess.run(
[
BUILD_DIR.parent / "bitsnpicas.sh",
"convertbitmap",
"-f",
"psf",
"-o",
hidpi_path.with_suffix(".psf"),
hidpi_path,
],
check=True,
)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="action")
images = subparsers.add_parser("images")
changelog = subparsers.add_parser("changelog")
fonts = subparsers.add_parser("fonts")
scan = subparsers.add_parser("scan")
# noinspection PyTypeChecker
scan.add_argument("path", type=Path) # type: ignore
scan.add_argument("-s", "--print-source-files", action="store_true")
scan.add_argument("-r", "--reverse", action="store_true")
args = parser.parse_args()
if args.action == "scan":
missing_codepoints = find_missing_codepoints(
SFDPATH,
scan_for_codepoints(args.path),
)
if missing_codepoints:
print_codepoints_for_changelog(
missing_codepoints,
print_source_files=args.print_source_files,
reverse=args.reverse,
)
else:
print(
crayons.green(
f"All codepoints under {args.path} already "
f"supported by Cozette."
)
)
if args.action == "images":
print(crayons.blue("Saving sample images..."))
save_images()
print(crayons.green("Done!", bold=True))
elif args.action == "fonts":
rmtree(BUILD_DIR, ignore_errors=True)
BUILD_DIR.mkdir(exist_ok=True)
for sfd_path, font_name in (
(SFDPATH, "Cozette"),
variant(
SFDPATH,
"CozetteCrossedSeven",
SFDPATH.parent / "variants" / "CozetteCrossedSeven.sfd",
),
):
print(crayons.blue(f"Building bitmap formats for {font_name}..."))
bdf_path = gen_bitmap_formats(sfd_path, font_name.lower())
print(crayons.green("Done!", bold=True))
print(crayons.blue(f"Building versions for {font_name}..."))
gen_versions(bdf_path, font_name, font_name.lower())
print(crayons.green("Done!", bold=True))
elif args.action == "changelog":
get_changelog()
else:
parser.print_usage()