From f15b1d06bfbbfe7aa9d2f4a2d1fdd47ab886b56b Mon Sep 17 00:00:00 2001 From: jpirnay Date: Tue, 5 May 2026 17:35:13 +0200 Subject: [PATCH] Fix static font generation from variable fonts --- lib/EpdFont/scripts/build-sd-fonts.py | 8 +- lib/EpdFont/scripts/fontconvert_sdcard.py | 156 +++++++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/lib/EpdFont/scripts/build-sd-fonts.py b/lib/EpdFont/scripts/build-sd-fonts.py index ec29a763..c07fcd22 100644 --- a/lib/EpdFont/scripts/build-sd-fonts.py +++ b/lib/EpdFont/scripts/build-sd-fonts.py @@ -82,7 +82,13 @@ def extract_static_instance(source_path: Path, axes: dict, family_name: str, sty print(f" Extracting static instance: {family_name}/{style_name} ({axis_key})") font = TTFont(str(source_path)) - instantiateVariableFont(font, axes) + font = instantiateVariableFont( + font, + axes, + static=True, + updateFontNames=True, + optimize=False, + ) font.save(str(cached)) font.close() diff --git a/lib/EpdFont/scripts/fontconvert_sdcard.py b/lib/EpdFont/scripts/fontconvert_sdcard.py index e2c8257a..cc2d1394 100644 --- a/lib/EpdFont/scripts/fontconvert_sdcard.py +++ b/lib/EpdFont/scripts/fontconvert_sdcard.py @@ -23,15 +23,22 @@ Usage: """ import freetype +import zlib import struct import sys import os import math import argparse +import binascii from collections import namedtuple from fontTools.ttLib import TTFont +try: + from PIL import Image +except ImportError: + Image = None + # --- Unicode interval presets --- INTERVAL_PRESETS = { @@ -137,6 +144,124 @@ def fp4_from_design_units(du, scale): return max(-128, min(127, raw)) +def write_png_gray(path, width, height, pixels): + def png_chunk(chunk_type, data): + chunk = chunk_type + data + return struct.pack("!I", len(data)) + chunk + struct.pack("!I", binascii.crc32(chunk) & 0xffffffff) + + ihdr = struct.pack("!IIBBBBB", width, height, 8, 0, 0, 0, 0) + raw_scanlines = bytearray() + for y in range(height): + raw_scanlines.append(0) + raw_scanlines.extend(pixels[y * width:(y + 1) * width]) + + with open(path, "wb") as f: + f.write(b"\x89PNG\r\n\x1a\n") + f.write(png_chunk(b"IHDR", ihdr)) + f.write(png_chunk(b"IDAT", zlib.compress(bytes(raw_scanlines), level=9))) + f.write(png_chunk(b"IEND", b"")) + + +STYLE_LABELS = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"} + + +def expand_processed_bitmap(width, height, packed, bits): + raw = bytearray(width * height) + pixels_per_byte = 8 // bits + mask = (1 << bits) - 1 + for idx in range(width * height): + byte = packed[idx // pixels_per_byte] + shift = (pixels_per_byte - 1 - (idx % pixels_per_byte)) * bits + raw[idx] = (byte >> shift) & mask + return raw + + +def pack_2bit_bitmap(width, height, pixels): + packed = bytearray(((width * height) + 3) // 4) + for idx, value in enumerate(pixels): + shift = (3 - (idx % 4)) * 2 + packed[idx // 4] |= (value & 3) << shift + return bytes(packed) + + +def dilate_2bit_bitmap(width, height, pixels): + out = bytearray(len(pixels)) + for y in range(height): + for x in range(width): + maxv = 0 + for dy in (-1, 0, 1): + ny = y + dy + if ny < 0 or ny >= height: + continue + for dx in (-1, 0, 1): + nx = x + dx + if nx < 0 or nx >= width: + continue + maxv = max(maxv, pixels[ny * width + nx]) + out[y * width + x] = maxv + return out + + +def glyph_pixels_2bit(entry): + glyph, packed = entry + return expand_processed_bitmap(glyph.width, glyph.height, packed, 2) + + +def glyph_entries_by_codepoint(sd, code_point): + return (entry for entry in sd.all_glyphs if entry[0].code_point == code_point) + + +def style_uses_synthetic_bold(base_sd, target_sd): + base_entry = next(glyph_entries_by_codepoint(base_sd, ord('A')), None) + target_entry = next(glyph_entries_by_codepoint(target_sd, ord('A')), None) + if base_entry is None or target_entry is None: + return False + base_glyph, base_packed = base_entry + target_glyph, target_packed = target_entry + if base_glyph.width != target_glyph.width or base_glyph.height != target_glyph.height: + return False + base_pixels = expand_processed_bitmap(base_glyph.width, base_glyph.height, base_packed, 2) + target_pixels = expand_processed_bitmap(target_glyph.width, target_glyph.height, target_packed, 2) + return base_pixels == target_pixels + + +def apply_synthetic_bold(sd, style_label): + print(f" Debug: synthetic bold applied for style {style_label}", file=sys.stderr) + for idx, (glyph, packed) in enumerate(sd.all_glyphs): + if glyph.width == 0 or glyph.height == 0: + continue + pixels = expand_processed_bitmap(glyph.width, glyph.height, packed, 2) + bold_pixels = dilate_2bit_bitmap(glyph.width, glyph.height, pixels) + sd.all_glyphs[idx] = (glyph, pack_2bit_bitmap(glyph.width, glyph.height, bold_pixels)) + + +def save_debug_glyph_image(output_path, raster_data): + base = os.path.splitext(output_path)[0] + found = False + for style_id, sd in raster_data.items(): + style_label = STYLE_LABELS.get(style_id, str(style_id)) + png_path = f"{base}_A_{style_label}.png" + glyph_entry = next(((g, p) for g, p in sd.all_glyphs if g.code_point == ord('A')), None) + if glyph_entry is None: + print(f" Debug: letter 'A' not found for style {style_label}", file=sys.stderr) + continue + glyph, packed = glyph_entry + if glyph.width == 0 or glyph.height == 0: + print(f" Debug: letter 'A' has empty bitmap for style {style_label}", file=sys.stderr) + continue + unpacked = expand_processed_bitmap(glyph.width, glyph.height, packed, 2) + img_pixels = bytes(255 - p * 85 for p in unpacked) + if Image: + img = Image.frombytes("L", (glyph.width, glyph.height), img_pixels) + img.save(png_path) + else: + write_png_gray(png_path, glyph.width, glyph.height, img_pixels) + print(f" Debug: saved letter 'A' bitmap to {png_path}", file=sys.stderr) + found = True + if not found: + print(f" Debug: no letter 'A' glyphs were saved for {output_path}", file=sys.stderr) + + # Standard Unicode ligature codepoints for known input sequences. # Used as a fallback when the GSUB substitute glyph has no cmap entry. STANDARD_LIGATURE_MAP = { @@ -665,7 +790,8 @@ def style_sections_total_size(sections): # --- File writers --- def generate_cpfont_multistyle(style_fonts, size, intervals, output_path, - force_autohint=False): + force_autohint=False, synthetic_bold=False, + debug_images=False): """Generate a multi-style v4 .cpfont file. style_fonts: dict of {style_id: fontfile_path} e.g. {0: "Regular.ttf", 2: "Italic.ttf"} @@ -686,6 +812,24 @@ def generate_cpfont_multistyle(style_fonts, size, intervals, output_path, fontfile, size, intervals, style_id=style_id, force_autohint=force_autohint) + if synthetic_bold: + # If a bold-style output is identical to its base style, apply a synthetic + # bold bitmap transformation so the style still appears heavier. + regular_sd = raster_data.get(0) + italic_sd = raster_data.get(2) + if regular_sd is not None and 1 in raster_data: + style_label = STYLE_LABELS.get(1, "1") + if style_uses_synthetic_bold(regular_sd, raster_data[1]): + apply_synthetic_bold(raster_data[1], style_label) + if italic_sd is not None and 3 in raster_data: + style_label = STYLE_LABELS.get(3, "3") + if style_uses_synthetic_bold(italic_sd, raster_data[3]): + apply_synthetic_bold(raster_data[3], style_label) + elif regular_sd is not None and 3 in raster_data: + style_label = STYLE_LABELS.get(3, "3") + if style_uses_synthetic_bold(regular_sd, raster_data[3]): + apply_synthetic_bold(raster_data[3], style_label) + # Pack binary sections for each style packed_sections = {} # style_id -> tuple of section bytearrays for style_id, sd in raster_data.items(): @@ -753,6 +897,8 @@ def generate_cpfont_multistyle(style_fonts, size, intervals, output_path, print(f" {sname}: {len(sd.all_glyphs)} glyphs, {len(sd.intervals)} intervals, " f"{ssize} bytes", file=sys.stderr) print(f" Total: {total_file_size} bytes ({total_file_size / 1024 / 1024:.2f} MB)", file=sys.stderr) + if debug_images: + save_debug_glyph_image(output_path, raster_data) return total_file_size @@ -779,6 +925,10 @@ def main(): help="Font family name for output filenames (default: derived from font filename).") parser.add_argument("--force-autohint", dest="force_autohint", action="store_true", help="Force FreeType auto-hinter instead of native font hinting.") + parser.add_argument("--synthetic-bold", dest="synthetic_bold", action="store_true", + help="Apply synthetic boldening when bold style equals its source style.") + parser.add_argument("--debug-images", dest="debug_images", action="store_true", + help="Save debug A glyph PNGs for each generated style.") parser.add_argument("-o", "--output", dest="output", help="Output file path (for single-size mode).") parser.add_argument("--output-dir", dest="output_dir", @@ -883,7 +1033,9 @@ def main(): print(f"Generating {output_path} (size {sz}, {len(style_fonts)} style(s), v4)...", file=sys.stderr) total_size += generate_cpfont_multistyle( style_fonts, sz, intervals, output_path, - force_autohint=args.force_autohint) + force_autohint=args.force_autohint, + synthetic_bold=args.synthetic_bold, + debug_images=args.debug_images) print(f"\nTotal: {len(sizes)} files, {total_size / 1024 / 1024:.2f} MB", file=sys.stderr)