12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937 |
- """ Partially instantiate a variable font.
- The module exports an `instantiateVariableFont` function and CLI that allow to
- create full instances (i.e. static fonts) from variable fonts, as well as "partial"
- variable fonts that only contain a subset of the original variation space.
- For example, if you wish to pin the width axis to a given location while also
- restricting the weight axis to 400..700 range, you can do:
- .. code-block:: sh
- $ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85 wght=400:700
- See `fonttools varLib.instancer --help` for more info on the CLI options.
- The module's entry point is the `instantiateVariableFont` function, which takes
- a TTFont object and a dict specifying either axis coodinates or (min, max) ranges,
- and returns a new TTFont representing either a partial VF, or full instance if all
- the VF axes were given an explicit coordinate.
- E.g. here's how to pin the wght axis at a given location in a wght+wdth variable
- font, keeping only the deltas associated with the wdth axis:
- .. code-block:: pycon
- >>>
- >> from fontTools import ttLib
- >> from fontTools.varLib import instancer
- >> varfont = ttLib.TTFont("path/to/MyVariableFont.ttf")
- >> [a.axisTag for a in varfont["fvar"].axes] # the varfont's current axes
- ['wght', 'wdth']
- >> partial = instancer.instantiateVariableFont(varfont, {"wght": 300})
- >> [a.axisTag for a in partial["fvar"].axes] # axes left after pinning 'wght'
- ['wdth']
- If the input location specifies all the axes, the resulting instance is no longer
- 'variable' (same as using fontools varLib.mutator):
- .. code-block:: pycon
- >>>
- >> instance = instancer.instantiateVariableFont(
- ... varfont, {"wght": 700, "wdth": 67.5}
- ... )
- >> "fvar" not in instance
- True
- If one just want to drop an axis at the default location, without knowing in
- advance what the default value for that axis is, one can pass a `None` value:
- .. code-block:: pycon
- >>>
- >> instance = instancer.instantiateVariableFont(varfont, {"wght": None})
- >> len(varfont["fvar"].axes)
- 1
- From the console script, this is equivalent to passing `wght=drop` as input.
- This module is similar to fontTools.varLib.mutator, which it's intended to supersede.
- Note that, unlike varLib.mutator, when an axis is not mentioned in the input
- location, the varLib.instancer will keep the axis and the corresponding deltas,
- whereas mutator implicitly drops the axis at its default coordinate.
- The module supports all the following "levels" of instancing, which can of
- course be combined:
- L1
- dropping one or more axes while leaving the default tables unmodified;
- .. code-block:: pycon
- >>>
- >> font = instancer.instantiateVariableFont(varfont, {"wght": None})
- L2
- dropping one or more axes while pinning them at non-default locations;
- .. code-block:: pycon
-
- >>>
- >> font = instancer.instantiateVariableFont(varfont, {"wght": 700})
- L3
- restricting the range of variation of one or more axes, by setting either
- a new minimum or maximum, potentially -- though not necessarily -- dropping
- entire regions of variations that fall completely outside this new range.
- .. code-block:: pycon
-
- >>>
- >> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300)})
- L4
- moving the default location of an axis, by specifying (min,defalt,max) values:
- .. code-block:: pycon
-
- >>>
- >> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300, 700)})
- Currently only TrueType-flavored variable fonts (i.e. containing 'glyf' table)
- are supported, but support for CFF2 variable fonts will be added soon.
- The discussion and implementation of these features are tracked at
- https://github.com/fonttools/fonttools/issues/1537
- """
- from fontTools.misc.fixedTools import (
- floatToFixedToFloat,
- strToFixedToFloat,
- otRound,
- )
- from fontTools.varLib.models import normalizeValue, piecewiseLinearMap
- from fontTools.ttLib import TTFont, newTable
- from fontTools.ttLib.tables.TupleVariation import TupleVariation
- from fontTools.ttLib.tables import _g_l_y_f
- from fontTools import varLib
- # we import the `subset` module because we use the `prune_lookups` method on the GSUB
- # table class, and that method is only defined dynamically upon importing `subset`
- from fontTools import subset # noqa: F401
- from fontTools.cffLib import privateDictOperators2
- from fontTools.cffLib.specializer import (
- programToCommands,
- commandsToProgram,
- specializeCommands,
- generalizeCommands,
- )
- from fontTools.varLib import builder
- from fontTools.varLib.mvar import MVAR_ENTRIES
- from fontTools.varLib.merger import MutatorMerger
- from fontTools.varLib.instancer import names
- from .featureVars import instantiateFeatureVariations
- from fontTools.misc.cliTools import makeOutputFileName
- from fontTools.varLib.instancer import solver
- from fontTools.ttLib.tables.otTables import VarComponentFlags
- import collections
- import dataclasses
- from contextlib import contextmanager
- from copy import deepcopy
- from enum import IntEnum
- import logging
- import os
- import re
- from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union
- import warnings
- log = logging.getLogger("fontTools.varLib.instancer")
- def AxisRange(minimum, maximum):
- warnings.warn(
- "AxisRange is deprecated; use AxisTriple instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return AxisTriple(minimum, None, maximum)
- def NormalizedAxisRange(minimum, maximum):
- warnings.warn(
- "NormalizedAxisRange is deprecated; use AxisTriple instead",
- DeprecationWarning,
- stacklevel=2,
- )
- return NormalizedAxisTriple(minimum, None, maximum)
- @dataclasses.dataclass(frozen=True, order=True, repr=False)
- class AxisTriple(Sequence):
- """A triple of (min, default, max) axis values.
- Any of the values can be None, in which case the limitRangeAndPopulateDefaults()
- method can be used to fill in the missing values based on the fvar axis values.
- """
- minimum: Optional[float]
- default: Optional[float]
- maximum: Optional[float]
- def __post_init__(self):
- if self.default is None and self.minimum == self.maximum:
- object.__setattr__(self, "default", self.minimum)
- if (
- (
- self.minimum is not None
- and self.default is not None
- and self.minimum > self.default
- )
- or (
- self.default is not None
- and self.maximum is not None
- and self.default > self.maximum
- )
- or (
- self.minimum is not None
- and self.maximum is not None
- and self.minimum > self.maximum
- )
- ):
- raise ValueError(
- f"{type(self).__name__} minimum ({self.minimum}), default ({self.default}), maximum ({self.maximum}) must be in sorted order"
- )
- def __getitem__(self, i):
- fields = dataclasses.fields(self)
- return getattr(self, fields[i].name)
- def __len__(self):
- return len(dataclasses.fields(self))
- def _replace(self, **kwargs):
- return dataclasses.replace(self, **kwargs)
- def __repr__(self):
- return (
- f"({', '.join(format(v, 'g') if v is not None else 'None' for v in self)})"
- )
- @classmethod
- def expand(
- cls,
- v: Union[
- "AxisTriple",
- float, # pin axis at single value, same as min==default==max
- Tuple[float, float], # (min, max), restrict axis and keep default
- Tuple[float, float, float], # (min, default, max)
- ],
- ) -> "AxisTriple":
- """Convert a single value or a tuple into an AxisTriple.
- If the input is a single value, it is interpreted as a pin at that value.
- If the input is a tuple, it is interpreted as (min, max) or (min, default, max).
- """
- if isinstance(v, cls):
- return v
- if isinstance(v, (int, float)):
- return cls(v, v, v)
- try:
- n = len(v)
- except TypeError as e:
- raise ValueError(
- f"expected float, 2- or 3-tuple of floats; got {type(v)}: {v!r}"
- ) from e
- default = None
- if n == 2:
- minimum, maximum = v
- elif n >= 3:
- return cls(*v)
- else:
- raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}")
- return cls(minimum, default, maximum)
- def limitRangeAndPopulateDefaults(self, fvarTriple) -> "AxisTriple":
- """Return a new AxisTriple with the default value filled in.
- Set default to fvar axis default if the latter is within the min/max range,
- otherwise set default to the min or max value, whichever is closer to the
- fvar axis default.
- If the default value is already set, return self.
- """
- minimum = self.minimum
- if minimum is None:
- minimum = fvarTriple[0]
- default = self.default
- if default is None:
- default = fvarTriple[1]
- maximum = self.maximum
- if maximum is None:
- maximum = fvarTriple[2]
- minimum = max(minimum, fvarTriple[0])
- maximum = max(maximum, fvarTriple[0])
- minimum = min(minimum, fvarTriple[2])
- maximum = min(maximum, fvarTriple[2])
- default = max(minimum, min(maximum, default))
- return AxisTriple(minimum, default, maximum)
- @dataclasses.dataclass(frozen=True, order=True, repr=False)
- class NormalizedAxisTriple(AxisTriple):
- """A triple of (min, default, max) normalized axis values."""
- minimum: float
- default: float
- maximum: float
- def __post_init__(self):
- if self.default is None:
- object.__setattr__(self, "default", max(self.minimum, min(self.maximum, 0)))
- if not (-1.0 <= self.minimum <= self.default <= self.maximum <= 1.0):
- raise ValueError(
- "Normalized axis values not in -1..+1 range; got "
- f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})"
- )
- @dataclasses.dataclass(frozen=True, order=True, repr=False)
- class NormalizedAxisTripleAndDistances(AxisTriple):
- """A triple of (min, default, max) normalized axis values,
- with distances between min and default, and default and max,
- in the *pre-normalized* space."""
- minimum: float
- default: float
- maximum: float
- distanceNegative: Optional[float] = 1
- distancePositive: Optional[float] = 1
- def __post_init__(self):
- if self.default is None:
- object.__setattr__(self, "default", max(self.minimum, min(self.maximum, 0)))
- if not (-1.0 <= self.minimum <= self.default <= self.maximum <= 1.0):
- raise ValueError(
- "Normalized axis values not in -1..+1 range; got "
- f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})"
- )
- def reverse_negate(self):
- v = self
- return self.__class__(-v[2], -v[1], -v[0], v[4], v[3])
- def renormalizeValue(self, v, extrapolate=True):
- """Renormalizes a normalized value v to the range of this axis,
- considering the pre-normalized distances as well as the new
- axis limits."""
- lower, default, upper, distanceNegative, distancePositive = self
- assert lower <= default <= upper
- if not extrapolate:
- v = max(lower, min(upper, v))
- if v == default:
- return 0
- if default < 0:
- return -self.reverse_negate().renormalizeValue(-v, extrapolate=extrapolate)
- # default >= 0 and v != default
- if v > default:
- return (v - default) / (upper - default)
- # v < default
- if lower >= 0:
- return (v - default) / (default - lower)
- # lower < 0 and v < default
- totalDistance = distanceNegative * -lower + distancePositive * default
- if v >= 0:
- vDistance = (default - v) * distancePositive
- else:
- vDistance = -v * distanceNegative + distancePositive * default
- return -vDistance / totalDistance
- class _BaseAxisLimits(Mapping[str, AxisTriple]):
- def __getitem__(self, key: str) -> AxisTriple:
- return self._data[key]
- def __iter__(self) -> Iterable[str]:
- return iter(self._data)
- def __len__(self) -> int:
- return len(self._data)
- def __repr__(self) -> str:
- return f"{type(self).__name__}({self._data!r})"
- def __str__(self) -> str:
- return str(self._data)
- def defaultLocation(self) -> Dict[str, float]:
- """Return a dict of default axis values."""
- return {k: v.default for k, v in self.items()}
- def pinnedLocation(self) -> Dict[str, float]:
- """Return a location dict with only the pinned axes."""
- return {k: v.default for k, v in self.items() if v.minimum == v.maximum}
- class AxisLimits(_BaseAxisLimits):
- """Maps axis tags (str) to AxisTriple values."""
- def __init__(self, *args, **kwargs):
- self._data = data = {}
- for k, v in dict(*args, **kwargs).items():
- if v is None:
- # will be filled in by limitAxesAndPopulateDefaults
- data[k] = v
- else:
- try:
- triple = AxisTriple.expand(v)
- except ValueError as e:
- raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e
- data[k] = triple
- def limitAxesAndPopulateDefaults(self, varfont) -> "AxisLimits":
- """Return a new AxisLimits with defaults filled in from fvar table.
- If all axis limits already have defaults, return self.
- """
- fvar = varfont["fvar"]
- fvarTriples = {
- a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes
- }
- newLimits = {}
- for axisTag, triple in self.items():
- fvarTriple = fvarTriples[axisTag]
- default = fvarTriple[1]
- if triple is None:
- newLimits[axisTag] = AxisTriple(default, default, default)
- else:
- newLimits[axisTag] = triple.limitRangeAndPopulateDefaults(fvarTriple)
- return type(self)(newLimits)
- def normalize(self, varfont, usingAvar=True) -> "NormalizedAxisLimits":
- """Return a new NormalizedAxisLimits with normalized -1..0..+1 values.
- If usingAvar is True, the avar table is used to warp the default normalization.
- """
- fvar = varfont["fvar"]
- badLimits = set(self.keys()).difference(a.axisTag for a in fvar.axes)
- if badLimits:
- raise ValueError("Cannot limit: {} not present in fvar".format(badLimits))
- axes = {
- a.axisTag: (a.minValue, a.defaultValue, a.maxValue)
- for a in fvar.axes
- if a.axisTag in self
- }
- avarSegments = {}
- if usingAvar and "avar" in varfont:
- avarSegments = varfont["avar"].segments
- normalizedLimits = {}
- for axis_tag, triple in axes.items():
- distanceNegative = triple[1] - triple[0]
- distancePositive = triple[2] - triple[1]
- if self[axis_tag] is None:
- normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances(
- 0, 0, 0, distanceNegative, distancePositive
- )
- continue
- minV, defaultV, maxV = self[axis_tag]
- if defaultV is None:
- defaultV = triple[1]
- avarMapping = avarSegments.get(axis_tag, None)
- normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances(
- *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)),
- distanceNegative,
- distancePositive,
- )
- return NormalizedAxisLimits(normalizedLimits)
- class NormalizedAxisLimits(_BaseAxisLimits):
- """Maps axis tags (str) to NormalizedAxisTriple values."""
- def __init__(self, *args, **kwargs):
- self._data = data = {}
- for k, v in dict(*args, **kwargs).items():
- try:
- triple = NormalizedAxisTripleAndDistances.expand(v)
- except ValueError as e:
- raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e
- data[k] = triple
- class OverlapMode(IntEnum):
- KEEP_AND_DONT_SET_FLAGS = 0
- KEEP_AND_SET_FLAGS = 1
- REMOVE = 2
- REMOVE_AND_IGNORE_ERRORS = 3
- def instantiateVARC(varfont, axisLimits):
- log.info("Instantiating VARC tables")
- # TODO(behdad) My confidence in this function is rather low;
- # It needs more testing. Specially with partial-instancing,
- # I don't think it currently works.
- varc = varfont["VARC"].table
- fvarAxes = varfont["fvar"].axes if "fvar" in varfont else []
- location = axisLimits.pinnedLocation()
- axisMap = [i for i, axis in enumerate(fvarAxes) if axis.axisTag not in location]
- reverseAxisMap = {i: j for j, i in enumerate(axisMap)}
- if varc.AxisIndicesList:
- axisIndicesList = varc.AxisIndicesList.Item
- for i, axisIndices in enumerate(axisIndicesList):
- if any(fvarAxes[j].axisTag in axisLimits for j in axisIndices):
- raise NotImplementedError(
- "Instancing across VarComponent axes is not supported."
- )
- axisIndicesList[i] = [reverseAxisMap[j] for j in axisIndices]
- store = varc.MultiVarStore
- if store:
- for region in store.SparseVarRegionList.Region:
- newRegionAxis = []
- for regionRecord in region.SparseVarRegionAxis:
- tag = fvarAxes[regionRecord.AxisIndex].axisTag
- if tag in axisLimits:
- raise NotImplementedError(
- "Instancing across VarComponent axes is not supported."
- )
- regionRecord.AxisIndex = reverseAxisMap[regionRecord.AxisIndex]
- def instantiateTupleVariationStore(
- variations, axisLimits, origCoords=None, endPts=None
- ):
- """Instantiate TupleVariation list at the given location, or limit axes' min/max.
- The 'variations' list of TupleVariation objects is modified in-place.
- The 'axisLimits' (dict) maps axis tags (str) to NormalizedAxisTriple namedtuples
- specifying (minimum, default, maximum) in the -1,0,+1 normalized space. Pinned axes
- have minimum == default == maximum.
- A 'full' instance (i.e. static font) is produced when all the axes are pinned to
- single coordinates; a 'partial' instance (i.e. a less variable font) is produced
- when some of the axes are omitted, or restricted with a new range.
- Tuples that do not participate are kept as they are. Those that have 0 influence
- at the given location are removed from the variation store.
- Those that are fully instantiated (i.e. all their axes are being pinned) are also
- removed from the variation store, their scaled deltas accummulated and returned, so
- that they can be added by the caller to the default instance's coordinates.
- Tuples that are only partially instantiated (i.e. not all the axes that they
- participate in are being pinned) are kept in the store, and their deltas multiplied
- by the scalar support of the axes to be pinned at the desired location.
- Args:
- variations: List[TupleVariation] from either 'gvar' or 'cvar'.
- axisLimits: NormalizedAxisLimits: map from axis tags to (min, default, max)
- normalized coordinates for the full or partial instance.
- origCoords: GlyphCoordinates: default instance's coordinates for computing 'gvar'
- inferred points (cf. table__g_l_y_f._getCoordinatesAndControls).
- endPts: List[int]: indices of contour end points, for inferring 'gvar' deltas.
- Returns:
- List[float]: the overall delta adjustment after applicable deltas were summed.
- """
- newVariations = changeTupleVariationsAxisLimits(variations, axisLimits)
- mergedVariations = collections.OrderedDict()
- for var in newVariations:
- # compute inferred deltas only for gvar ('origCoords' is None for cvar)
- if origCoords is not None:
- var.calcInferredDeltas(origCoords, endPts)
- # merge TupleVariations with overlapping "tents"
- axes = frozenset(var.axes.items())
- if axes in mergedVariations:
- mergedVariations[axes] += var
- else:
- mergedVariations[axes] = var
- # drop TupleVariation if all axes have been pinned (var.axes.items() is empty);
- # its deltas will be added to the default instance's coordinates
- defaultVar = mergedVariations.pop(frozenset(), None)
- for var in mergedVariations.values():
- var.roundDeltas()
- variations[:] = list(mergedVariations.values())
- return defaultVar.coordinates if defaultVar is not None else []
- def changeTupleVariationsAxisLimits(variations, axisLimits):
- for axisTag, axisLimit in sorted(axisLimits.items()):
- newVariations = []
- for var in variations:
- newVariations.extend(changeTupleVariationAxisLimit(var, axisTag, axisLimit))
- variations = newVariations
- return variations
- def changeTupleVariationAxisLimit(var, axisTag, axisLimit):
- assert isinstance(axisLimit, NormalizedAxisTripleAndDistances)
- # Skip when current axis is missing or peaks at 0 (i.e. doesn't participate)
- lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1))
- if peak == 0:
- # explicitly defined, no-op axes can be omitted
- # https://github.com/fonttools/fonttools/issues/3453
- if axisTag in var.axes:
- del var.axes[axisTag]
- return [var]
- # Drop if the var 'tent' isn't well-formed
- if not (lower <= peak <= upper) or (lower < 0 and upper > 0):
- return []
- if axisTag not in var.axes:
- return [var]
- tent = var.axes[axisTag]
- solutions = solver.rebaseTent(tent, axisLimit)
- out = []
- for scalar, tent in solutions:
- newVar = (
- TupleVariation(var.axes, var.coordinates) if len(solutions) > 1 else var
- )
- if tent is None:
- newVar.axes.pop(axisTag)
- else:
- assert tent[1] != 0, tent
- newVar.axes[axisTag] = tent
- newVar *= scalar
- out.append(newVar)
- return out
- def instantiateCFF2(
- varfont,
- axisLimits,
- *,
- round=round,
- specialize=True,
- generalize=False,
- downgrade=False,
- ):
- # The algorithm here is rather simple:
- #
- # Take all blend operations and store their deltas in the (otherwise empty)
- # CFF2 VarStore. Then, instantiate the VarStore with the given axis limits,
- # and read back the new deltas. This is done for both the CharStrings and
- # the Private dicts.
- #
- # Then prune unused things and possibly drop the VarStore if it's empty.
- # In which case, downgrade to CFF table if requested.
- log.info("Instantiating CFF2 table")
- fvarAxes = varfont["fvar"].axes
- cff = varfont["CFF2"].cff
- topDict = cff.topDictIndex[0]
- varStore = topDict.VarStore.otVarStore
- if not varStore:
- if downgrade:
- from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF
- convertCFF2ToCFF(varfont)
- return
- cff.desubroutinize()
- def getNumRegions(vsindex):
- return varStore.VarData[vsindex if vsindex is not None else 0].VarRegionCount
- charStrings = topDict.CharStrings.values()
- # Gather all unique private dicts
- uniquePrivateDicts = set()
- privateDicts = []
- for fd in topDict.FDArray:
- if fd.Private not in uniquePrivateDicts:
- uniquePrivateDicts.add(fd.Private)
- privateDicts.append(fd.Private)
- allCommands = []
- for cs in charStrings:
- assert cs.private.vstore.otVarStore is varStore # Or in many places!!
- commands = programToCommands(cs.program, getNumRegions=getNumRegions)
- if generalize:
- commands = generalizeCommands(commands)
- if specialize:
- commands = specializeCommands(commands, generalizeFirst=not generalize)
- allCommands.append(commands)
- def storeBlendsToVarStore(arg):
- if not isinstance(arg, list):
- return
- if any(isinstance(subarg, list) for subarg in arg[:-1]):
- raise NotImplementedError("Nested blend lists not supported (yet)")
- count = arg[-1]
- assert (len(arg) - 1) % count == 0
- nRegions = (len(arg) - 1) // count - 1
- assert nRegions == getNumRegions(vsindex)
- for i in range(count, len(arg) - 1, nRegions):
- deltas = arg[i : i + nRegions]
- assert len(deltas) == nRegions
- varData = varStore.VarData[vsindex]
- varData.Item.append(deltas)
- varData.ItemCount += 1
- def fetchBlendsFromVarStore(arg):
- if not isinstance(arg, list):
- return [arg]
- if any(isinstance(subarg, list) for subarg in arg[:-1]):
- raise NotImplementedError("Nested blend lists not supported (yet)")
- count = arg[-1]
- assert (len(arg) - 1) % count == 0
- numRegions = getNumRegions(vsindex)
- newDefaults = []
- newDeltas = []
- for i in range(count):
- defaultValue = arg[i]
- major = vsindex
- minor = varDataCursor[major]
- varDataCursor[major] += 1
- varIdx = (major << 16) + minor
- defaultValue += round(defaultDeltas[varIdx])
- newDefaults.append(defaultValue)
- varData = varStore.VarData[major]
- deltas = varData.Item[minor]
- assert len(deltas) == numRegions
- newDeltas.extend(deltas)
- if not numRegions:
- return newDefaults # No deltas, just return the defaults
- return [newDefaults + newDeltas + [count]]
- # Check VarData's are empty
- for varData in varStore.VarData:
- assert varData.Item == []
- assert varData.ItemCount == 0
- # Add charstring blend lists to VarStore so we can instantiate them
- for commands in allCommands:
- vsindex = 0
- for command in commands:
- if command[0] == "vsindex":
- vsindex = command[1][0]
- continue
- for arg in command[1]:
- storeBlendsToVarStore(arg)
- # Add private blend lists to VarStore so we can instantiate values
- vsindex = 0
- for opcode, name, arg_type, default, converter in privateDictOperators2:
- if arg_type not in ("number", "delta", "array"):
- continue
- vsindex = 0
- for private in privateDicts:
- if not hasattr(private, name):
- continue
- values = getattr(private, name)
- if name == "vsindex":
- vsindex = values[0]
- continue
- if arg_type == "number":
- values = [values]
- for value in values:
- if not isinstance(value, list):
- continue
- assert len(value) % (getNumRegions(vsindex) + 1) == 0
- count = len(value) // (getNumRegions(vsindex) + 1)
- storeBlendsToVarStore(value + [count])
- # Instantiate VarStore
- defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
- # Read back new charstring blends from the instantiated VarStore
- varDataCursor = [0] * len(varStore.VarData)
- for commands in allCommands:
- vsindex = 0
- for command in commands:
- if command[0] == "vsindex":
- vsindex = command[1][0]
- continue
- newArgs = []
- for arg in command[1]:
- newArgs.extend(fetchBlendsFromVarStore(arg))
- command[1][:] = newArgs
- # Read back new private blends from the instantiated VarStore
- for opcode, name, arg_type, default, converter in privateDictOperators2:
- if arg_type not in ("number", "delta", "array"):
- continue
- for private in privateDicts:
- if not hasattr(private, name):
- continue
- values = getattr(private, name)
- if arg_type == "number":
- values = [values]
- newValues = []
- for value in values:
- if not isinstance(value, list):
- newValues.append(value)
- continue
- value.append(1)
- value = fetchBlendsFromVarStore(value)
- newValues.extend(v[:-1] if isinstance(v, list) else v for v in value)
- if arg_type == "number":
- newValues = newValues[0]
- setattr(private, name, newValues)
- # Empty out the VarStore
- for i, varData in enumerate(varStore.VarData):
- assert varDataCursor[i] == varData.ItemCount, (
- varDataCursor[i],
- varData.ItemCount,
- )
- varData.Item = []
- varData.ItemCount = 0
- # Remove vsindex commands that are no longer needed, collect those that are.
- usedVsindex = set()
- for commands in allCommands:
- if any(isinstance(arg, list) for command in commands for arg in command[1]):
- vsindex = 0
- for command in commands:
- if command[0] == "vsindex":
- vsindex = command[1][0]
- continue
- if any(isinstance(arg, list) for arg in command[1]):
- usedVsindex.add(vsindex)
- else:
- commands[:] = [command for command in commands if command[0] != "vsindex"]
- # Remove unused VarData and update vsindex values
- vsindexMapping = {v: i for i, v in enumerate(sorted(usedVsindex))}
- varStore.VarData = [
- varData for i, varData in enumerate(varStore.VarData) if i in usedVsindex
- ]
- varStore.VarDataCount = len(varStore.VarData)
- for commands in allCommands:
- for command in commands:
- if command[0] == "vsindex":
- command[1][0] = vsindexMapping[command[1][0]]
- # Remove initial vsindex commands that are implied
- for commands in allCommands:
- if commands and commands[0] == ("vsindex", [0]):
- commands.pop(0)
- # Ship the charstrings!
- for cs, commands in zip(charStrings, allCommands):
- cs.program = commandsToProgram(commands)
- # Remove empty VarStore
- if not varStore.VarData:
- if "VarStore" in topDict.rawDict:
- del topDict.rawDict["VarStore"]
- del topDict.VarStore
- del topDict.CharStrings.varStore
- for private in privateDicts:
- del private.vstore
- if downgrade:
- from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF
- convertCFF2ToCFF(varfont)
- def _instantiateGvarGlyph(
- glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=True
- ):
- coordinates, ctrl = glyf._getCoordinatesAndControls(glyphname, hMetrics, vMetrics)
- endPts = ctrl.endPts
- # Not every glyph may have variations
- tupleVarStore = gvar.variations.get(glyphname)
- if tupleVarStore:
- defaultDeltas = instantiateTupleVariationStore(
- tupleVarStore, axisLimits, coordinates, endPts
- )
- if defaultDeltas:
- coordinates += _g_l_y_f.GlyphCoordinates(defaultDeltas)
- # _setCoordinates also sets the hmtx/vmtx advance widths and sidebearings from
- # the four phantom points and glyph bounding boxes.
- # We call it unconditionally even if a glyph has no variations or no deltas are
- # applied at this location, in case the glyph's xMin and in turn its sidebearing
- # have changed. E.g. a composite glyph has no deltas for the component's (x, y)
- # offset nor for the 4 phantom points (e.g. it's monospaced). Thus its entry in
- # gvar table is empty; however, the composite's base glyph may have deltas
- # applied, hence the composite's bbox and left/top sidebearings may need updating
- # in the instanced font.
- glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics)
- if not tupleVarStore:
- if glyphname in gvar.variations:
- del gvar.variations[glyphname]
- return
- if optimize:
- # IUP semantics depend on point equality, and so round prior to
- # optimization to ensure that comparisons that happen now will be the
- # same as those that happen at render time. This is especially needed
- # when floating point deltas have been applied to the default position.
- # See https://github.com/fonttools/fonttools/issues/3634
- # Rounding must happen only after calculating glyf metrics above, to
- # preserve backwards compatibility.
- # See 0010a3cd9aa25f84a3a6250dafb119743d32aa40
- coordinates.toInt()
- isComposite = glyf[glyphname].isComposite()
- for var in tupleVarStore:
- var.optimize(coordinates, endPts, isComposite=isComposite)
- def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True):
- """Remove?
- https://github.com/fonttools/fonttools/pull/2266"""
- gvar = varfont["gvar"]
- glyf = varfont["glyf"]
- hMetrics = varfont["hmtx"].metrics
- vMetrics = getattr(varfont.get("vmtx"), "metrics", None)
- _instantiateGvarGlyph(
- glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=optimize
- )
- def instantiateGvar(varfont, axisLimits, optimize=True):
- log.info("Instantiating glyf/gvar tables")
- gvar = varfont["gvar"]
- glyf = varfont["glyf"]
- hMetrics = varfont["hmtx"].metrics
- vMetrics = getattr(varfont.get("vmtx"), "metrics", None)
- # Get list of glyph names sorted by component depth.
- # If a composite glyph is processed before its base glyph, the bounds may
- # be calculated incorrectly because deltas haven't been applied to the
- # base glyph yet.
- glyphnames = sorted(
- glyf.glyphOrder,
- key=lambda name: (
- (
- glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
- if glyf[name].isComposite()
- else 0
- ),
- name,
- ),
- )
- for glyphname in glyphnames:
- _instantiateGvarGlyph(
- glyphname, glyf, gvar, hMetrics, vMetrics, axisLimits, optimize=optimize
- )
- if not gvar.variations:
- del varfont["gvar"]
- def setCvarDeltas(cvt, deltas):
- for i, delta in enumerate(deltas):
- if delta:
- cvt[i] += otRound(delta)
- def instantiateCvar(varfont, axisLimits):
- log.info("Instantiating cvt/cvar tables")
- cvar = varfont["cvar"]
- defaultDeltas = instantiateTupleVariationStore(cvar.variations, axisLimits)
- if defaultDeltas:
- setCvarDeltas(varfont["cvt "], defaultDeltas)
- if not cvar.variations:
- del varfont["cvar"]
- def setMvarDeltas(varfont, deltas):
- mvar = varfont["MVAR"].table
- records = mvar.ValueRecord
- for rec in records:
- mvarTag = rec.ValueTag
- if mvarTag not in MVAR_ENTRIES:
- continue
- tableTag, itemName = MVAR_ENTRIES[mvarTag]
- delta = deltas[rec.VarIdx]
- if delta != 0:
- setattr(
- varfont[tableTag],
- itemName,
- getattr(varfont[tableTag], itemName) + otRound(delta),
- )
- @contextmanager
- def verticalMetricsKeptInSync(varfont):
- """Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing.
- When applying MVAR deltas to the OS/2 table, if the ascender, descender and
- line gap change but they were the same as the respective hhea metrics in the
- original font, this context manager ensures that hhea metrcs also get updated
- accordingly.
- The MVAR spec only has tags for the OS/2 metrics, but it is common in fonts
- to have the hhea metrics be equal to those for compat reasons.
- https://learn.microsoft.com/en-us/typography/opentype/spec/mvar
- https://googlefonts.github.io/gf-guide/metrics.html#7-hhea-and-typo-metrics-should-be-equal
- https://github.com/fonttools/fonttools/issues/3297
- """
- current_os2_vmetrics = [
- getattr(varfont["OS/2"], attr)
- for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
- ]
- metrics_are_synced = current_os2_vmetrics == [
- getattr(varfont["hhea"], attr) for attr in ("ascender", "descender", "lineGap")
- ]
- yield metrics_are_synced
- if metrics_are_synced:
- new_os2_vmetrics = [
- getattr(varfont["OS/2"], attr)
- for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
- ]
- if current_os2_vmetrics != new_os2_vmetrics:
- for attr, value in zip(
- ("ascender", "descender", "lineGap"), new_os2_vmetrics
- ):
- setattr(varfont["hhea"], attr, value)
- def instantiateMVAR(varfont, axisLimits):
- log.info("Instantiating MVAR table")
- mvar = varfont["MVAR"].table
- fvarAxes = varfont["fvar"].axes
- varStore = mvar.VarStore
- defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
- with verticalMetricsKeptInSync(varfont):
- setMvarDeltas(varfont, defaultDeltas)
- if varStore.VarRegionList.Region:
- varIndexMapping = varStore.optimize()
- for rec in mvar.ValueRecord:
- rec.VarIdx = varIndexMapping[rec.VarIdx]
- else:
- del varfont["MVAR"]
- def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder):
- oldMapping = getattr(table, attrName).mapping
- newMapping = [varIndexMapping[oldMapping[glyphName]] for glyphName in glyphOrder]
- setattr(table, attrName, builder.buildVarIdxMap(newMapping, glyphOrder))
- # TODO(anthrotype) Add support for HVAR/VVAR in CFF2
- def _instantiateVHVAR(varfont, axisLimits, tableFields, *, round=round):
- location = axisLimits.pinnedLocation()
- tableTag = tableFields.tableTag
- fvarAxes = varfont["fvar"].axes
- log.info("Instantiating %s table", tableTag)
- vhvar = varfont[tableTag].table
- varStore = vhvar.VarStore
- if "glyf" in varfont:
- # Deltas from gvar table have already been applied to the hmtx/vmtx. For full
- # instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return
- if set(location).issuperset(axis.axisTag for axis in fvarAxes):
- log.info("Dropping %s table", tableTag)
- del varfont[tableTag]
- return
- defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
- if "glyf" not in varfont:
- # CFF2 fonts need hmtx/vmtx updated here. For glyf fonts, the instantiateGvar
- # function already updated the hmtx/vmtx from phantom points. Maybe remove
- # that and do it here for both CFF2 and glyf fonts?
- #
- # Specially, if a font has glyf but not gvar, the hmtx/vmtx will not have been
- # updated by instantiateGvar. Though one can call that a faulty font.
- metricsTag = "vmtx" if tableTag == "VVAR" else "hmtx"
- if metricsTag in varfont:
- advMapping = getattr(vhvar, tableFields.advMapping)
- metricsTable = varfont[metricsTag]
- metrics = metricsTable.metrics
- for glyphName, (advanceWidth, sb) in metrics.items():
- if advMapping:
- varIdx = advMapping.mapping[glyphName]
- else:
- varIdx = varfont.getGlyphID(glyphName)
- metrics[glyphName] = (advanceWidth + round(defaultDeltas[varIdx]), sb)
- if (
- tableTag == "VVAR"
- and getattr(vhvar, tableFields.vOrigMapping) is not None
- ):
- log.warning(
- "VORG table not yet updated to reflect changes in VVAR table"
- )
- # For full instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return
- if set(location).issuperset(axis.axisTag for axis in fvarAxes):
- log.info("Dropping %s table", tableTag)
- del varfont[tableTag]
- return
- if varStore.VarRegionList.Region:
- # Only re-optimize VarStore if the HVAR/VVAR already uses indirect AdvWidthMap
- # or AdvHeightMap. If a direct, implicit glyphID->VariationIndex mapping is
- # used for advances, skip re-optimizing and maintain original VariationIndex.
- if getattr(vhvar, tableFields.advMapping):
- varIndexMapping = varStore.optimize(use_NO_VARIATION_INDEX=False)
- glyphOrder = varfont.getGlyphOrder()
- _remapVarIdxMap(vhvar, tableFields.advMapping, varIndexMapping, glyphOrder)
- if getattr(vhvar, tableFields.sb1): # left or top sidebearings
- _remapVarIdxMap(vhvar, tableFields.sb1, varIndexMapping, glyphOrder)
- if getattr(vhvar, tableFields.sb2): # right or bottom sidebearings
- _remapVarIdxMap(vhvar, tableFields.sb2, varIndexMapping, glyphOrder)
- if tableTag == "VVAR" and getattr(vhvar, tableFields.vOrigMapping):
- _remapVarIdxMap(
- vhvar, tableFields.vOrigMapping, varIndexMapping, glyphOrder
- )
- def instantiateHVAR(varfont, axisLimits):
- return _instantiateVHVAR(varfont, axisLimits, varLib.HVAR_FIELDS)
- def instantiateVVAR(varfont, axisLimits):
- return _instantiateVHVAR(varfont, axisLimits, varLib.VVAR_FIELDS)
- class _TupleVarStoreAdapter(object):
- def __init__(self, regions, axisOrder, tupleVarData, itemCounts):
- self.regions = regions
- self.axisOrder = axisOrder
- self.tupleVarData = tupleVarData
- self.itemCounts = itemCounts
- @classmethod
- def fromItemVarStore(cls, itemVarStore, fvarAxes):
- axisOrder = [axis.axisTag for axis in fvarAxes]
- regions = [
- region.get_support(fvarAxes) for region in itemVarStore.VarRegionList.Region
- ]
- tupleVarData = []
- itemCounts = []
- for varData in itemVarStore.VarData:
- variations = []
- varDataRegions = (regions[i] for i in varData.VarRegionIndex)
- for axes, coordinates in zip(varDataRegions, zip(*varData.Item)):
- variations.append(TupleVariation(axes, list(coordinates)))
- tupleVarData.append(variations)
- itemCounts.append(varData.ItemCount)
- return cls(regions, axisOrder, tupleVarData, itemCounts)
- def rebuildRegions(self):
- # Collect the set of all unique region axes from the current TupleVariations.
- # We use an OrderedDict to de-duplicate regions while keeping the order.
- uniqueRegions = collections.OrderedDict.fromkeys(
- (
- frozenset(var.axes.items())
- for variations in self.tupleVarData
- for var in variations
- )
- )
- # Maintain the original order for the regions that pre-existed, appending
- # the new regions at the end of the region list.
- newRegions = []
- for region in self.regions:
- regionAxes = frozenset(region.items())
- if regionAxes in uniqueRegions:
- newRegions.append(region)
- del uniqueRegions[regionAxes]
- if uniqueRegions:
- newRegions.extend(dict(region) for region in uniqueRegions)
- self.regions = newRegions
- def instantiate(self, axisLimits):
- defaultDeltaArray = []
- for variations, itemCount in zip(self.tupleVarData, self.itemCounts):
- defaultDeltas = instantiateTupleVariationStore(variations, axisLimits)
- if not defaultDeltas:
- defaultDeltas = [0] * itemCount
- defaultDeltaArray.append(defaultDeltas)
- # rebuild regions whose axes were dropped or limited
- self.rebuildRegions()
- pinnedAxes = set(axisLimits.pinnedLocation())
- self.axisOrder = [
- axisTag for axisTag in self.axisOrder if axisTag not in pinnedAxes
- ]
- return defaultDeltaArray
- def asItemVarStore(self):
- regionOrder = [frozenset(axes.items()) for axes in self.regions]
- varDatas = []
- for variations, itemCount in zip(self.tupleVarData, self.itemCounts):
- if variations:
- assert len(variations[0].coordinates) == itemCount
- varRegionIndices = [
- regionOrder.index(frozenset(var.axes.items())) for var in variations
- ]
- varDataItems = list(zip(*(var.coordinates for var in variations)))
- varDatas.append(
- builder.buildVarData(varRegionIndices, varDataItems, optimize=False)
- )
- else:
- varDatas.append(
- builder.buildVarData([], [[] for _ in range(itemCount)])
- )
- regionList = builder.buildVarRegionList(self.regions, self.axisOrder)
- itemVarStore = builder.buildVarStore(regionList, varDatas)
- # remove unused regions from VarRegionList
- itemVarStore.prune_regions()
- return itemVarStore
- def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits):
- """Compute deltas at partial location, and update varStore in-place.
- Remove regions in which all axes were instanced, or fall outside the new axis
- limits. Scale the deltas of the remaining regions where only some of the axes
- were instanced.
- The number of VarData subtables, and the number of items within each, are
- not modified, in order to keep the existing VariationIndex valid.
- One may call VarStore.optimize() method after this to further optimize those.
- Args:
- varStore: An otTables.VarStore object (Item Variation Store)
- fvarAxes: list of fvar's Axis objects
- axisLimits: NormalizedAxisLimits: mapping axis tags to normalized
- min/default/max axis coordinates. May not specify coordinates/ranges for
- all the fvar axes.
- Returns:
- defaultDeltas: to be added to the default instance, of type dict of floats
- keyed by VariationIndex compound values: i.e. (outer << 16) + inner.
- """
- tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes)
- defaultDeltaArray = tupleVarStore.instantiate(axisLimits)
- newItemVarStore = tupleVarStore.asItemVarStore()
- itemVarStore.VarRegionList = newItemVarStore.VarRegionList
- if not hasattr(itemVarStore, "VarDataCount"): # Happens fromXML
- itemVarStore.VarDataCount = len(newItemVarStore.VarData)
- assert itemVarStore.VarDataCount == newItemVarStore.VarDataCount
- itemVarStore.VarData = newItemVarStore.VarData
- defaultDeltas = {
- ((major << 16) + minor): delta
- for major, deltas in enumerate(defaultDeltaArray)
- for minor, delta in enumerate(deltas)
- }
- defaultDeltas[itemVarStore.NO_VARIATION_INDEX] = 0
- return defaultDeltas
- def instantiateOTL(varfont, axisLimits):
- # TODO(anthrotype) Support partial instancing of JSTF and BASE tables
- if (
- "GDEF" not in varfont
- or varfont["GDEF"].table.Version < 0x00010003
- or not varfont["GDEF"].table.VarStore
- ):
- return
- if "GPOS" in varfont:
- msg = "Instantiating GDEF and GPOS tables"
- else:
- msg = "Instantiating GDEF table"
- log.info(msg)
- gdef = varfont["GDEF"].table
- varStore = gdef.VarStore
- fvarAxes = varfont["fvar"].axes
- defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
- # When VF are built, big lookups may overflow and be broken into multiple
- # subtables. MutatorMerger (which inherits from AligningMerger) reattaches
- # them upon instancing, in case they can now fit a single subtable (if not,
- # they will be split again upon compilation).
- # This 'merger' also works as a 'visitor' that traverses the OTL tables and
- # calls specific methods when instances of a given type are found.
- # Specifically, it adds default deltas to GPOS Anchors/ValueRecords and GDEF
- # LigatureCarets, and optionally deletes all VariationIndex tables if the
- # VarStore is fully instanced.
- merger = MutatorMerger(
- varfont, defaultDeltas, deleteVariations=(not varStore.VarRegionList.Region)
- )
- merger.mergeTables(varfont, [varfont], ["GDEF", "GPOS"])
- if varStore.VarRegionList.Region:
- varIndexMapping = varStore.optimize()
- gdef.remap_device_varidxes(varIndexMapping)
- if "GPOS" in varfont:
- varfont["GPOS"].table.remap_device_varidxes(varIndexMapping)
- else:
- # Downgrade GDEF.
- del gdef.VarStore
- gdef.Version = 0x00010002
- if gdef.MarkGlyphSetsDef is None:
- del gdef.MarkGlyphSetsDef
- gdef.Version = 0x00010000
- if not (
- gdef.LigCaretList
- or gdef.MarkAttachClassDef
- or gdef.GlyphClassDef
- or gdef.AttachList
- or (gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef)
- ):
- del varfont["GDEF"]
- def _isValidAvarSegmentMap(axisTag, segmentMap):
- if not segmentMap:
- return True
- if not {(-1.0, -1.0), (0, 0), (1.0, 1.0)}.issubset(segmentMap.items()):
- log.warning(
- f"Invalid avar SegmentMap record for axis '{axisTag}': does not "
- "include all required value maps {-1.0: -1.0, 0: 0, 1.0: 1.0}"
- )
- return False
- previousValue = None
- for fromCoord, toCoord in sorted(segmentMap.items()):
- if previousValue is not None and previousValue > toCoord:
- log.warning(
- f"Invalid avar AxisValueMap({fromCoord}, {toCoord}) record "
- f"for axis '{axisTag}': the toCoordinate value must be >= to "
- f"the toCoordinate value of the preceding record ({previousValue})."
- )
- return False
- previousValue = toCoord
- return True
- def instantiateAvar(varfont, axisLimits):
- # 'axisLimits' dict must contain user-space (non-normalized) coordinates.
- avar = varfont["avar"]
- if getattr(avar, "majorVersion", 1) >= 2 and avar.table.VarStore:
- raise NotImplementedError("avar table with VarStore is not supported")
- segments = avar.segments
- # drop table if we instantiate all the axes
- pinnedAxes = set(axisLimits.pinnedLocation())
- if pinnedAxes.issuperset(segments):
- log.info("Dropping avar table")
- del varfont["avar"]
- return
- log.info("Instantiating avar table")
- for axis in pinnedAxes:
- if axis in segments:
- del segments[axis]
- # First compute the default normalization for axisLimits coordinates: i.e.
- # min = -1.0, default = 0, max = +1.0, and in between values interpolated linearly,
- # without using the avar table's mappings.
- # Then, for each SegmentMap, if we are restricting its axis, compute the new
- # mappings by dividing the key/value pairs by the desired new min/max values,
- # dropping any mappings that fall outside the restricted range.
- # The keys ('fromCoord') are specified in default normalized coordinate space,
- # whereas the values ('toCoord') are "mapped forward" using the SegmentMap.
- normalizedRanges = axisLimits.normalize(varfont, usingAvar=False)
- newSegments = {}
- for axisTag, mapping in segments.items():
- if not _isValidAvarSegmentMap(axisTag, mapping):
- continue
- if mapping and axisTag in normalizedRanges:
- axisRange = normalizedRanges[axisTag]
- mappedMin = floatToFixedToFloat(
- piecewiseLinearMap(axisRange.minimum, mapping), 14
- )
- mappedDef = floatToFixedToFloat(
- piecewiseLinearMap(axisRange.default, mapping), 14
- )
- mappedMax = floatToFixedToFloat(
- piecewiseLinearMap(axisRange.maximum, mapping), 14
- )
- mappedAxisLimit = NormalizedAxisTripleAndDistances(
- mappedMin,
- mappedDef,
- mappedMax,
- axisRange.distanceNegative,
- axisRange.distancePositive,
- )
- newMapping = {}
- for fromCoord, toCoord in mapping.items():
- if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum:
- continue
- fromCoord = axisRange.renormalizeValue(fromCoord)
- assert mappedMin <= toCoord <= mappedMax
- toCoord = mappedAxisLimit.renormalizeValue(toCoord)
- fromCoord = floatToFixedToFloat(fromCoord, 14)
- toCoord = floatToFixedToFloat(toCoord, 14)
- newMapping[fromCoord] = toCoord
- newMapping.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0})
- newSegments[axisTag] = newMapping
- else:
- newSegments[axisTag] = mapping
- avar.segments = newSegments
- def isInstanceWithinAxisRanges(location, axisRanges):
- for axisTag, coord in location.items():
- if axisTag in axisRanges:
- axisRange = axisRanges[axisTag]
- if coord < axisRange.minimum or coord > axisRange.maximum:
- return False
- return True
- def instantiateFvar(varfont, axisLimits):
- # 'axisLimits' dict must contain user-space (non-normalized) coordinates
- location = axisLimits.pinnedLocation()
- fvar = varfont["fvar"]
- # drop table if we instantiate all the axes
- if set(location).issuperset(axis.axisTag for axis in fvar.axes):
- log.info("Dropping fvar table")
- del varfont["fvar"]
- return
- log.info("Instantiating fvar table")
- axes = []
- for axis in fvar.axes:
- axisTag = axis.axisTag
- if axisTag in location:
- continue
- if axisTag in axisLimits:
- triple = axisLimits[axisTag]
- if triple.default is None:
- triple = (triple.minimum, axis.defaultValue, triple.maximum)
- axis.minValue, axis.defaultValue, axis.maxValue = triple
- axes.append(axis)
- fvar.axes = axes
- # only keep NamedInstances whose coordinates == pinned axis location
- instances = []
- for instance in fvar.instances:
- if any(instance.coordinates[axis] != value for axis, value in location.items()):
- continue
- for axisTag in location:
- del instance.coordinates[axisTag]
- if not isInstanceWithinAxisRanges(instance.coordinates, axisLimits):
- continue
- instances.append(instance)
- fvar.instances = instances
- def instantiateSTAT(varfont, axisLimits):
- # 'axisLimits' dict must contain user-space (non-normalized) coordinates
- stat = varfont["STAT"].table
- if not stat.DesignAxisRecord or not (
- stat.AxisValueArray and stat.AxisValueArray.AxisValue
- ):
- return # STAT table empty, nothing to do
- log.info("Instantiating STAT table")
- newAxisValueTables = axisValuesFromAxisLimits(stat, axisLimits)
- stat.AxisValueCount = len(newAxisValueTables)
- if stat.AxisValueCount:
- stat.AxisValueArray.AxisValue = newAxisValueTables
- else:
- stat.AxisValueArray = None
- def axisValuesFromAxisLimits(stat, axisLimits):
- def isAxisValueOutsideLimits(axisTag, axisValue):
- if axisTag in axisLimits:
- triple = axisLimits[axisTag]
- if axisValue < triple.minimum or axisValue > triple.maximum:
- return True
- return False
- # only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the
- # exact (nominal) value, or is restricted but the value is within the new range
- designAxes = stat.DesignAxisRecord.Axis
- newAxisValueTables = []
- for axisValueTable in stat.AxisValueArray.AxisValue:
- axisValueFormat = axisValueTable.Format
- if axisValueFormat in (1, 2, 3):
- axisTag = designAxes[axisValueTable.AxisIndex].AxisTag
- if axisValueFormat == 2:
- axisValue = axisValueTable.NominalValue
- else:
- axisValue = axisValueTable.Value
- if isAxisValueOutsideLimits(axisTag, axisValue):
- continue
- elif axisValueFormat == 4:
- # drop 'non-analytic' AxisValue if _any_ AxisValueRecord doesn't match
- # the pinned location or is outside range
- dropAxisValueTable = False
- for rec in axisValueTable.AxisValueRecord:
- axisTag = designAxes[rec.AxisIndex].AxisTag
- axisValue = rec.Value
- if isAxisValueOutsideLimits(axisTag, axisValue):
- dropAxisValueTable = True
- break
- if dropAxisValueTable:
- continue
- else:
- log.warning("Unknown AxisValue table format (%s); ignored", axisValueFormat)
- newAxisValueTables.append(axisValueTable)
- return newAxisValueTables
- def setMacOverlapFlags(glyfTable):
- flagOverlapCompound = _g_l_y_f.OVERLAP_COMPOUND
- flagOverlapSimple = _g_l_y_f.flagOverlapSimple
- for glyphName in glyfTable.keys():
- glyph = glyfTable[glyphName]
- # Set OVERLAP_COMPOUND bit for compound glyphs
- if glyph.isComposite():
- glyph.components[0].flags |= flagOverlapCompound
- # Set OVERLAP_SIMPLE bit for simple glyphs
- elif glyph.numberOfContours > 0:
- glyph.flags[0] |= flagOverlapSimple
- def normalize(value, triple, avarMapping):
- value = normalizeValue(value, triple)
- if avarMapping:
- value = piecewiseLinearMap(value, avarMapping)
- # Quantize to F2Dot14, to avoid surprise interpolations.
- return floatToFixedToFloat(value, 14)
- def sanityCheckVariableTables(varfont):
- if "fvar" not in varfont:
- raise ValueError("Missing required table fvar")
- if "gvar" in varfont:
- if "glyf" not in varfont:
- raise ValueError("Can't have gvar without glyf")
- def instantiateVariableFont(
- varfont,
- axisLimits,
- inplace=False,
- optimize=True,
- overlap=OverlapMode.KEEP_AND_SET_FLAGS,
- updateFontNames=False,
- *,
- downgradeCFF2=False,
- ):
- """Instantiate variable font, either fully or partially.
- Depending on whether the `axisLimits` dictionary references all or some of the
- input varfont's axes, the output font will either be a full instance (static
- font) or a variable font with possibly less variation data.
- Args:
- varfont: a TTFont instance, which must contain at least an 'fvar' table.
- axisLimits: a dict keyed by axis tags (str) containing the coordinates (float)
- along one or more axes where the desired instance will be located.
- If the value is `None`, the default coordinate as per 'fvar' table for
- that axis is used.
- The limit values can also be (min, max) tuples for restricting an
- axis's variation range. The default axis value must be included in
- the new range.
- inplace (bool): whether to modify input TTFont object in-place instead of
- returning a distinct object.
- optimize (bool): if False, do not perform IUP-delta optimization on the
- remaining 'gvar' table's deltas. Possibly faster, and might work around
- rendering issues in some buggy environments, at the cost of a slightly
- larger file size.
- overlap (OverlapMode): variable fonts usually contain overlapping contours, and
- some font rendering engines on Apple platforms require that the
- `OVERLAP_SIMPLE` and `OVERLAP_COMPOUND` flags in the 'glyf' table be set to
- force rendering using a non-zero fill rule. Thus we always set these flags
- on all glyphs to maximise cross-compatibility of the generated instance.
- You can disable this by passing OverlapMode.KEEP_AND_DONT_SET_FLAGS.
- If you want to remove the overlaps altogether and merge overlapping
- contours and components, you can pass OverlapMode.REMOVE (or
- REMOVE_AND_IGNORE_ERRORS to not hard-fail on tricky glyphs). Note that this
- requires the skia-pathops package (available to pip install).
- The overlap parameter only has effect when generating full static instances.
- updateFontNames (bool): if True, update the instantiated font's name table using
- the Axis Value Tables from the STAT table. The name table and the style bits
- in the head and OS/2 table will be updated so they conform to the R/I/B/BI
- model. If the STAT table is missing or an Axis Value table is missing for
- a given axis coordinate, a ValueError will be raised.
- downgradeCFF2 (bool): if True, downgrade the CFF2 table to CFF table when possible
- ie. full instancing of all axes. This is useful for compatibility with older
- software that does not support CFF2. Defaults to False. Note that this
- operation also removes overlaps within glyph shapes, as CFF does not support
- overlaps but CFF2 does.
- """
- # 'overlap' used to be bool and is now enum; for backward compat keep accepting bool
- overlap = OverlapMode(int(overlap))
- sanityCheckVariableTables(varfont)
- axisLimits = AxisLimits(axisLimits).limitAxesAndPopulateDefaults(varfont)
- log.info("Restricted limits: %s", axisLimits)
- normalizedLimits = axisLimits.normalize(varfont)
- log.info("Normalized limits: %s", normalizedLimits)
- if not inplace:
- varfont = deepcopy(varfont)
- if "DSIG" in varfont:
- del varfont["DSIG"]
- if updateFontNames:
- log.info("Updating name table")
- names.updateNameTable(varfont, axisLimits)
- if "VARC" in varfont:
- instantiateVARC(varfont, normalizedLimits)
- if "CFF2" in varfont:
- instantiateCFF2(varfont, normalizedLimits, downgrade=downgradeCFF2)
- if "gvar" in varfont:
- instantiateGvar(varfont, normalizedLimits, optimize=optimize)
- if "cvar" in varfont:
- instantiateCvar(varfont, normalizedLimits)
- if "MVAR" in varfont:
- instantiateMVAR(varfont, normalizedLimits)
- if "HVAR" in varfont:
- instantiateHVAR(varfont, normalizedLimits)
- if "VVAR" in varfont:
- instantiateVVAR(varfont, normalizedLimits)
- instantiateOTL(varfont, normalizedLimits)
- instantiateFeatureVariations(varfont, normalizedLimits)
- if "avar" in varfont:
- instantiateAvar(varfont, axisLimits)
- with names.pruningUnusedNames(varfont):
- if "STAT" in varfont:
- instantiateSTAT(varfont, axisLimits)
- instantiateFvar(varfont, axisLimits)
- if "fvar" not in varfont:
- if "glyf" in varfont:
- if overlap == OverlapMode.KEEP_AND_SET_FLAGS:
- setMacOverlapFlags(varfont["glyf"])
- elif overlap in (OverlapMode.REMOVE, OverlapMode.REMOVE_AND_IGNORE_ERRORS):
- from fontTools.ttLib.removeOverlaps import removeOverlaps
- log.info("Removing overlaps from glyf table")
- removeOverlaps(
- varfont,
- ignoreErrors=(overlap == OverlapMode.REMOVE_AND_IGNORE_ERRORS),
- )
- if "OS/2" in varfont:
- varfont["OS/2"].recalcAvgCharWidth(varfont)
- varLib.set_default_weight_width_slant(
- varfont, location=axisLimits.defaultLocation()
- )
- if updateFontNames:
- # Set Regular/Italic/Bold/Bold Italic bits as appropriate, after the
- # name table has been updated.
- setRibbiBits(varfont)
- return varfont
- def setRibbiBits(font):
- """Set the `head.macStyle` and `OS/2.fsSelection` style bits
- appropriately."""
- english_ribbi_style = font["name"].getName(names.NameID.SUBFAMILY_NAME, 3, 1, 0x409)
- if english_ribbi_style is None:
- return
- styleMapStyleName = english_ribbi_style.toStr().lower()
- if styleMapStyleName not in {"regular", "bold", "italic", "bold italic"}:
- return
- if styleMapStyleName == "bold":
- font["head"].macStyle = 0b01
- elif styleMapStyleName == "bold italic":
- font["head"].macStyle = 0b11
- elif styleMapStyleName == "italic":
- font["head"].macStyle = 0b10
- selection = font["OS/2"].fsSelection
- # First clear...
- selection &= ~(1 << 0)
- selection &= ~(1 << 5)
- selection &= ~(1 << 6)
- # ...then re-set the bits.
- if styleMapStyleName == "regular":
- selection |= 1 << 6
- elif styleMapStyleName == "bold":
- selection |= 1 << 5
- elif styleMapStyleName == "italic":
- selection |= 1 << 0
- elif styleMapStyleName == "bold italic":
- selection |= 1 << 0
- selection |= 1 << 5
- font["OS/2"].fsSelection = selection
- def parseLimits(limits: Iterable[str]) -> Dict[str, Optional[AxisTriple]]:
- result = {}
- for limitString in limits:
- match = re.match(
- r"^(\w{1,4})=(?:(drop)|(?:([^:]*)(?:[:]([^:]*))?(?:[:]([^:]*))?))$",
- limitString,
- )
- if not match:
- raise ValueError("invalid location format: %r" % limitString)
- tag = match.group(1).ljust(4)
- if match.group(2): # 'drop'
- result[tag] = None
- continue
- triple = match.group(3, 4, 5)
- if triple[1] is None: # "value" syntax
- triple = (triple[0], triple[0], triple[0])
- elif triple[2] is None: # "min:max" syntax
- triple = (triple[0], None, triple[1])
- triple = tuple(float(v) if v else None for v in triple)
- result[tag] = AxisTriple(*triple)
- return result
- def parseArgs(args):
- """Parse argv.
- Returns:
- 3-tuple (infile, axisLimits, options)
- axisLimits is either a Dict[str, Optional[float]], for pinning variation axes
- to specific coordinates along those axes (with `None` as a placeholder for an
- axis' default value); or a Dict[str, Tuple(float, float)], meaning limit this
- axis to min/max range.
- Axes locations are in user-space coordinates, as defined in the "fvar" table.
- """
- from fontTools import configLogger
- import argparse
- parser = argparse.ArgumentParser(
- "fonttools varLib.instancer",
- description="Partially instantiate a variable font",
- )
- parser.add_argument("input", metavar="INPUT.ttf", help="Input variable TTF file.")
- parser.add_argument(
- "locargs",
- metavar="AXIS=LOC",
- nargs="*",
- help="List of space separated locations. A location consists of "
- "the tag of a variation axis, followed by '=' and the literal, "
- "string 'drop', or colon-separated list of one to three values, "
- "each of which is the empty string, or a number. "
- "E.g.: wdth=100 or wght=75.0:125.0 or wght=100:400:700 or wght=:500: "
- "or wght=drop",
- )
- parser.add_argument(
- "-o",
- "--output",
- metavar="OUTPUT.ttf",
- default=None,
- help="Output instance TTF file (default: INPUT-instance.ttf).",
- )
- parser.add_argument(
- "--no-optimize",
- dest="optimize",
- action="store_false",
- help="Don't perform IUP optimization on the remaining gvar TupleVariations",
- )
- parser.add_argument(
- "--no-overlap-flag",
- dest="overlap",
- action="store_false",
- help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags (only applicable "
- "when generating a full instance)",
- )
- parser.add_argument(
- "--remove-overlaps",
- dest="remove_overlaps",
- action="store_true",
- help="Merge overlapping contours and components (only applicable "
- "when generating a full instance). Requires skia-pathops",
- )
- parser.add_argument(
- "--ignore-overlap-errors",
- dest="ignore_overlap_errors",
- action="store_true",
- help="Don't crash if the remove-overlaps operation fails for some glyphs.",
- )
- parser.add_argument(
- "--update-name-table",
- action="store_true",
- help="Update the instantiated font's `name` table. Input font must have "
- "a STAT table with Axis Value Tables",
- )
- parser.add_argument(
- "--downgrade-cff2",
- action="store_true",
- help="If all axes are pinned, downgrade CFF2 to CFF table format",
- )
- parser.add_argument(
- "--no-recalc-timestamp",
- dest="recalc_timestamp",
- action="store_false",
- help="Don't set the output font's timestamp to the current time.",
- )
- parser.add_argument(
- "--no-recalc-bounds",
- dest="recalc_bounds",
- action="store_false",
- help="Don't recalculate font bounding boxes",
- )
- loggingGroup = parser.add_mutually_exclusive_group(required=False)
- loggingGroup.add_argument(
- "-v", "--verbose", action="store_true", help="Run more verbosely."
- )
- loggingGroup.add_argument(
- "-q", "--quiet", action="store_true", help="Turn verbosity off."
- )
- options = parser.parse_args(args)
- if options.remove_overlaps:
- if options.ignore_overlap_errors:
- options.overlap = OverlapMode.REMOVE_AND_IGNORE_ERRORS
- else:
- options.overlap = OverlapMode.REMOVE
- else:
- options.overlap = OverlapMode(int(options.overlap))
- infile = options.input
- if not os.path.isfile(infile):
- parser.error("No such file '{}'".format(infile))
- configLogger(
- level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
- )
- try:
- axisLimits = parseLimits(options.locargs)
- except ValueError as e:
- parser.error(str(e))
- if len(axisLimits) != len(options.locargs):
- parser.error("Specified multiple limits for the same axis")
- return (infile, axisLimits, options)
- def main(args=None):
- """Partially instantiate a variable font"""
- infile, axisLimits, options = parseArgs(args)
- log.info("Restricting axes: %s", axisLimits)
- log.info("Loading variable font")
- varfont = TTFont(
- infile,
- recalcTimestamp=options.recalc_timestamp,
- recalcBBoxes=options.recalc_bounds,
- )
- isFullInstance = {
- axisTag
- for axisTag, limit in axisLimits.items()
- if limit is None or limit[0] == limit[2]
- }.issuperset(axis.axisTag for axis in varfont["fvar"].axes)
- instantiateVariableFont(
- varfont,
- axisLimits,
- inplace=True,
- optimize=options.optimize,
- overlap=options.overlap,
- updateFontNames=options.update_name_table,
- downgradeCFF2=options.downgrade_cff2,
- )
- suffix = "-instance" if isFullInstance else "-partial"
- outfile = (
- makeOutputFileName(infile, overWrite=True, suffix=suffix)
- if not options.output
- else options.output
- )
- log.info(
- "Saving %s font %s",
- "instance" if isFullInstance else "partial variable",
- outfile,
- )
- varfont.save(outfile)
|