fix(font): Final font patcher fixes (#8847)

This is my final set of fixes to the font patcher/icon scaling code. It
builds on #8563 and there's not much reason to pay attention here until
that one has been reviewed (the unique changes in this PR only touch the
two `nerd_font_*` files; the other 8 files in the diff are just #8563).
However, I wanted to make sure the full set of changes/fixes I propose
are out in the open, such that any substantial edits by maintainers
(like in #7953) can take into account the full context.

I think this and the related patches should be considered fixes, not
features, so I hope they can be considered for a 1.2.x release.

This PR fixes some bugs in the extraction of scale and alignment rules
from the `font_patcher` script. Roughly in order of importance:

* Nerd fonts apply an offset to some codepoint ranges when extracting
glyphs from their original font (e.g., Font Awesome) and placing them in
a Nerd Font. Rules are specified in terms of the former codepoints, but
must be applied to the latter. This offset was previously not taken into
account, so rules were applied to the wrong glyphs, and some glyphs that
should have rules didn't get any.
* Previously, the rules from every single patch set was included, but
the embedded Symbols Only font doesn't contain all of them. Most
importantly, there's a legacy patch set that only exists for historical
reasons and is never used anymore, which was overwriting some other
rules because of overlapping codepoint ranges. Also, the Symbols Only
font contains no box drawing characters, so those rules should not be
included. With this PR, irrelevant patch sets are filtered out.
* Some patch sets specify overlapping codepoint ranges, though in
reality the original fonts don't actually cover the full ranges and the
overlaps just imply that they're filling each other's gaps. During font
patching, the presence/absence of a glyph at each codepoint in the
original font takes care of the ambiguity. Since we don't have that
information, we need to hardcode which patch set "wins" for each case
(it's not always the latest set in the list). Luckily, there are only
two cases.
* Many glyphs belong to scale groups that should be scaled and aligned
as a unit. However, in `font_patcher`, the scale group is _not_ used for
_horizontal_ alignment, _unless_ the entire scale group has a single
advance width (remember, the original symbol fonts are not monospace).
This PR implements this rule by only setting `relative_width` and
`relative_x` if the group is monospace.

There are some additional tweaks to ensure that each codepoint actually
gets the rule it's supposed to when it belongs to multiple scale groups
or patch sets, and to avoid setting rules for codepoints that don't
exist in the embedded font.
This commit is contained in:
Mitchell Hashimoto
2025-10-03 13:53:04 -07:00
parent bdc1dc4363
commit 1a94e7b016
2 changed files with 2080 additions and 317 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -57,6 +57,7 @@ class PatchSetAttributeEntry(TypedDict):
class PatchSet(TypedDict): class PatchSet(TypedDict):
Name: str
SymStart: int SymStart: int
SymEnd: int SymEnd: int
SrcStart: int | None SrcStart: int | None
@@ -113,20 +114,43 @@ class PatchSetExtractor(ast.NodeVisitor):
if hasattr(ast, "unparse"): if hasattr(ast, "unparse"):
return eval( return eval(
ast.unparse(node), ast.unparse(node),
{"box_keep": True}, {"box_enabled": False, "box_keep": False},
{"self": SimpleNamespace(args=SimpleNamespace(careful=True))}, {
"self": SimpleNamespace(
args=SimpleNamespace(
careful=False,
custom=False,
fontawesome=True,
fontawesomeextension=True,
fontlogos=True,
octicons=True,
codicons=True,
powersymbols=True,
pomicons=True,
powerline=True,
powerlineextra=True,
material=True,
weather=True,
)
),
},
) )
msg = f"<cannot eval: {type(node).__name__}>" msg = f"<cannot eval: {type(node).__name__}>"
raise ValueError(msg) from None raise ValueError(msg) from None
def process_patch_entry(self, dict_node: ast.Dict) -> None: def process_patch_entry(self, dict_node: ast.Dict) -> None:
entry = {} entry = {}
disallowed_key_nodes = frozenset({"Enabled", "Name", "Filename", "Exact"}) disallowed_key_nodes = frozenset({"Filename", "Exact"})
for key_node, value_node in zip(dict_node.keys, dict_node.values): for key_node, value_node in zip(dict_node.keys, dict_node.values):
if ( if (
isinstance(key_node, ast.Constant) isinstance(key_node, ast.Constant)
and key_node.value not in disallowed_key_nodes and key_node.value not in disallowed_key_nodes
): ):
if key_node.value == "Enabled":
if self.safe_literal_eval(value_node):
continue # This patch set is enabled, continue to next key
else:
return # This patch set is disabled, skip
key = ast.literal_eval(cast("ast.Constant", key_node)) key = ast.literal_eval(cast("ast.Constant", key_node))
entry[key] = self.resolve_symbol(value_node) entry[key] = self.resolve_symbol(value_node)
self.patch_set_values.append(cast("PatchSet", entry)) self.patch_set_values.append(cast("PatchSet", entry))
@@ -275,45 +299,109 @@ def generate_zig_switch_arms(
entries: dict[int, PatchSetAttributeEntry] = {} entries: dict[int, PatchSetAttributeEntry] = {}
for entry in patch_sets: for entry in patch_sets:
patch_set_name = entry["Name"]
print(f"Info: Extracting rules from patch set '{patch_set_name}'")
attributes = entry["Attributes"] attributes = entry["Attributes"]
patch_set_entries: dict[int, PatchSetAttributeEntry] = {}
for cp in range(entry["SymStart"], entry["SymEnd"] + 1): # A glyph's scale rules are specified using its codepoint in
entries[cp] = attributes["default"].copy() # the original font, which is sometimes different from its
# Nerd Font codepoint. In font_patcher, the font to be patched
# (including the Symbols Only font embedded in Ghostty) is
# termed the sourceFont, while the original font is the
# symbolFont. Thus, the offset that maps the scale rule
# codepoint to the Nerd Font codepoint is SrcStart - SymStart.
cp_offset = entry["SrcStart"] - entry["SymStart"] if entry["SrcStart"] else 0
for cp_rule in range(entry["SymStart"], entry["SymEnd"] + 1):
cp_font = cp_rule + cp_offset
if cp_font not in cmap:
print(f"Info: Skipping missing codepoint {hex(cp_font)}")
continue
elif cp_font in entries:
# Patch sets sometimes have overlapping codepoint ranges.
# Sometimes a later set is a smaller set filling in a gap
# in the range of a larger, preceding set. Sometimes it's
# the other way around. The best thing we can do is hardcode
# each case.
if patch_set_name == "Font Awesome":
# The Font Awesome range has a gap matching the
# prededing Progress Indicators range.
print(f"Info: Not overwriting existing codepoint {hex(cp_font)}")
continue
elif patch_set_name == "Octicons":
# The fourth Octicons range overlaps with the first.
print(f"Info: Overwriting existing codepoint {hex(cp_font)}")
else:
raise ValueError(
f"Unknown case of overlap for codepoint {hex(cp_font)} in patch set '{patch_set_name}'"
)
if cp_rule in attributes:
patch_set_entries[cp_font] = attributes[cp_rule].copy()
else:
patch_set_entries[cp_font] = attributes["default"].copy()
entries |= {k: v for k, v in attributes.items() if isinstance(k, int)} if entry["ScaleRules"] is not None:
if "ScaleGroups" not in entry["ScaleRules"]:
if entry["ScaleRules"] is not None and "ScaleGroups" in entry["ScaleRules"]: raise ValueError(
f"Scale rule format {entry['ScaleRules']} not implemented."
)
for group in entry["ScaleRules"]["ScaleGroups"]: for group in entry["ScaleRules"]["ScaleGroups"]:
xMin = math.inf xMin = math.inf
yMin = math.inf yMin = math.inf
xMax = -math.inf xMax = -math.inf
yMax = -math.inf yMax = -math.inf
individual_bounds: dict[int, tuple[int, int, int, int]] = {} individual_bounds: dict[int, tuple[int, int, int, int]] = {}
for cp in group: individual_advances: set[float] = set()
if cp not in cmap: for cp_rule in group:
cp_font = cp_rule + cp_offset
if cp_font not in cmap:
continue continue
glyph = glyphs[cmap[cp]] glyph = glyphs[cmap[cp_font]]
individual_advances.add(glyph.width)
bounds = BoundsPen(glyphSet=glyphs) bounds = BoundsPen(glyphSet=glyphs)
glyph.draw(bounds) glyph.draw(bounds)
individual_bounds[cp] = bounds.bounds individual_bounds[cp_font] = bounds.bounds
xMin = min(bounds.bounds[0], xMin) xMin = min(bounds.bounds[0], xMin)
yMin = min(bounds.bounds[1], yMin) yMin = min(bounds.bounds[1], yMin)
xMax = max(bounds.bounds[2], xMax) xMax = max(bounds.bounds[2], xMax)
yMax = max(bounds.bounds[3], yMax) yMax = max(bounds.bounds[3], yMax)
group_width = xMax - xMin group_width = xMax - xMin
group_height = yMax - yMin group_height = yMax - yMin
for cp in group: group_is_monospace = (len(individual_bounds) > 1) and (
if cp not in cmap or cp not in entries: len(individual_advances) == 1
)
for cp_rule in group:
cp_font = cp_rule + cp_offset
if (
cp_font not in cmap
or cp_font not in patch_set_entries
# Codepoints may contribute to the bounding box of multiple groups,
# but should be scaled according to the first group they are found
# in. Hence, to avoid overwriting, we need to skip codepoints that
# have already been assigned a scale group.
or "relative_height" in patch_set_entries[cp_font]
):
continue continue
this_bounds = individual_bounds[cp] this_bounds = individual_bounds[cp_font]
this_width = this_bounds[2] - this_bounds[0]
this_height = this_bounds[3] - this_bounds[1] this_height = this_bounds[3] - this_bounds[1]
entries[cp]["relative_width"] = this_width / group_width patch_set_entries[cp_font]["relative_height"] = (
entries[cp]["relative_height"] = this_height / group_height this_height / group_height
entries[cp]["relative_x"] = (this_bounds[0] - xMin) / group_width )
entries[cp]["relative_y"] = (this_bounds[1] - yMin) / group_height patch_set_entries[cp_font]["relative_y"] = (
this_bounds[1] - yMin
del entries[0] ) / group_height
# Horizontal alignment should only be grouped if the group is monospace,
# that is, if all glyphs in the group have the same advance width.
if group_is_monospace:
this_width = this_bounds[2] - this_bounds[0]
patch_set_entries[cp_font]["relative_width"] = (
this_width / group_width
)
patch_set_entries[cp_font]["relative_x"] = (
this_bounds[0] - xMin
) / group_width
entries |= patch_set_entries
# Group codepoints by attribute key # Group codepoints by attribute key
grouped = defaultdict[AttributeHash, list[int]](list) grouped = defaultdict[AttributeHash, list[int]](list)