Skip to content

Instantly share code, notes, and snippets.

@madig
Last active May 22, 2023 15:58
Show Gist options
  • Save madig/76567a9650de639bbff51ce010783790 to your computer and use it in GitHub Desktop.
Save madig/76567a9650de639bbff51ce010783790 to your computer and use it in GitHub Desktop.
Align kerning groups and kerning
# pyright: basic
from __future__ import annotations
import argparse
import logging
from pathlib import Path
from fontTools.designspaceLib import DesignSpaceDocument
from fontTools.ufoLib.kerning import lookupKerningValue
from ufoLib2 import Font
parser = argparse.ArgumentParser()
parser.add_argument("designspace", type=Path, help="The source Designspace.")
args = parser.parse_args()
designspace_path: Path = args.designspace
designspace = DesignSpaceDocument.fromfile(designspace_path)
designspace.loadSourceFonts(Font.open)
# Step 1: Align kerning groups. Some fonts group the same glyphs under different names
# (e.g. some source revisions of Roboto Serif). Rename them to what the default source
# calls them, and discard groups that regroup glyphs or don't appear in the default
# source.
default_source: Font = designspace.findDefault().font
canonical_group_name_kern1: dict[tuple[str, ...], str] = {
tuple(sorted(members)): group_name
for group_name, members in default_source.groups.items()
if group_name.startswith("public.kern1.")
}
canonical_group_name_kern2: dict[tuple[str, ...], str] = {
tuple(sorted(members)): group_name
for group_name, members in default_source.groups.items()
if group_name.startswith("public.kern2.")
}
for source in designspace.sources:
if source.layerName is not None:
continue
assert source.font is not None
font: Font = source.font
renamed_groups: dict[str, str] = {}
deleted_groups: list[str] = []
overwritten_groups: list[str] = []
for group_name, group_members in font.groups.items():
if not group_members:
continue
if group_name.startswith("public.kern1."):
is_kern1 = True
elif group_name.startswith("public.kern2."):
is_kern1 = False
else:
continue
member_key = tuple(sorted(group_members))
if is_kern1:
canonical_name = canonical_group_name_kern1.get(member_key)
else:
canonical_name = canonical_group_name_kern2.get(member_key)
if canonical_name is None:
if group_name in default_source.groups:
# This group appears in the default source, but with different content.
logging.warning(
"Overwriting group %s in %s with group from the default "
"source because the content differs",
group_name,
source.name,
)
overwritten_groups.append(group_name)
else:
# This group doesn't appear in the default source. Discard it.
logging.warning(
"Discarding group %s in %s because it does not appear in the "
"default source",
group_name,
source.name,
)
deleted_groups.append(group_name)
elif group_name != canonical_name:
# This group appears in the default source, but under a different name.
# Rename it.
logging.warning(
"Renaming group %s in %s to %s to match the default source",
group_name,
source.name,
canonical_name,
)
# deleted_groups.append(group_name)
# font.groups[canonical_name] = group_members
renamed_groups[group_name] = canonical_name
for group_name in deleted_groups:
del font.groups[group_name]
for group_name in overwritten_groups:
font.groups[group_name] = list(default_source.groups[group_name])
# Rename kerning pairs to use the new canonical names, if any.
if renamed_groups:
for old_name, new_name in renamed_groups.items():
# TODO: Handle edge cases like swapped names? They'd crash at least by
# popping a non-existent key?
members = font.groups.pop(old_name)
font.groups[new_name] = members
new_kerning = {}
for (first, second), value in font.kerning.items():
new_kerning[
(renamed_groups.get(first, first), renamed_groups.get(second, second))
] = value
font.kerning = new_kerning
assert not font.groups.keys() & deleted_groups
assert not any(
side in renamed_groups for pair in font.kerning.keys() for side in pair
)
assert all(
font.groups[group_name] == default_source.groups[group_name]
for group_name in overwritten_groups
)
# Step 2: Take the union of all kerning pairs, then for each source, fill in missing
# pairs as the UFO kerning lookup algorithm would. This ensures that kerning exceptions
# are handled correctly when they are present in one source (used as is) but not another
# (backfilled by the less specific pair or falling back to zero).
union_kerning: set[tuple[str, str]] = {
pair for source in designspace.sources for pair in source.font.kerning
}
glyph_to_first_group = {
glyph_name: group_name
for glyph_names, group_name in canonical_group_name_kern1.items()
for glyph_name in glyph_names
}
glyph_to_second_group = {
glyph_name: group_name
for glyph_names, group_name in canonical_group_name_kern2.items()
for glyph_name in glyph_names
}
for source in designspace.sources:
if source.layerName is not None:
continue
font: Font = source.font
missing_kerning_pairs = union_kerning - font.kerning.keys()
for pair in missing_kerning_pairs:
value = lookupKerningValue(
pair,
font.kerning,
font.groups,
glyphToFirstGroup=glyph_to_first_group,
glyphToSecondGroup=glyph_to_second_group,
)
font.kerning[pair] = value
assert font.kerning.keys() == union_kerning
font.save()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment