asd
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,5 @@
|
||||
import sys
|
||||
from fontTools.varLib.instancer import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@ -0,0 +1,190 @@
|
||||
from fontTools.ttLib.tables import otTables as ot
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
|
||||
|
||||
log = logging.getLogger("fontTools.varLib.instancer")
|
||||
|
||||
|
||||
def _featureVariationRecordIsUnique(rec, seen):
|
||||
conditionSet = []
|
||||
conditionSets = (
|
||||
rec.ConditionSet.ConditionTable if rec.ConditionSet is not None else []
|
||||
)
|
||||
for cond in conditionSets:
|
||||
if cond.Format != 1:
|
||||
# can't tell whether this is duplicate, assume is unique
|
||||
return True
|
||||
conditionSet.append(
|
||||
(cond.AxisIndex, cond.FilterRangeMinValue, cond.FilterRangeMaxValue)
|
||||
)
|
||||
# besides the set of conditions, we also include the FeatureTableSubstitution
|
||||
# version to identify unique FeatureVariationRecords, even though only one
|
||||
# version is currently defined. It's theoretically possible that multiple
|
||||
# records with same conditions but different substitution table version be
|
||||
# present in the same font for backward compatibility.
|
||||
recordKey = frozenset([rec.FeatureTableSubstitution.Version] + conditionSet)
|
||||
if recordKey in seen:
|
||||
return False
|
||||
else:
|
||||
seen.add(recordKey) # side effect
|
||||
return True
|
||||
|
||||
|
||||
def _limitFeatureVariationConditionRange(condition, axisLimit):
|
||||
minValue = condition.FilterRangeMinValue
|
||||
maxValue = condition.FilterRangeMaxValue
|
||||
|
||||
if (
|
||||
minValue > maxValue
|
||||
or minValue > axisLimit.maximum
|
||||
or maxValue < axisLimit.minimum
|
||||
):
|
||||
# condition invalid or out of range
|
||||
return
|
||||
|
||||
return tuple(
|
||||
axisLimit.renormalizeValue(v, extrapolate=False) for v in (minValue, maxValue)
|
||||
)
|
||||
|
||||
|
||||
def _instantiateFeatureVariationRecord(
|
||||
record, recIdx, axisLimits, fvarAxes, axisIndexMap
|
||||
):
|
||||
applies = True
|
||||
shouldKeep = False
|
||||
newConditions = []
|
||||
from fontTools.varLib.instancer import NormalizedAxisTripleAndDistances
|
||||
|
||||
default_triple = NormalizedAxisTripleAndDistances(-1, 0, +1)
|
||||
if record.ConditionSet is None:
|
||||
record.ConditionSet = ot.ConditionSet()
|
||||
record.ConditionSet.ConditionTable = []
|
||||
record.ConditionSet.ConditionCount = 0
|
||||
for i, condition in enumerate(record.ConditionSet.ConditionTable):
|
||||
if condition.Format == 1:
|
||||
axisIdx = condition.AxisIndex
|
||||
axisTag = fvarAxes[axisIdx].axisTag
|
||||
|
||||
minValue = condition.FilterRangeMinValue
|
||||
maxValue = condition.FilterRangeMaxValue
|
||||
triple = axisLimits.get(axisTag, default_triple)
|
||||
|
||||
if not (minValue <= triple.default <= maxValue):
|
||||
applies = False
|
||||
|
||||
# if condition not met, remove entire record
|
||||
if triple.minimum > maxValue or triple.maximum < minValue:
|
||||
newConditions = None
|
||||
break
|
||||
|
||||
if axisTag in axisIndexMap:
|
||||
# remap axis index
|
||||
condition.AxisIndex = axisIndexMap[axisTag]
|
||||
|
||||
# remap condition limits
|
||||
newRange = _limitFeatureVariationConditionRange(condition, triple)
|
||||
if newRange:
|
||||
# keep condition with updated limits
|
||||
minimum, maximum = newRange
|
||||
condition.FilterRangeMinValue = minimum
|
||||
condition.FilterRangeMaxValue = maximum
|
||||
shouldKeep = True
|
||||
if minimum != -1 or maximum != +1:
|
||||
newConditions.append(condition)
|
||||
else:
|
||||
# condition out of range, remove entire record
|
||||
newConditions = None
|
||||
break
|
||||
|
||||
else:
|
||||
log.warning(
|
||||
"Condition table {0} of FeatureVariationRecord {1} has "
|
||||
"unsupported format ({2}); ignored".format(i, recIdx, condition.Format)
|
||||
)
|
||||
applies = False
|
||||
newConditions.append(condition)
|
||||
|
||||
if newConditions is not None and shouldKeep:
|
||||
record.ConditionSet.ConditionTable = newConditions
|
||||
if not newConditions:
|
||||
record.ConditionSet = None
|
||||
shouldKeep = True
|
||||
else:
|
||||
shouldKeep = False
|
||||
|
||||
# Does this *always* apply?
|
||||
universal = shouldKeep and not newConditions
|
||||
|
||||
return applies, shouldKeep, universal
|
||||
|
||||
|
||||
def _instantiateFeatureVariations(table, fvarAxes, axisLimits):
|
||||
pinnedAxes = set(axisLimits.pinnedLocation())
|
||||
axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes]
|
||||
axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder}
|
||||
|
||||
featureVariationApplied = False
|
||||
uniqueRecords = set()
|
||||
newRecords = []
|
||||
defaultsSubsts = None
|
||||
|
||||
for i, record in enumerate(table.FeatureVariations.FeatureVariationRecord):
|
||||
applies, shouldKeep, universal = _instantiateFeatureVariationRecord(
|
||||
record, i, axisLimits, fvarAxes, axisIndexMap
|
||||
)
|
||||
|
||||
if shouldKeep and _featureVariationRecordIsUnique(record, uniqueRecords):
|
||||
newRecords.append(record)
|
||||
|
||||
if applies and not featureVariationApplied:
|
||||
assert record.FeatureTableSubstitution.Version == 0x00010000
|
||||
defaultsSubsts = deepcopy(record.FeatureTableSubstitution)
|
||||
for default, rec in zip(
|
||||
defaultsSubsts.SubstitutionRecord,
|
||||
record.FeatureTableSubstitution.SubstitutionRecord,
|
||||
):
|
||||
default.Feature = deepcopy(
|
||||
table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature
|
||||
)
|
||||
table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = deepcopy(
|
||||
rec.Feature
|
||||
)
|
||||
# Set variations only once
|
||||
featureVariationApplied = True
|
||||
|
||||
# Further records don't have a chance to apply after a universal record
|
||||
if universal:
|
||||
break
|
||||
|
||||
# Insert a catch-all record to reinstate the old features if necessary
|
||||
if featureVariationApplied and newRecords and not universal:
|
||||
defaultRecord = ot.FeatureVariationRecord()
|
||||
defaultRecord.ConditionSet = ot.ConditionSet()
|
||||
defaultRecord.ConditionSet.ConditionTable = []
|
||||
defaultRecord.ConditionSet.ConditionCount = 0
|
||||
defaultRecord.FeatureTableSubstitution = defaultsSubsts
|
||||
|
||||
newRecords.append(defaultRecord)
|
||||
|
||||
if newRecords:
|
||||
table.FeatureVariations.FeatureVariationRecord = newRecords
|
||||
table.FeatureVariations.FeatureVariationCount = len(newRecords)
|
||||
else:
|
||||
del table.FeatureVariations
|
||||
# downgrade table version if there are no FeatureVariations left
|
||||
table.Version = 0x00010000
|
||||
|
||||
|
||||
def instantiateFeatureVariations(varfont, axisLimits):
|
||||
for tableTag in ("GPOS", "GSUB"):
|
||||
if tableTag not in varfont or not getattr(
|
||||
varfont[tableTag].table, "FeatureVariations", None
|
||||
):
|
||||
continue
|
||||
log.info("Instantiating FeatureVariations of %s table", tableTag)
|
||||
_instantiateFeatureVariations(
|
||||
varfont[tableTag].table, varfont["fvar"].axes, axisLimits
|
||||
)
|
||||
# remove unreferenced lookups
|
||||
varfont[tableTag].prune_lookups()
|
||||
@ -0,0 +1,388 @@
|
||||
"""Helpers for instantiating name table records."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from enum import IntEnum
|
||||
import re
|
||||
|
||||
|
||||
class NameID(IntEnum):
|
||||
FAMILY_NAME = 1
|
||||
SUBFAMILY_NAME = 2
|
||||
UNIQUE_FONT_IDENTIFIER = 3
|
||||
FULL_FONT_NAME = 4
|
||||
VERSION_STRING = 5
|
||||
POSTSCRIPT_NAME = 6
|
||||
TYPOGRAPHIC_FAMILY_NAME = 16
|
||||
TYPOGRAPHIC_SUBFAMILY_NAME = 17
|
||||
VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25
|
||||
|
||||
|
||||
ELIDABLE_AXIS_VALUE_NAME = 2
|
||||
|
||||
|
||||
def getVariationNameIDs(varfont):
|
||||
used = []
|
||||
if "fvar" in varfont:
|
||||
fvar = varfont["fvar"]
|
||||
for axis in fvar.axes:
|
||||
used.append(axis.axisNameID)
|
||||
for instance in fvar.instances:
|
||||
used.append(instance.subfamilyNameID)
|
||||
if instance.postscriptNameID != 0xFFFF:
|
||||
used.append(instance.postscriptNameID)
|
||||
if "STAT" in varfont:
|
||||
stat = varfont["STAT"].table
|
||||
for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else ():
|
||||
used.append(axis.AxisNameID)
|
||||
for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else ():
|
||||
used.append(value.ValueNameID)
|
||||
elidedFallbackNameID = getattr(stat, "ElidedFallbackNameID", None)
|
||||
if elidedFallbackNameID is not None:
|
||||
used.append(elidedFallbackNameID)
|
||||
# nameIDs <= 255 are reserved by OT spec so we don't touch them
|
||||
return {nameID for nameID in used if nameID > 255}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pruningUnusedNames(varfont):
|
||||
from . import log
|
||||
|
||||
origNameIDs = getVariationNameIDs(varfont)
|
||||
|
||||
yield
|
||||
|
||||
log.info("Pruning name table")
|
||||
exclude = origNameIDs - getVariationNameIDs(varfont)
|
||||
varfont["name"].names[:] = [
|
||||
record for record in varfont["name"].names if record.nameID not in exclude
|
||||
]
|
||||
if "ltag" in varfont:
|
||||
# Drop the whole 'ltag' table if all the language-dependent Unicode name
|
||||
# records that reference it have been dropped.
|
||||
# TODO: Only prune unused ltag tags, renumerating langIDs accordingly.
|
||||
# Note ltag can also be used by feat or morx tables, so check those too.
|
||||
if not any(
|
||||
record
|
||||
for record in varfont["name"].names
|
||||
if record.platformID == 0 and record.langID != 0xFFFF
|
||||
):
|
||||
del varfont["ltag"]
|
||||
|
||||
|
||||
def updateNameTable(varfont, axisLimits):
|
||||
"""Update instatiated variable font's name table using STAT AxisValues.
|
||||
|
||||
Raises ValueError if the STAT table is missing or an Axis Value table is
|
||||
missing for requested axis locations.
|
||||
|
||||
First, collect all STAT AxisValues that match the new default axis locations
|
||||
(excluding "elided" ones); concatenate the strings in design axis order,
|
||||
while giving priority to "synthetic" values (Format 4), to form the
|
||||
typographic subfamily name associated with the new default instance.
|
||||
Finally, update all related records in the name table, making sure that
|
||||
legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic,
|
||||
Bold, Bold Italic) naming model.
|
||||
|
||||
Example: Updating a partial variable font:
|
||||
| >>> ttFont = TTFont("OpenSans[wdth,wght].ttf")
|
||||
| >>> updateNameTable(ttFont, {"wght": (400, 900), "wdth": 75})
|
||||
|
||||
The name table records will be updated in the following manner:
|
||||
NameID 1 familyName: "Open Sans" --> "Open Sans Condensed"
|
||||
NameID 2 subFamilyName: "Regular" --> "Regular"
|
||||
NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \
|
||||
"3.000;GOOG;OpenSans-Condensed"
|
||||
NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed"
|
||||
NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed"
|
||||
NameID 16 Typographic Family name: None --> "Open Sans"
|
||||
NameID 17 Typographic Subfamily name: None --> "Condensed"
|
||||
|
||||
References:
|
||||
https://docs.microsoft.com/en-us/typography/opentype/spec/stat
|
||||
https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids
|
||||
"""
|
||||
from . import AxisLimits, axisValuesFromAxisLimits
|
||||
|
||||
if "STAT" not in varfont:
|
||||
raise ValueError("Cannot update name table since there is no STAT table.")
|
||||
stat = varfont["STAT"].table
|
||||
if not stat.AxisValueArray:
|
||||
raise ValueError("Cannot update name table since there are no STAT Axis Values")
|
||||
fvar = varfont["fvar"]
|
||||
|
||||
# The updated name table will reflect the new 'zero origin' of the font.
|
||||
# If we're instantiating a partial font, we will populate the unpinned
|
||||
# axes with their default axis values from fvar.
|
||||
axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont)
|
||||
partialDefaults = axisLimits.defaultLocation()
|
||||
fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes}
|
||||
defaultAxisCoords = AxisLimits({**fvarDefaults, **partialDefaults})
|
||||
assert all(v.minimum == v.maximum for v in defaultAxisCoords.values())
|
||||
|
||||
axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords)
|
||||
checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords.pinnedLocation())
|
||||
|
||||
# ignore "elidable" axis values, should be omitted in application font menus.
|
||||
axisValueTables = [
|
||||
v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME
|
||||
]
|
||||
axisValueTables = _sortAxisValues(axisValueTables)
|
||||
_updateNameRecords(varfont, axisValueTables)
|
||||
|
||||
|
||||
def checkAxisValuesExist(stat, axisValues, axisCoords):
|
||||
seen = set()
|
||||
designAxes = stat.DesignAxisRecord.Axis
|
||||
hasValues = set()
|
||||
for value in stat.AxisValueArray.AxisValue:
|
||||
if value.Format in (1, 2, 3):
|
||||
hasValues.add(designAxes[value.AxisIndex].AxisTag)
|
||||
elif value.Format == 4:
|
||||
for rec in value.AxisValueRecord:
|
||||
hasValues.add(designAxes[rec.AxisIndex].AxisTag)
|
||||
|
||||
for axisValueTable in axisValues:
|
||||
axisValueFormat = axisValueTable.Format
|
||||
if axisValueTable.Format in (1, 2, 3):
|
||||
axisTag = designAxes[axisValueTable.AxisIndex].AxisTag
|
||||
if axisValueFormat == 2:
|
||||
axisValue = axisValueTable.NominalValue
|
||||
else:
|
||||
axisValue = axisValueTable.Value
|
||||
if axisTag in axisCoords and axisValue == axisCoords[axisTag]:
|
||||
seen.add(axisTag)
|
||||
elif axisValueTable.Format == 4:
|
||||
for rec in axisValueTable.AxisValueRecord:
|
||||
axisTag = designAxes[rec.AxisIndex].AxisTag
|
||||
if axisTag in axisCoords and rec.Value == axisCoords[axisTag]:
|
||||
seen.add(axisTag)
|
||||
|
||||
missingAxes = (set(axisCoords) - seen) & hasValues
|
||||
if missingAxes:
|
||||
missing = ", ".join(f"'{i}': {axisCoords[i]}" for i in missingAxes)
|
||||
raise ValueError(f"Cannot find Axis Values {{{missing}}}")
|
||||
|
||||
|
||||
def _sortAxisValues(axisValues):
|
||||
# Sort by axis index, remove duplicates and ensure that format 4 AxisValues
|
||||
# are dominant.
|
||||
# The MS Spec states: "if a format 1, format 2 or format 3 table has a
|
||||
# (nominal) value used in a format 4 table that also has values for
|
||||
# other axes, the format 4 table, being the more specific match, is used",
|
||||
# https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4
|
||||
results = []
|
||||
seenAxes = set()
|
||||
# Sort format 4 axes so the tables with the most AxisValueRecords are first
|
||||
format4 = sorted(
|
||||
[v for v in axisValues if v.Format == 4],
|
||||
key=lambda v: len(v.AxisValueRecord),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
for val in format4:
|
||||
axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord)
|
||||
minIndex = min(axisIndexes)
|
||||
if not seenAxes & axisIndexes:
|
||||
seenAxes |= axisIndexes
|
||||
results.append((minIndex, val))
|
||||
|
||||
for val in axisValues:
|
||||
if val in format4:
|
||||
continue
|
||||
axisIndex = val.AxisIndex
|
||||
if axisIndex not in seenAxes:
|
||||
seenAxes.add(axisIndex)
|
||||
results.append((axisIndex, val))
|
||||
|
||||
return [axisValue for _, axisValue in sorted(results)]
|
||||
|
||||
|
||||
def _updateNameRecords(varfont, axisValues):
|
||||
# Update nametable based on the axisValues using the R/I/B/BI model.
|
||||
nametable = varfont["name"]
|
||||
stat = varfont["STAT"].table
|
||||
|
||||
axisValueNameIDs = [a.ValueNameID for a in axisValues]
|
||||
ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)]
|
||||
nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs]
|
||||
elidedNameID = stat.ElidedFallbackNameID
|
||||
elidedNameIsRibbi = _isRibbi(nametable, elidedNameID)
|
||||
|
||||
getName = nametable.getName
|
||||
platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names)
|
||||
for platform in platforms:
|
||||
if not all(getName(i, *platform) for i in (1, 2, elidedNameID)):
|
||||
# Since no family name and subfamily name records were found,
|
||||
# we cannot update this set of name Records.
|
||||
continue
|
||||
|
||||
subFamilyName = " ".join(
|
||||
getName(n, *platform).toUnicode() for n in ribbiNameIDs
|
||||
)
|
||||
if nonRibbiNameIDs:
|
||||
typoSubFamilyName = " ".join(
|
||||
getName(n, *platform).toUnicode() for n in axisValueNameIDs
|
||||
)
|
||||
else:
|
||||
typoSubFamilyName = None
|
||||
|
||||
# If neither subFamilyName and typographic SubFamilyName exist,
|
||||
# we will use the STAT's elidedFallbackName
|
||||
if not typoSubFamilyName and not subFamilyName:
|
||||
if elidedNameIsRibbi:
|
||||
subFamilyName = getName(elidedNameID, *platform).toUnicode()
|
||||
else:
|
||||
typoSubFamilyName = getName(elidedNameID, *platform).toUnicode()
|
||||
|
||||
familyNameSuffix = " ".join(
|
||||
getName(n, *platform).toUnicode() for n in nonRibbiNameIDs
|
||||
)
|
||||
|
||||
_updateNameTableStyleRecords(
|
||||
varfont,
|
||||
familyNameSuffix,
|
||||
subFamilyName,
|
||||
typoSubFamilyName,
|
||||
*platform,
|
||||
)
|
||||
|
||||
|
||||
def _isRibbi(nametable, nameID):
|
||||
englishRecord = nametable.getName(nameID, 3, 1, 0x409)
|
||||
return (
|
||||
True
|
||||
if englishRecord is not None
|
||||
and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic")
|
||||
else False
|
||||
)
|
||||
|
||||
|
||||
def _updateNameTableStyleRecords(
|
||||
varfont,
|
||||
familyNameSuffix,
|
||||
subFamilyName,
|
||||
typoSubFamilyName,
|
||||
platformID=3,
|
||||
platEncID=1,
|
||||
langID=0x409,
|
||||
):
|
||||
# TODO (Marc F) It may be nice to make this part a standalone
|
||||
# font renamer in the future.
|
||||
nametable = varfont["name"]
|
||||
platform = (platformID, platEncID, langID)
|
||||
|
||||
currentFamilyName = nametable.getName(
|
||||
NameID.TYPOGRAPHIC_FAMILY_NAME, *platform
|
||||
) or nametable.getName(NameID.FAMILY_NAME, *platform)
|
||||
|
||||
currentStyleName = nametable.getName(
|
||||
NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform
|
||||
) or nametable.getName(NameID.SUBFAMILY_NAME, *platform)
|
||||
|
||||
if not all([currentFamilyName, currentStyleName]):
|
||||
raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}")
|
||||
|
||||
currentFamilyName = currentFamilyName.toUnicode()
|
||||
currentStyleName = currentStyleName.toUnicode()
|
||||
|
||||
nameIDs = {
|
||||
NameID.FAMILY_NAME: currentFamilyName,
|
||||
NameID.SUBFAMILY_NAME: subFamilyName or "Regular",
|
||||
}
|
||||
if typoSubFamilyName:
|
||||
nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip()
|
||||
nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName
|
||||
nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName
|
||||
else:
|
||||
# Remove previous Typographic Family and SubFamily names since they're
|
||||
# no longer required
|
||||
for nameID in (
|
||||
NameID.TYPOGRAPHIC_FAMILY_NAME,
|
||||
NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
|
||||
):
|
||||
nametable.removeNames(nameID=nameID)
|
||||
|
||||
newFamilyName = (
|
||||
nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME]
|
||||
)
|
||||
newStyleName = (
|
||||
nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME]
|
||||
)
|
||||
|
||||
nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}"
|
||||
nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord(
|
||||
varfont, newFamilyName, newStyleName, platform
|
||||
)
|
||||
|
||||
uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform)
|
||||
if uniqueID:
|
||||
nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID
|
||||
|
||||
for nameID, string in nameIDs.items():
|
||||
assert string, nameID
|
||||
nametable.setName(string, nameID, *platform)
|
||||
|
||||
if "fvar" not in varfont:
|
||||
nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX)
|
||||
|
||||
|
||||
def _updatePSNameRecord(varfont, familyName, styleName, platform):
|
||||
# Implementation based on Adobe Technical Note #5902 :
|
||||
# https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf
|
||||
nametable = varfont["name"]
|
||||
|
||||
family_prefix = nametable.getName(
|
||||
NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform
|
||||
)
|
||||
if family_prefix:
|
||||
family_prefix = family_prefix.toUnicode()
|
||||
else:
|
||||
family_prefix = familyName
|
||||
|
||||
psName = f"{family_prefix}-{styleName}"
|
||||
# Remove any characters other than uppercase Latin letters, lowercase
|
||||
# Latin letters, digits and hyphens.
|
||||
psName = re.sub(r"[^A-Za-z0-9-]", r"", psName)
|
||||
|
||||
if len(psName) > 127:
|
||||
# Abbreviating the stylename so it fits within 127 characters whilst
|
||||
# conforming to every vendor's specification is too complex. Instead
|
||||
# we simply truncate the psname and add the required "..."
|
||||
return f"{psName[:124]}..."
|
||||
return psName
|
||||
|
||||
|
||||
def _updateUniqueIdNameRecord(varfont, nameIDs, platform):
|
||||
nametable = varfont["name"]
|
||||
currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform)
|
||||
if not currentRecord:
|
||||
return None
|
||||
|
||||
# Check if full name and postscript name are a substring of currentRecord
|
||||
for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME):
|
||||
nameRecord = nametable.getName(nameID, *platform)
|
||||
if not nameRecord:
|
||||
continue
|
||||
if nameRecord.toUnicode() in currentRecord.toUnicode():
|
||||
return currentRecord.toUnicode().replace(
|
||||
nameRecord.toUnicode(), nameIDs[nameRecord.nameID]
|
||||
)
|
||||
|
||||
# Create a new string since we couldn't find any substrings.
|
||||
fontVersion = _fontVersion(varfont, platform)
|
||||
achVendID = varfont["OS/2"].achVendID
|
||||
# Remove non-ASCII characers and trailing spaces
|
||||
vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip()
|
||||
psName = nameIDs[NameID.POSTSCRIPT_NAME]
|
||||
return f"{fontVersion};{vendor};{psName}"
|
||||
|
||||
|
||||
def _fontVersion(font, platform=(3, 1, 0x409)):
|
||||
nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform)
|
||||
if nameRecord is None:
|
||||
return f'{font["head"].fontRevision:.3f}'
|
||||
# "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101"
|
||||
# Also works fine with inputs "Version 1.101" or "1.101" etc
|
||||
versionNumber = nameRecord.toUnicode().split(";")[0]
|
||||
return versionNumber.lstrip("Version ").strip()
|
||||
@ -0,0 +1,309 @@
|
||||
from fontTools.varLib.models import supportScalar
|
||||
from fontTools.misc.fixedTools import MAX_F2DOT14
|
||||
from functools import lru_cache
|
||||
|
||||
__all__ = ["rebaseTent"]
|
||||
|
||||
EPSILON = 1 / (1 << 14)
|
||||
|
||||
|
||||
def _reverse_negate(v):
|
||||
return (-v[2], -v[1], -v[0])
|
||||
|
||||
|
||||
def _solve(tent, axisLimit, negative=False):
|
||||
axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit
|
||||
lower, peak, upper = tent
|
||||
|
||||
# Mirror the problem such that axisDef <= peak
|
||||
if axisDef > peak:
|
||||
return [
|
||||
(scalar, _reverse_negate(t) if t is not None else None)
|
||||
for scalar, t in _solve(
|
||||
_reverse_negate(tent),
|
||||
axisLimit.reverse_negate(),
|
||||
not negative,
|
||||
)
|
||||
]
|
||||
# axisDef <= peak
|
||||
|
||||
# case 1: The whole deltaset falls outside the new limit; we can drop it
|
||||
#
|
||||
# peak
|
||||
# 1.........................................o..........
|
||||
# / \
|
||||
# / \
|
||||
# / \
|
||||
# / \
|
||||
# 0---|-----------|----------|-------- o o----1
|
||||
# axisMin axisDef axisMax lower upper
|
||||
#
|
||||
if axisMax <= lower and axisMax < peak:
|
||||
return [] # No overlap
|
||||
|
||||
# case 2: Only the peak and outermost bound fall outside the new limit;
|
||||
# we keep the deltaset, update peak and outermost bound and and scale deltas
|
||||
# by the scalar value for the restricted axis at the new limit, and solve
|
||||
# recursively.
|
||||
#
|
||||
# |peak
|
||||
# 1...............................|.o..........
|
||||
# |/ \
|
||||
# / \
|
||||
# /| \
|
||||
# / | \
|
||||
# 0--------------------------- o | o----1
|
||||
# lower | upper
|
||||
# |
|
||||
# axisMax
|
||||
#
|
||||
# Convert to:
|
||||
#
|
||||
# 1............................................
|
||||
# |
|
||||
# o peak
|
||||
# /|
|
||||
# /x|
|
||||
# 0--------------------------- o o upper ----1
|
||||
# lower |
|
||||
# |
|
||||
# axisMax
|
||||
if axisMax < peak:
|
||||
mult = supportScalar({"tag": axisMax}, {"tag": tent})
|
||||
tent = (lower, axisMax, axisMax)
|
||||
return [(scalar * mult, t) for scalar, t in _solve(tent, axisLimit)]
|
||||
|
||||
# lower <= axisDef <= peak <= axisMax
|
||||
|
||||
gain = supportScalar({"tag": axisDef}, {"tag": tent})
|
||||
out = [(gain, None)]
|
||||
|
||||
# First, the positive side
|
||||
|
||||
# outGain is the scalar of axisMax at the tent.
|
||||
outGain = supportScalar({"tag": axisMax}, {"tag": tent})
|
||||
|
||||
# Case 3a: Gain is more than outGain. The tent down-slope crosses
|
||||
# the axis into negative. We have to split it into multiples.
|
||||
#
|
||||
# | peak |
|
||||
# 1...................|.o.....|..............
|
||||
# |/x\_ |
|
||||
# gain................+....+_.|..............
|
||||
# /| |y\|
|
||||
# ................../.|....|..+_......outGain
|
||||
# / | | | \
|
||||
# 0---|-----------o | | | o----------1
|
||||
# axisMin lower | | | upper
|
||||
# | | |
|
||||
# axisDef | axisMax
|
||||
# |
|
||||
# crossing
|
||||
if gain >= outGain:
|
||||
# Note that this is the branch taken if both gain and outGain are 0.
|
||||
|
||||
# Crossing point on the axis.
|
||||
crossing = peak + (1 - gain) * (upper - peak)
|
||||
|
||||
loc = (max(lower, axisDef), peak, crossing)
|
||||
scalar = 1
|
||||
|
||||
# The part before the crossing point.
|
||||
out.append((scalar - gain, loc))
|
||||
|
||||
# The part after the crossing point may use one or two tents,
|
||||
# depending on whether upper is before axisMax or not, in one
|
||||
# case we need to keep it down to eternity.
|
||||
|
||||
# Case 3a1, similar to case 1neg; just one tent needed, as in
|
||||
# the drawing above.
|
||||
if upper >= axisMax:
|
||||
loc = (crossing, axisMax, axisMax)
|
||||
scalar = outGain
|
||||
|
||||
out.append((scalar - gain, loc))
|
||||
|
||||
# Case 3a2: Similar to case 2neg; two tents needed, to keep
|
||||
# down to eternity.
|
||||
#
|
||||
# | peak |
|
||||
# 1...................|.o................|...
|
||||
# |/ \_ |
|
||||
# gain................+....+_............|...
|
||||
# /| | \xxxxxxxxxxy|
|
||||
# / | | \_xxxxxyyyy|
|
||||
# / | | \xxyyyyyy|
|
||||
# 0---|-----------o | | o-------|--1
|
||||
# axisMin lower | | upper |
|
||||
# | | |
|
||||
# axisDef | axisMax
|
||||
# |
|
||||
# crossing
|
||||
else:
|
||||
# A tent's peak cannot fall on axis default. Nudge it.
|
||||
if upper == axisDef:
|
||||
upper += EPSILON
|
||||
|
||||
# Downslope.
|
||||
loc1 = (crossing, upper, axisMax)
|
||||
scalar1 = 0
|
||||
|
||||
# Eternity justify.
|
||||
loc2 = (upper, axisMax, axisMax)
|
||||
scalar2 = 0
|
||||
|
||||
out.append((scalar1 - gain, loc1))
|
||||
out.append((scalar2 - gain, loc2))
|
||||
|
||||
else:
|
||||
# Special-case if peak is at axisMax.
|
||||
if axisMax == peak:
|
||||
upper = peak
|
||||
|
||||
# Case 3:
|
||||
# We keep delta as is and only scale the axis upper to achieve
|
||||
# the desired new tent if feasible.
|
||||
#
|
||||
# peak
|
||||
# 1.....................o....................
|
||||
# / \_|
|
||||
# ..................../....+_.........outGain
|
||||
# / | \
|
||||
# gain..............+......|..+_.............
|
||||
# /| | | \
|
||||
# 0---|-----------o | | | o----------1
|
||||
# axisMin lower| | | upper
|
||||
# | | newUpper
|
||||
# axisDef axisMax
|
||||
#
|
||||
newUpper = peak + (1 - gain) * (upper - peak)
|
||||
assert axisMax <= newUpper # Because outGain > gain
|
||||
# Disabled because ots doesn't like us:
|
||||
# https://github.com/fonttools/fonttools/issues/3350
|
||||
if False and newUpper <= axisDef + (axisMax - axisDef) * 2:
|
||||
upper = newUpper
|
||||
if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper:
|
||||
# we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience
|
||||
upper = axisDef + (axisMax - axisDef) * MAX_F2DOT14
|
||||
assert peak < upper
|
||||
|
||||
loc = (max(axisDef, lower), peak, upper)
|
||||
scalar = 1
|
||||
|
||||
out.append((scalar - gain, loc))
|
||||
|
||||
# Case 4: New limit doesn't fit; we need to chop into two tents,
|
||||
# because the shape of a triangle with part of one side cut off
|
||||
# cannot be represented as a triangle itself.
|
||||
#
|
||||
# | peak |
|
||||
# 1.........|......o.|....................
|
||||
# ..........|...../x\|.............outGain
|
||||
# | |xxy|\_
|
||||
# | /xxxy| \_
|
||||
# | |xxxxy| \_
|
||||
# | /xxxxy| \_
|
||||
# 0---|-----|-oxxxxxx| o----------1
|
||||
# axisMin | lower | upper
|
||||
# | |
|
||||
# axisDef axisMax
|
||||
#
|
||||
else:
|
||||
loc1 = (max(axisDef, lower), peak, axisMax)
|
||||
scalar1 = 1
|
||||
|
||||
loc2 = (peak, axisMax, axisMax)
|
||||
scalar2 = outGain
|
||||
|
||||
out.append((scalar1 - gain, loc1))
|
||||
# Don't add a dirac delta!
|
||||
if peak < axisMax:
|
||||
out.append((scalar2 - gain, loc2))
|
||||
|
||||
# Now, the negative side
|
||||
|
||||
# Case 1neg: Lower extends beyond axisMin: we chop. Simple.
|
||||
#
|
||||
# | |peak
|
||||
# 1..................|...|.o.................
|
||||
# | |/ \
|
||||
# gain...............|...+...\...............
|
||||
# |x_/| \
|
||||
# |/ | \
|
||||
# _/| | \
|
||||
# 0---------------o | | o----------1
|
||||
# lower | | upper
|
||||
# | |
|
||||
# axisMin axisDef
|
||||
#
|
||||
if lower <= axisMin:
|
||||
loc = (axisMin, axisMin, axisDef)
|
||||
scalar = supportScalar({"tag": axisMin}, {"tag": tent})
|
||||
|
||||
out.append((scalar - gain, loc))
|
||||
|
||||
# Case 2neg: Lower is betwen axisMin and axisDef: we add two
|
||||
# tents to keep it down all the way to eternity.
|
||||
#
|
||||
# | |peak
|
||||
# 1...|...............|.o.................
|
||||
# | |/ \
|
||||
# gain|...............+...\...............
|
||||
# |yxxxxxxxxxxxxx/| \
|
||||
# |yyyyyyxxxxxxx/ | \
|
||||
# |yyyyyyyyyyyx/ | \
|
||||
# 0---|-----------o | o----------1
|
||||
# axisMin lower | upper
|
||||
# |
|
||||
# axisDef
|
||||
#
|
||||
else:
|
||||
# A tent's peak cannot fall on axis default. Nudge it.
|
||||
if lower == axisDef:
|
||||
lower -= EPSILON
|
||||
|
||||
# Downslope.
|
||||
loc1 = (axisMin, lower, axisDef)
|
||||
scalar1 = 0
|
||||
|
||||
# Eternity justify.
|
||||
loc2 = (axisMin, axisMin, lower)
|
||||
scalar2 = 0
|
||||
|
||||
out.append((scalar1 - gain, loc1))
|
||||
out.append((scalar2 - gain, loc2))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@lru_cache(128)
|
||||
def rebaseTent(tent, axisLimit):
|
||||
"""Given a tuple (lower,peak,upper) "tent" and new axis limits
|
||||
(axisMin,axisDefault,axisMax), solves how to represent the tent
|
||||
under the new axis configuration. All values are in normalized
|
||||
-1,0,+1 coordinate system. Tent values can be outside this range.
|
||||
|
||||
Return value is a list of tuples. Each tuple is of the form
|
||||
(scalar,tent), where scalar is a multipler to multiply any
|
||||
delta-sets by, and tent is a new tent for that output delta-set.
|
||||
If tent value is None, that is a special deltaset that should
|
||||
be always-enabled (called "gain")."""
|
||||
|
||||
axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit
|
||||
assert -1 <= axisMin <= axisDef <= axisMax <= +1
|
||||
|
||||
lower, peak, upper = tent
|
||||
assert -2 <= lower <= peak <= upper <= +2
|
||||
|
||||
assert peak != 0
|
||||
|
||||
sols = _solve(tent, axisLimit)
|
||||
|
||||
n = lambda v: axisLimit.renormalizeValue(v)
|
||||
sols = [
|
||||
(scalar, (n(v[0]), n(v[1]), n(v[2])) if v is not None else None)
|
||||
for scalar, v in sols
|
||||
if scalar
|
||||
]
|
||||
|
||||
return sols
|
||||
Reference in New Issue
Block a user