feat: Support for proportional numeral spacing (#1414)

## Summary

**What is the goal of this PR?**

Reading a book with frequent numbers, I noticed that the spacing between
numeral glyphs was strangely large. This was because Bookerly and Noto
Sans default to tabular figures, where every digit gets an identical
advance width. This is designed for column alignment in spreadsheets,
but in rendering prose it produces visually wide gaps between digits.

This change adds a `--pnum` flag to fontconvert.py that applies the
font's OpenType `pnum` (proportional numerals) feature during
conversion. When active, the converter:
- Parses the GSUB table for pnum SingleSubst lookups
- Resolves substitute glyph indices via fonttools' glyph order
- Loads the proportional alternate glyphs instead of the tabular
defaults
- Includes substitute glyph names in kern pair extraction, so kerning
data that references proportional alternates is captured

Bookerly's proportional alternates also carry digit-digit and
digit-punctuation kerning that the tabular glyphs lack (e.g., at 16pt
7->4 at -1.69px, 7->. at -2.31px, 7->1 at +1.00px).

Noto Sans gains proportional advances but no new kerning (its
proportional glyphs have no kern class data in the font).

OpenDyslexic is unaffected. Its `cmap` already points to proportional
glyphs, so `--pnum` is a no-op. `--pnum` is intentionally omitted from
OpenDyslexic in the build script for deliberately uniform digit spacing
as an accessibility choice.

UI fonts (Ubuntu, notosans_8) also omit `--pnum` to preserve tabular
alignment for page numbers, battery percentages, etc.

| Before | After |
| -- | -- |
| <img
src="https://github.com/user-attachments/files/26042238/screenshot-31673.bmp"
width="300" /> | <img
src="https://github.com/user-attachments/files/26042241/screenshot-124075.bmp"
width="300" /> |

---

### AI Usage

While CrossPoint doesn't have restrictions on AI tools in contributing,
please be transparent about their usage as it
helps set the right context for reviewers.

Did you use AI tools to help write this code? _**YES**_
This commit is contained in:
Zach Nelson
2026-04-18 17:03:04 -05:00
committed by GitHub
parent 3cdfc6c781
commit 64f5ef018a
35 changed files with 97345 additions and 96583 deletions
+2 -2
View File
@@ -14,7 +14,7 @@ for size in ${BOOKERLY_FONT_SIZES[@]}; do
font_name="bookerly_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/Bookerly/Bookerly-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress --pnum > $output_path
echo "Generated $output_path"
done
done
@@ -24,7 +24,7 @@ for size in ${NOTOSANS_FONT_SIZES[@]}; do
font_name="notosans_${size}_$(echo $style | tr '[:upper:]' '[:lower:]')"
font_path="../builtinFonts/source/NotoSans/NotoSans-${style}.ttf"
output_path="../builtinFonts/${font_name}.h"
python fontconvert.py $font_name $size $font_path --2bit --compress > $output_path
python fontconvert.py $font_name $size $font_path --2bit --compress --pnum > $output_path
echo "Generated $output_path"
done
done
+70 -4
View File
@@ -18,6 +18,7 @@ parser.add_argument("--2bit", dest="is2Bit", action="store_true", help="generate
parser.add_argument("--additional-intervals", dest="additional_intervals", action="append", help="Additional code point intervals to export as min,max. This argument can be repeated.")
parser.add_argument("--compress", dest="compress", action="store_true", help="Compress glyph bitmaps using DEFLATE with group-based compression.")
parser.add_argument("--force-autohint", dest="force_autohint", action="store_true", help="Force FreeType auto-hinter instead of native font hinting. Improves stem width consistency for fonts with weak or no native TrueType hints.")
parser.add_argument("--pnum", dest="pnum", action="store_true", help="Use proportional numerals (pnum OpenType feature) instead of default tabular figures. Reduces visual gaps between digits in running prose.")
args = parser.parse_args()
GlyphProps = namedtuple("GlyphProps", ["width", "height", "advance_x", "left", "top", "data_length", "data_offset", "code_point"])
@@ -168,11 +169,68 @@ def chunks(l, n):
for i in range(0, len(l), n):
yield l[i:i + n]
def extract_pnum_subs(font_path):
"""Extract pnum (proportional figures) GSUB substitutions.
Parses the font's GSUB table for the 'pnum' feature, which replaces
tabular-width figure glyphs with proportional-width alternates.
Returns {original_glyph_name: substitute_glyph_name} or empty dict.
"""
font = TTFont(font_path)
subs = {}
if 'GSUB' not in font:
font.close()
return subs
gsub = font['GSUB'].table
pnum_indices = set()
if gsub.FeatureList:
for fr in gsub.FeatureList.FeatureRecord:
if fr.FeatureTag == 'pnum':
pnum_indices.update(fr.Feature.LookupListIndex)
for li in pnum_indices:
lookup = gsub.LookupList.Lookup[li]
for st in lookup.SubTable:
actual = st
if lookup.LookupType == 7 and hasattr(st, 'ExtSubTable'):
actual = st.ExtSubTable
if hasattr(actual, 'mapping'):
subs.update(actual.mapping)
font.close()
return subs
# Build proportional numeral glyph overrides when --pnum is active.
# Maps (face_index, codepoint) -> freetype glyph index for the proportional alternate.
pnum_glyph_overrides = {}
pnum_kern_subs = {} # face_index -> {original_glyph_name: substitute_glyph_name}
if args.pnum:
for face_idx, font_path in enumerate(args.fontstack):
subs = extract_pnum_subs(font_path)
if not subs:
continue
pnum_kern_subs[face_idx] = subs
tt_font = TTFont(font_path)
cmap = tt_font.getBestCmap() or {}
glyph_order = tt_font.getGlyphOrder()
name_to_glyph_idx = {name: idx for idx, name in enumerate(glyph_order)}
count = 0
for cp, glyph_name in cmap.items():
if glyph_name in subs:
sub_name = subs[glyph_name]
sub_idx = name_to_glyph_idx.get(sub_name, 0)
if sub_idx > 0:
pnum_glyph_overrides[(face_idx, cp)] = sub_idx
count += 1
tt_font.close()
if count > 0:
print(f"pnum: {count} glyph substitutions from {font_path}", file=sys.stderr)
def load_glyph(code_point):
face_index = 0
while face_index < len(font_stack):
face = font_stack[face_index]
glyph_index = face.get_char_index(code_point)
glyph_index = pnum_glyph_overrides.get((face_index, code_point))
if glyph_index is None:
glyph_index = face.get_char_index(code_point)
if glyph_index > 0:
face.load_glyph(glyph_index, load_flags)
return face
@@ -386,23 +444,30 @@ def _extract_pairpos_subtable(subtable, glyph_to_cp, raw_kern):
key = (left_glyph, right_glyph)
raw_kern[key] = raw_kern.get(key, 0) + xa
def extract_kerning_fonttools(font_path, codepoints, ppem):
def extract_kerning_fonttools(font_path, codepoints, ppem, pnum_subs=None):
"""Extract kerning pairs from a font file using fonttools.
Returns dict of {(leftCp, rightCp): pixel_adjust} for the given
codepoints. Values are scaled from font design units to integer
pixels at ppem.
When pnum_subs is provided, substitute glyph names are also included
in the lookup so kern pairs referencing proportional alternates are found.
"""
font = TTFont(font_path)
units_per_em = font['head'].unitsPerEm
cmap = font.getBestCmap() or {}
# Build glyph_name -> codepoint map (only for requested codepoints)
# Build glyph_name -> codepoint map (only for requested codepoints).
# When pnum is active, include both the original and substitute glyph
# names so kern pairs referencing either are captured.
glyph_to_cp = {}
for cp in codepoints:
gname = cmap.get(cp)
if gname:
glyph_to_cp[gname] = cp
if pnum_subs and gname in pnum_subs:
glyph_to_cp[pnum_subs[gname]] = cp
# Collect raw kerning values in font design units
raw_kern = {} # (left_glyph_name, right_glyph_name) -> design_units
@@ -454,7 +519,8 @@ ppem = size * 150.0 / 72.0
kern_map = {} # (leftCp, rightCp) -> adjust
for face_idx, cps in face_idx_cps.items():
font_path = args.fontstack[face_idx]
kern_map.update(extract_kerning_fonttools(font_path, cps, ppem))
subs = pnum_kern_subs.get(face_idx) if args.pnum else None
kern_map.update(extract_kerning_fonttools(font_path, cps, ppem, pnum_subs=subs))
print(f"kerning: {len(kern_map)} pairs extracted", file=sys.stderr)