From 1f8d67d5301ff78f744eec2d786f633e90e2de1d Mon Sep 17 00:00:00 2001 From: "kau.sh" <1042037+kaushikgopal@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:56:48 -0800 Subject: [PATCH 1/2] Fix ss04 feature freezing with Type 2 GSUB support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Problem: - The ss04 "Simplified i" feature uses a Type 2 substitution rule in the font (maps i → ['i.simple']) - opentype-feature-freezer lib only understood Type 1 substitution rules - When it encountered Type 2 rules, it silently skipped them - so customizations like ss04 never got applied to the generated fonts # Solution: - Remove opentype-feature-freezer dependency (doesn't support Type 2) - Create fontfreeze_activation.py (custom feature freezing script adapted from https://github.com/MuTsunTsai/fontfreeze) - Extended it to detect and process Type 2 substitution rules - Now when ss04 is enabled, it correctly freezes i → i.simple into the font's character map --- requirements.txt | 3 - scripts/fontfreeze_activation.py | 185 ++++++++++++++++++++++++++++++ scripts/instantiate-code-fonts.py | 36 +++--- 3 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 scripts/fontfreeze_activation.py diff --git a/requirements.txt b/requirements.txt index f12db88..e4eecba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,9 +4,6 @@ fonttools==4.39.3 # to check font versions in the build-all shell script font-v==1.0.5 -# to run the code font instantiation script -opentype-feature-freezer==1.32.2 - # to remove components in code ligatures for improved rendering skia-pathops==0.7.4 diff --git a/scripts/fontfreeze_activation.py b/scripts/fontfreeze_activation.py new file mode 100644 index 0000000..2fe70fe --- /dev/null +++ b/scripts/fontfreeze_activation.py @@ -0,0 +1,185 @@ +""" +Utilities for freezing OpenType features using FontFreeze logic. + +Portions of this module are adapted from FontFreeze +(https://github.com/MuTsunTsai/fontfreeze) and retain the original MIT License. +""" + +from __future__ import annotations + +from typing import Iterable, Sequence + +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables.otTables import ( + FeatureParamsCharacterVariants, + FeatureParamsStylisticSet, + featureParamTypes, +) + +hideRemovedFeature = True + + +def clearFeatureRecord(featureRecord) -> None: + featureRecord.Feature.LookupListIndex.clear() + featureRecord.Feature.LookupCount = 0 + if hideRemovedFeature: + featureRecord.FeatureTag = "DELT" + + +class Activator: + def __init__(self, font: TTFont, args: dict) -> None: + self.font = font + self.features = args.get("features") or [] + options: dict = args.get("options") or {} + self.target = options.get("target") + self.singleSub = options.get("singleSub") + self.singleSubs: dict[str, str] = {} + + if len(self.features) == 0 or "GSUB" not in self.font: + return + + self.cmapTables = self.font["cmap"].tables + self.unicodeGlyphs = {name for table in self.cmapTables for name in table.cmap.values()} + + gsub_table = self.font["GSUB"].table + self.featureRecords = gsub_table.FeatureList.FeatureRecord + self.lookup = gsub_table.LookupList.Lookup + + scriptRecords = gsub_table.ScriptList.ScriptRecord + for scriptRecord in scriptRecords: + self.activateInScript(scriptRecord.Script) + + if self.singleSub: + self.applySingleSubstitutions() + + def activateInScript(self, script) -> None: + if script.DefaultLangSys is not None: + self.activateInLangSys(script.DefaultLangSys) + for langSysRecord in script.LangSysRecord: + self.activateInLangSys(langSysRecord.LangSys) + + def activateInLangSys(self, langSys) -> None: + targetRecord = None + + # try to find existing target feature + for index in langSys.FeatureIndex: + featureRecord = self.featureRecords[index] + if featureRecord.FeatureTag == self.target: + targetRecord = featureRecord + + for index in langSys.FeatureIndex: + featureRecord = self.featureRecords[index] + if featureRecord.FeatureTag in self.features: + if self.singleSub: + self.findSingleSubstitution(featureRecord) + + if targetRecord is None: + # if there's no existing one, use the first matching feature as target + targetRecord = featureRecord + featureParamTypes[self.target] = ( + FeatureParamsStylisticSet + if featureRecord.FeatureTag.startswith("ss") + else FeatureParamsCharacterVariants + ) + featureRecord.FeatureTag = self.target + else: + Activator.moveFeatureLookups(featureRecord.Feature, targetRecord.Feature) + clearFeatureRecord(featureRecord) + + if targetRecord is not None: + targetRecord.Feature.LookupListIndex.sort() + + def findSingleSubstitution(self, featureRecord) -> None: + for lookupIndex in featureRecord.Feature.LookupListIndex: + lookup = self.lookup[lookupIndex] + if lookup.LookupType == 1: # Single substitution + subTables = lookup.SubTable + for sub in subTables: + for key, value in sub.mapping.items(): + self.singleSubs[key] = value + elif lookup.LookupType == 2: # Multiple substitution (one-to-many) + for sub in lookup.SubTable: + for key, value_list in sub.mapping.items(): + # Only handle single-element lists as single substitutions + if len(value_list) == 1: + self.singleSubs[key] = value_list[0] + elif lookup.LookupType == 7: # Extension lookup + for sub in lookup.SubTable: + ext = sub.ExtSubTable + if ext.LookupType == 1: + for key, value in ext.mapping.items(): + self.singleSubs[key] = value + elif ext.LookupType == 2: + for key, value_list in ext.mapping.items(): + if len(value_list) == 1: + self.singleSubs[key] = value_list[0] + + def applySingleSubstitutions(self) -> None: + if not self.singleSubs: + return + + cache: dict[str, str] = {} + + def resolve(glyph: str) -> str: + if glyph in cache: + return cache[glyph] + seen = set() + current = glyph + while current in self.singleSubs and current not in seen: + seen.add(current) + current = self.singleSubs[current] + cache[glyph] = current + return current + + for table in self.cmapTables: + for index, glyph in table.cmap.items(): + table.cmap[index] = resolve(glyph) + + @staticmethod + def moveFeatureLookups(fromFeature, toFeature) -> None: + toFeature.LookupListIndex.extend(fromFeature.LookupListIndex) + toFeature.LookupCount += fromFeature.LookupCount + + +def freeze_features( + input_path: str, + features: Sequence[str] | Iterable[str], + output_path: str | None = None, + *, + target_feature: str = "calt", + single_sub: bool = True, +) -> None: + """ + Freeze the requested features by moving their lookups into the target feature. + """ + feature_list = list(features) + if not feature_list: + return + + if output_path is None: + output_path = input_path + + def _apply(hide_removed: bool) -> TTFont: + global hideRemovedFeature + hideRemovedFeature = hide_removed + font = TTFont(input_path) + Activator( + font, + { + "features": feature_list, + "options": { + "target": target_feature, + "singleSub": single_sub, + }, + }, + ) + return font + + font = _apply(True) + try: + font.save(output_path) + except AssertionError as exc: + if "DELT" not in str(exc): + raise + font = _apply(False) + font.save(output_path) diff --git a/scripts/instantiate-code-fonts.py b/scripts/instantiate-code-fonts.py index f9fe233..0bd1d50 100644 --- a/scripts/instantiate-code-fonts.py +++ b/scripts/instantiate-code-fonts.py @@ -15,17 +15,14 @@ import shutil import yaml import sys -import logging import ttfautohint from fontTools.varLib import instancer from fontTools.varLib.instancer import OverlapMode -from opentype_feature_freezer import cli as pyftfeatfreeze +from fontTools.varLib.instancer.featureVars import instantiateFeatureVariations from dlig2calt import dlig2calt from mergePowerlineFont import mergePowerlineFont from ttfautohint.options import USER_OPTIONS as ttfautohint_options - -# prevents over-active warning logs -logging.getLogger("opentype_feature_freezer").setLevel(logging.ERROR) +from fontfreeze_activation import freeze_features # if you provide a custom config path, this picks it up try: @@ -92,18 +89,22 @@ def splitFont( print("\n--------------------------------------------------------------------------------------\n" + instance) + axisLocation = { + "wght": fontOptions["Fonts"][instance]["wght"], + "CASL": fontOptions["Fonts"][instance]["CASL"], + "MONO": fontOptions["Fonts"][instance]["MONO"], + "slnt": fontOptions["Fonts"][instance]["slnt"], + "CRSV": fontOptions["Fonts"][instance]["CRSV"], + } + instanceFont = instancer.instantiateVariableFont( varfont, - { - "wght": fontOptions["Fonts"][instance]["wght"], - "CASL": fontOptions["Fonts"][instance]["CASL"], - "MONO": fontOptions["Fonts"][instance]["MONO"], - "slnt": fontOptions["Fonts"][instance]["slnt"], - "CRSV": fontOptions["Fonts"][instance]["CRSV"], - }, + axisLocation, overlap=OverlapMode.REMOVE ) + instantiateFeatureVariations(instanceFont, axisLocation) + # UPDATE NAME ID 6, postscript name currentPsName = getFontNameID(instanceFont, 6) newPsName = (currentPsName\ @@ -155,8 +156,15 @@ def splitFont( # ------------------------------------------------------- # Code font special stuff in post processing - # freeze in rvrn & stylistic set features with pyftfeatfreeze - pyftfeatfreeze.main([f"--features=rvrn,{','.join(fontOptions['Features'])}", outputPath, outputPath]) + # Freeze stylistic set features (rvrn is already baked in via instantiateFeatureVariations) + # Note: ss04 and similar features use Type 2 (Multiple Substitution) lookups + if fontOptions["Features"]: + freeze_features( + outputPath, + fontOptions["Features"], + target_feature="calt", + single_sub=True, + ) if fontOptions['Code Ligatures']: # swap dlig2calt to make code ligatures work in old code editor apps From 3c599370eff959e59c877e15077b4131fad3605c Mon Sep 17 00:00:00 2001 From: "kau.sh" <1042037+kaushikgopal@users.noreply.github.com> Date: Wed, 19 Nov 2025 17:20:24 -0800 Subject: [PATCH 2/2] fix: rvrn freezing neded for l --- README.md | 2 +- scripts/instantiate-code-fonts.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 3a43181..3905c44 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ Finally, you can copy in the font feature options you want: - ss01 # Single-story a - ss02 # Single-story g - ss03 # Simplified f -- ss04 # Simplified i ### NOT CURRENTLY WORKING, see issue #4 +- ss04 # Simplified i - ss05 # Simplified l - ss06 # Simplified r diff --git a/scripts/instantiate-code-fonts.py b/scripts/instantiate-code-fonts.py index 0bd1d50..ce52c2d 100644 --- a/scripts/instantiate-code-fonts.py +++ b/scripts/instantiate-code-fonts.py @@ -156,15 +156,15 @@ def splitFont( # ------------------------------------------------------- # Code font special stuff in post processing - # Freeze stylistic set features (rvrn is already baked in via instantiateFeatureVariations) + # Freeze rvrn and stylistic set features # Note: ss04 and similar features use Type 2 (Multiple Substitution) lookups - if fontOptions["Features"]: - freeze_features( - outputPath, - fontOptions["Features"], - target_feature="calt", - single_sub=True, - ) + features_to_freeze = ["rvrn"] + fontOptions["Features"] + freeze_features( + outputPath, + features_to_freeze, + target_feature="calt", + single_sub=True, + ) if fontOptions['Code Ligatures']: # swap dlig2calt to make code ligatures work in old code editor apps