kinda/scripts/generate-fonts.py

206 lines
6.3 KiB
Python

# Generate embedded 1-bit bitmap fonts from a TrueType/OpenType font.
#
# FontForge's direct monochrome XBM export is too thin for this display, especially
# at footer sizes. This exports antialiased BMP glyphs and packs 4-bit alpha.
#
# Run with FontForge, not python3:
# fontforge -lang=py -script scripts/generate-fonts.py \
# '/Volumes/Drawer/Fonts/iAFonts/iA Writer Quattro/Static/iAWriterQuattroS-Regular.ttf' \
# src/ui/generated_fonts.rs
import os
import struct
import sys
import tempfile
import fontforge
SIZES = [
("QUATTRO_SMALL", 24),
("QUATTRO_BODY", 30),
("QUATTRO_READER", 34),
("QUATTRO_TITLE", 38),
]
CODEPOINTS = list(range(32, 127)) + [
0x00A0, # no-break space
0x2018, # left single quotation mark
0x2019, # right single quotation mark
0x201C, # left double quotation mark
0x201D, # right double quotation mark
0x2013, # en dash
0x2014, # em dash
0x2026, # ellipsis
]
def parse_bmp(path):
with open(path, "rb") as file:
data = file.read()
if data[0:2] != b"BM":
raise RuntimeError("not a BMP file: {}".format(path))
pixel_offset = struct.unpack_from("<I", data, 10)[0]
header_size = struct.unpack_from("<I", data, 14)[0]
width = struct.unpack_from("<i", data, 18)[0]
height = struct.unpack_from("<i", data, 22)[0]
planes = struct.unpack_from("<H", data, 26)[0]
bits_per_pixel = struct.unpack_from("<H", data, 28)[0]
compression = struct.unpack_from("<I", data, 30)[0]
if width <= 0 or height == 0 or planes != 1 or bits_per_pixel != 8 or compression != 0:
raise RuntimeError("unsupported BMP format: {}".format(path))
palette_start = 14 + header_size
palette = []
for index in range(256):
offset = palette_start + index * 4
blue, green, red, _alpha = data[offset : offset + 4]
palette.append((int(red) + int(green) + int(blue)) // 3)
top_down = height < 0
height = abs(height)
bmp_row_bytes = ((width * bits_per_pixel + 31) // 32) * 4
packed = [0] * ((width * height + 1) // 2)
for row in range(height):
source_row = row if top_down else height - 1 - row
source_offset = pixel_offset + source_row * bmp_row_bytes
for column in range(width):
palette_index = data[source_offset + column]
alpha = (255 - palette[palette_index] + 8) // 17
alpha = min(15, max(0, alpha))
pixel_index = row * width + column
byte_index = pixel_index // 2
if pixel_index % 2 == 0:
packed[byte_index] |= alpha << 4
else:
packed[byte_index] |= alpha
return width, height, packed
def has_codepoint(font, codepoint):
try:
glyph = font[codepoint]
except Exception:
return False
return glyph is not None and glyph.isWorthOutputting()
def rust_array(values, indent=" ", per_line=16):
lines = []
for index in range(0, len(values), per_line):
chunk = values[index : index + per_line]
lines.append(indent + ", ".join("0x{:02x}".format(value) for value in chunk) + ",")
return "\n".join(lines)
def generate_font(font, temp_dir, name, pixelsize):
glyphs = []
bitmap = []
cell_height = 0
for codepoint in CODEPOINTS:
if not has_codepoint(font, codepoint):
continue
glyph = font[codepoint]
path = os.path.join(temp_dir, "glyph_{:04x}_{}.bmp".format(codepoint, pixelsize))
glyph.export(path, pixelsize=pixelsize)
width, height, data = parse_bmp(path)
cell_height = max(cell_height, height)
glyphs.append((codepoint, width, height, len(bitmap)))
bitmap.extend(data)
if cell_height == 0:
raise RuntimeError("no glyphs generated for {}".format(name))
return {
"name": name,
"pixelsize": pixelsize,
"cell_height": cell_height,
"glyphs": glyphs,
"bitmap": bitmap,
}
def emit_font(output, generated):
name = generated["name"]
glyphs = generated["glyphs"]
bitmap = generated["bitmap"]
output.write(
"pub(crate) static {name}: BitmapFont = BitmapFont {{\n"
" cell_height: {cell_height},\n"
" glyphs: &{name}_GLYPHS,\n"
" bitmap: &{name}_BITMAP,\n"
"}};\n\n".format(name=name, cell_height=generated["cell_height"])
)
output.write("#[rustfmt::skip]\n")
output.write(
"static {name}_GLYPHS: [Glyph; {count}] = [\n".format(
name=name, count=len(glyphs)
)
)
for codepoint, width, height, offset in glyphs:
output.write(
" Glyph {{ codepoint: 0x{codepoint:04x}, width: {width}, height: {height}, bitmap_offset: {offset} }},\n".format(
codepoint=codepoint, width=width, height=height, offset=offset
)
)
output.write("];\n\n")
output.write("#[rustfmt::skip]\n")
output.write(
"static {name}_BITMAP: [u8; {count}] = [\n".format(
name=name, count=len(bitmap)
)
)
if bitmap:
output.write(rust_array(bitmap))
output.write("\n")
output.write("];\n\n")
def main():
if len(sys.argv) != 3:
print("usage: fontforge -lang=py -script scripts/generate-fonts.py FONT.ttf OUTPUT.rs", file=sys.stderr)
return 2
font_path = sys.argv[1]
output_path = sys.argv[2]
font = fontforge.open(font_path)
with tempfile.TemporaryDirectory() as temp_dir:
generated_fonts = [
generate_font(font, temp_dir, name, pixelsize) for name, pixelsize in SIZES
]
with open(output_path, "w", encoding="utf-8") as output:
output.write("// @generated by scripts/generate-fonts.py. Do not edit by hand.\n")
output.write("// Source font: {}\n\n".format(font_path))
output.write("use crate::ui::typography::{BitmapFont, Glyph};\n\n")
for generated in generated_fonts:
output.write(
"// {} px; cell height {} px; {} glyphs; {} alpha bytes.\n".format(
generated["pixelsize"],
generated["cell_height"],
len(generated["glyphs"]),
len(generated["bitmap"]),
)
)
emit_font(output, generated)
return 0
if __name__ == "__main__":
raise SystemExit(main())