Merge pull request #173 from jpirnay/fix-variable-font

fix: Fix variable font issue (no bold variants)
This commit is contained in:
jpirnay
2026-05-05 18:12:47 +02:00
committed by GitHub
41 changed files with 167 additions and 7 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -1
View File
@@ -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()
+160 -6
View File
@@ -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,128 @@ 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):
sample_codepoints = [ord('A'), ord('a'), ord('g'), ord('0')]
for code_point in sample_codepoints:
base_entry = next(glyph_entries_by_codepoint(base_sd, code_point), None)
target_entry = next(glyph_entries_by_codepoint(target_sd, code_point), 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)
if base_pixels != target_pixels:
return False
return True
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 = {
@@ -447,8 +576,7 @@ def extract_ligatures_fonttools(font_path, codepoints):
def rasterize_font_style(fontfile, size, intervals, style_id=0, force_autohint=False):
"""Rasterize all glyphs for one font style. Returns StyleRasterData."""
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
style_label = style_names.get(style_id, str(style_id))
style_label = STYLE_LABELS.get(style_id, str(style_id))
face = freetype.Face(fontfile)
# Set font size at 150 DPI (matching fontconvert.py) BEFORE any glyph load
@@ -665,7 +793,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 +815,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():
@@ -747,12 +894,13 @@ def generate_cpfont_multistyle(style_fonts, size, intervals, output_path,
for style_id in sorted(raster_data.keys()):
sd = raster_data[style_id]
secs = packed_sections[style_id]
style_names = {0: "regular", 1: "bold", 2: "italic", 3: "bolditalic"}
sname = style_names.get(style_id, str(style_id))
sname = STYLE_LABELS.get(style_id, str(style_id))
ssize = style_sections_total_size(secs)
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 +927,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 +1035,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)