diff options
author | robot-piglet <[email protected]> | 2025-08-29 11:40:50 +0300 |
---|---|---|
committer | robot-piglet <[email protected]> | 2025-08-29 12:04:59 +0300 |
commit | eb4fa69a58c58a2f36a4cda1b61972ad7037eee6 (patch) | |
tree | 35d7c1d1f7ce3cb83cd04bedd711ab451719882c | |
parent | ccfbaaf1ad9afe621cb75dc5296d2e0de0e758b4 (diff) |
Intermediate changes
commit_hash:efca680e102a12bb0a656779dafefc81261b3eac
13 files changed, 208 insertions, 51 deletions
diff --git a/contrib/python/fonttools/.dist-info/METADATA b/contrib/python/fonttools/.dist-info/METADATA index 19786640848..1272aa27e6f 100644 --- a/contrib/python/fonttools/.dist-info/METADATA +++ b/contrib/python/fonttools/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: fonttools -Version: 4.59.0 +Version: 4.59.1 Summary: Tools to manipulate font files Home-page: http://github.com/fonttools/fonttools Author: Just van Rossum @@ -168,15 +168,6 @@ are required to unlock the extra features named "ufo", etc. *Extra:* ``lxml`` -- ``Lib/fontTools/ufoLib`` - - Package for reading and writing UFO source files; it requires: - - * `fs <https://pypi.org/pypi/fs>`__: (aka ``pyfilesystem2``) filesystem - abstraction layer. - - *Extra:* ``ufo`` - - ``Lib/fontTools/ttLib/woff2.py`` Module to compress/decompress WOFF 2.0 web fonts; it requires: @@ -269,6 +260,17 @@ are required to unlock the extra features named "ufo", etc. *Extra:* ``pathops`` +- ``Lib/fontTools/ufoLib`` + + Package for reading and writing UFO source files; if available, it will use: + + * `fs <https://pypi.org/pypi/fs>`__: (aka ``pyfilesystem2``) filesystem abstraction layer + + for reading and writing UFOs to the local filesystem or zip files (.ufoz), instead of + the built-in ``fontTools.misc.filesystem`` package. + The reader and writer classes can in theory also accept any object compatible the + ``fs.base.FS`` interface, although not all have been tested. + - ``Lib/fontTools/pens/cocoaPen.py`` and ``Lib/fontTools/pens/quartzPen.py`` Pens for drawing glyphs with Cocoa ``NSBezierPath`` or ``CGPath`` require: @@ -386,6 +388,18 @@ Have fun! Changelog ~~~~~~~~~ +4.59.1 (released 2025-08-14) +---------------------------- + +- [featureVars] Update OS/2.usMaxContext if possible after addFeatureVariationsRaw (#3894). +- [vhmtx] raise TTLibError('not enough data...') when hmtx/vmtx are truncated (#3843, #3901). +- [feaLib] Combine duplicate features that have the same set of lookups regardless of the order in which those lookups are added to the feature (#3895). +- [varLib] Deprecate ``varLib.mutator`` in favor of ``varLib.instancer``. The latter + provides equivalent full (static font) instancing in addition to partial VF instancing. + CLI users should replace ``fonttools varLib.mutator`` with ``fonttools varLib.instancer``. + API users should migrate to ``fontTools.varLib.instancer.instantiateVariableFont`` (#2680). + + 4.59.0 (released 2025-07-16) ---------------------------- diff --git a/contrib/python/fonttools/README.rst b/contrib/python/fonttools/README.rst index e40554dae8f..6d638f89c0e 100644 --- a/contrib/python/fonttools/README.rst +++ b/contrib/python/fonttools/README.rst @@ -81,15 +81,6 @@ are required to unlock the extra features named "ufo", etc. *Extra:* ``lxml`` -- ``Lib/fontTools/ufoLib`` - - Package for reading and writing UFO source files; it requires: - - * `fs <https://pypi.org/pypi/fs>`__: (aka ``pyfilesystem2``) filesystem - abstraction layer. - - *Extra:* ``ufo`` - - ``Lib/fontTools/ttLib/woff2.py`` Module to compress/decompress WOFF 2.0 web fonts; it requires: @@ -182,6 +173,17 @@ are required to unlock the extra features named "ufo", etc. *Extra:* ``pathops`` +- ``Lib/fontTools/ufoLib`` + + Package for reading and writing UFO source files; if available, it will use: + + * `fs <https://pypi.org/pypi/fs>`__: (aka ``pyfilesystem2``) filesystem abstraction layer + + for reading and writing UFOs to the local filesystem or zip files (.ufoz), instead of + the built-in ``fontTools.misc.filesystem`` package. + The reader and writer classes can in theory also accept any object compatible the + ``fs.base.FS`` interface, although not all have been tested. + - ``Lib/fontTools/pens/cocoaPen.py`` and ``Lib/fontTools/pens/quartzPen.py`` Pens for drawing glyphs with Cocoa ``NSBezierPath`` or ``CGPath`` require: diff --git a/contrib/python/fonttools/fontTools/__init__.py b/contrib/python/fonttools/fontTools/__init__.py index d2ff98f6a68..75e3e32e488 100644 --- a/contrib/python/fonttools/fontTools/__init__.py +++ b/contrib/python/fonttools/fontTools/__init__.py @@ -3,6 +3,6 @@ from fontTools.misc.loggingTools import configLogger log = logging.getLogger(__name__) -version = __version__ = "4.59.0" +version = __version__ = "4.59.1" __all__ = ["version", "log", "configLogger"] diff --git a/contrib/python/fonttools/fontTools/cffLib/CFF2ToCFF.py b/contrib/python/fonttools/fontTools/cffLib/CFF2ToCFF.py index f929cc96869..e0ec956b60d 100644 --- a/contrib/python/fonttools/fontTools/cffLib/CFF2ToCFF.py +++ b/contrib/python/fonttools/fontTools/cffLib/CFF2ToCFF.py @@ -2,13 +2,17 @@ from fontTools.ttLib import TTFont, newTable from fontTools.misc.cliTools import makeOutputFileName +from fontTools.misc.psCharStrings import T2StackUseExtractor from fontTools.cffLib import ( TopDictIndex, buildOrder, buildDefaults, topDictOperators, privateDictOperators, + FDSelect, ) +from .transforms import desubroutinizeCharString +from .specializer import specializeProgram from .width import optimizeWidths from collections import defaultdict import logging @@ -27,7 +31,7 @@ def _convertCFF2ToCFF(cff, otFont): The CFF2 font cannot be variable. (TODO Accept those and convert to the default instance?) - This assumes a decompiled CFF table. (i.e. that the object has been + This assumes a decompiled CFF2 table. (i.e. that the object has been filled via :meth:`decompile` and e.g. not loaded from XML.)""" cff.major = 1 @@ -51,9 +55,14 @@ def _convertCFF2ToCFF(cff, otFont): if hasattr(topDict, key): delattr(topDict, key) - fdArray = topDict.FDArray charStrings = topDict.CharStrings + fdArray = topDict.FDArray + if not hasattr(topDict, "FDSelect"): + # FDSelect is optional in CFF2, but required in CFF. + fdSelect = topDict.FDSelect = FDSelect() + fdSelect.gidArray = [0] * len(charStrings.charStrings) + defaults = buildDefaults(privateDictOperators) order = buildOrder(privateDictOperators) for fd in fdArray: @@ -69,6 +78,7 @@ def _convertCFF2ToCFF(cff, otFont): if hasattr(privateDict, key): delattr(privateDict, key) + # Add ending operators for cs in charStrings.values(): cs.decompile() cs.program.append("endchar") @@ -100,23 +110,43 @@ def _convertCFF2ToCFF(cff, otFont): if width != private.defaultWidthX: cs.program.insert(0, width - private.nominalWidthX) + # Handle stack use since stack-depth is lower in CFF than in CFF2. + for glyphName in charStrings.keys(): + cs, fdIndex = charStrings.getItemAndSelector(glyphName) + if fdIndex is None: + fdIndex = 0 + private = fdArray[fdIndex].Private + extractor = T2StackUseExtractor( + getattr(private, "Subrs", []), cff.GlobalSubrs, private=private + ) + stackUse = extractor.execute(cs) + if stackUse > 48: # CFF stack depth is 48 + desubroutinizeCharString(cs) + cs.program = specializeProgram(cs.program) + + # Unused subroutines are still in CFF2 (ie. lacking 'return' operator) + # because they were not decompiled when we added the 'return'. + # Moreover, some used subroutines may have become unused after the + # stack-use fixup. So we remove all unused subroutines now. + cff.remove_unused_subroutines() + mapping = { - name: ("cid" + str(n) if n else ".notdef") + name: ("cid" + str(n).zfill(5) if n else ".notdef") for n, name in enumerate(topDict.charset) } topDict.charset = [ - "cid" + str(n) if n else ".notdef" for n in range(len(topDict.charset)) + "cid" + str(n).zfill(5) if n else ".notdef" for n in range(len(topDict.charset)) ] charStrings.charStrings = { mapping[name]: v for name, v in charStrings.charStrings.items() } - # I'm not sure why the following is *not* necessary. And it breaks - # the output if I add it. - # topDict.ROS = ("Adobe", "Identity", 0) + topDict.ROS = ("Adobe", "Identity", 0) def convertCFF2ToCFF(font, *, updatePostTable=True): + if "CFF2" not in font: + raise ValueError("Input font does not contain a CFF2 table.") cff = font["CFF2"].cff _convertCFF2ToCFF(cff, font) del font["CFF2"] @@ -131,7 +161,7 @@ def convertCFF2ToCFF(font, *, updatePostTable=True): def main(args=None): - """Convert CFF OTF font to CFF2 OTF font""" + """Convert CFF2 OTF font to CFF OTF font""" if args is None: import sys @@ -140,8 +170,8 @@ def main(args=None): import argparse parser = argparse.ArgumentParser( - "fonttools cffLib.CFFToCFF2", - description="Upgrade a CFF font to CFF2.", + "fonttools cffLib.CFF2ToCFF", + description="Convert a non-variable CFF2 font to CFF.", ) parser.add_argument( "input", metavar="INPUT.ttf", help="Input OTF file with CFF table." diff --git a/contrib/python/fonttools/fontTools/cffLib/transforms.py b/contrib/python/fonttools/fontTools/cffLib/transforms.py index 82c70f81f49..b9b7c86c8b4 100644 --- a/contrib/python/fonttools/fontTools/cffLib/transforms.py +++ b/contrib/python/fonttools/fontTools/cffLib/transforms.py @@ -94,17 +94,22 @@ class _DesubroutinizingT2Decompiler(SimpleT2Decompiler): cs._patches.append((index, subr._desubroutinized)) +def desubroutinizeCharString(cs): + """Desubroutinize a charstring in-place.""" + cs.decompile() + subrs = getattr(cs.private, "Subrs", []) + decompiler = _DesubroutinizingT2Decompiler(subrs, cs.globalSubrs, cs.private) + decompiler.execute(cs) + cs.program = cs._desubroutinized + del cs._desubroutinized + + def desubroutinize(cff): for fontName in cff.fontNames: font = cff[fontName] cs = font.CharStrings for c in cs.values(): - c.decompile() - subrs = getattr(c.private, "Subrs", []) - decompiler = _DesubroutinizingT2Decompiler(subrs, c.globalSubrs, c.private) - decompiler.execute(c) - c.program = c._desubroutinized - del c._desubroutinized + desubroutinizeCharString(c) # Delete all the local subrs if hasattr(font, "FDArray"): for fd in font.FDArray: diff --git a/contrib/python/fonttools/fontTools/feaLib/builder.py b/contrib/python/fonttools/fontTools/feaLib/builder.py index 25b319c0b01..0d253222552 100644 --- a/contrib/python/fonttools/fontTools/feaLib/builder.py +++ b/contrib/python/fonttools/fontTools/feaLib/builder.py @@ -926,6 +926,11 @@ class Builder(object): l.lookup_index for l in lookups if l.lookup_index is not None ) ) + # order doesn't matter, but lookup_indices preserves it. + # We want to combine identical sets of lookups (order doesn't matter) + # but also respect the order provided by the user (although there's + # a reasonable argument to just sort and dedupe, which fontc does) + lookup_key = frozenset(lookup_indices) size_feature = tag == "GPOS" and feature_tag == "size" force_feature = self.any_feature_variations(feature_tag, tag) @@ -943,7 +948,7 @@ class Builder(object): "stash debug information. See fonttools#2065." ) - feature_key = (feature_tag, lookup_indices) + feature_key = (feature_tag, lookup_key) feature_index = feature_indices.get(feature_key) if feature_index is None: feature_index = len(table.FeatureList.FeatureRecord) diff --git a/contrib/python/fonttools/fontTools/misc/psCharStrings.py b/contrib/python/fonttools/fontTools/misc/psCharStrings.py index 5d881c5816c..db837248dea 100644 --- a/contrib/python/fonttools/fontTools/misc/psCharStrings.py +++ b/contrib/python/fonttools/fontTools/misc/psCharStrings.py @@ -338,7 +338,7 @@ class SimpleT2Decompiler(object): self.numRegions = 0 self.vsIndex = 0 - def execute(self, charString): + def execute(self, charString, *, pushToStack=None): self.callingStack.append(charString) needsDecompilation = charString.needsDecompilation() if needsDecompilation: @@ -346,7 +346,8 @@ class SimpleT2Decompiler(object): pushToProgram = program.append else: pushToProgram = lambda x: None - pushToStack = self.operandStack.append + if pushToStack is None: + pushToStack = self.operandStack.append index = 0 while True: token, isOperator, index = charString.getToken(index) @@ -551,6 +552,20 @@ t1Operators = [ ] +class T2StackUseExtractor(SimpleT2Decompiler): + + def execute(self, charString): + maxStackUse = 0 + + def pushToStack(value): + nonlocal maxStackUse + self.operandStack.append(value) + maxStackUse = max(maxStackUse, len(self.operandStack)) + + super().execute(charString, pushToStack=pushToStack) + return maxStackUse + + class T2WidthExtractor(SimpleT2Decompiler): def __init__( self, diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/_g_v_a_r.py b/contrib/python/fonttools/fontTools/ttLib/tables/_g_v_a_r.py index 07d3befb7aa..15cc6fab5bd 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/_g_v_a_r.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/_g_v_a_r.py @@ -64,7 +64,6 @@ class table__g_v_a_r(DefaultTable.DefaultTable): self.variations = {} def compile(self, ttFont): - axisTags = [axis.axisTag for axis in ttFont["fvar"].axes] sharedTuples = tv.compileSharedTuples( axisTags, itertools.chain(*self.variations.values()) @@ -141,8 +140,12 @@ class table__g_v_a_r(DefaultTable.DefaultTable): self, ) - assert len(glyphs) == self.glyphCount - assert len(axisTags) == self.axisCount + assert len(glyphs) == self.glyphCount, (len(glyphs), self.glyphCount) + assert len(axisTags) == self.axisCount, ( + len(axisTags), + self.axisCount, + axisTags, + ) sharedCoords = tv.decompileSharedTuples( axisTags, self.sharedTupleCount, data, self.offsetToSharedTuples ) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/_h_m_t_x.py b/contrib/python/fonttools/fontTools/ttLib/tables/_h_m_t_x.py index 43d49b09256..0dc5077588b 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/_h_m_t_x.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/_h_m_t_x.py @@ -40,15 +40,19 @@ class table__h_m_t_x(DefaultTable.DefaultTable): % (self.headerTag, self.numberOfMetricsName) ) numberOfMetrics = numGlyphs - if len(data) < 4 * numberOfMetrics: - raise ttLib.TTLibError("not enough '%s' table data" % self.tableTag) + numberOfSideBearings = numGlyphs - numberOfMetrics + tableSize = 4 * numberOfMetrics + 2 * numberOfSideBearings + if len(data) < tableSize: + raise ttLib.TTLibError( + f"not enough '{self.tableTag}' table data: " + f"expected {tableSize} bytes, got {len(data)}" + ) # Note: advanceWidth is unsigned, but some font editors might # read/write as signed. We can't be sure whether it was a mistake # or not, so we read as unsigned but also issue a warning... metricsFmt = ">" + self.longMetricFormat * numberOfMetrics metrics = struct.unpack(metricsFmt, data[: 4 * numberOfMetrics]) data = data[4 * numberOfMetrics :] - numberOfSideBearings = numGlyphs - numberOfMetrics sideBearings = array.array("h", data[: 2 * numberOfSideBearings]) data = data[2 * numberOfSideBearings :] diff --git a/contrib/python/fonttools/fontTools/varLib/featureVars.py b/contrib/python/fonttools/fontTools/varLib/featureVars.py index 856f00bcb9c..40ad9dfca6c 100644 --- a/contrib/python/fonttools/fontTools/varLib/featureVars.py +++ b/contrib/python/fonttools/fontTools/varLib/featureVars.py @@ -95,6 +95,14 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag="rvrn"): addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTags) + # Update OS/2.usMaxContext in case the font didn't have features before, but + # does now, if the OS/2 table exists. The table may be required, but + # fontTools needs to be able to deal with non-standard fonts. Since feature + # variations are always 1:1 mappings, we can set the value to at least 1 + # instead of recomputing it with `otlLib.maxContextCalc.maxCtxFont()`. + if (os2 := font.get("OS/2")) is not None: + os2.usMaxContext = max(1, os2.usMaxContext) + def _existingVariableFeatures(table): existingFeatureVarsTags = set() diff --git a/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py b/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py index 76901880553..d635045c3f4 100644 --- a/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py +++ b/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py @@ -120,6 +120,7 @@ from fontTools.cffLib.specializer import ( specializeCommands, generalizeCommands, ) +from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF from fontTools.varLib import builder from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.merger import MutatorMerger @@ -136,6 +137,7 @@ from enum import IntEnum import logging import os import re +import io from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union import warnings @@ -643,7 +645,11 @@ def instantiateCFF2( # 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. + # + # If the downgrade parameter is True, no actual downgrading is done, but + # the function returns True if the VarStore was empty after instantiation, + # and hence a downgrade to CFF is possible. In all other cases it returns + # False. log.info("Instantiating CFF2 table") @@ -882,9 +888,9 @@ def instantiateCFF2( del private.vstore if downgrade: - from fontTools.cffLib.CFF2ToCFF import convertCFF2ToCFF + return True - convertCFF2ToCFF(varfont) + return False def _instantiateGvarGlyph( @@ -1377,6 +1383,52 @@ def _isValidAvarSegmentMap(axisTag, segmentMap): return True +def downgradeCFF2ToCFF(varfont): + + # Save these properties + recalcTimestamp = varfont.recalcTimestamp + recalcBBoxes = varfont.recalcBBoxes + + # Disable them + varfont.recalcTimestamp = False + varfont.recalcBBoxes = False + + # Save to memory, reload, downgrade and save again, reload. + # We do this dance because the convertCFF2ToCFF changes glyph + # names, so following save would fail if any other table was + # loaded and referencing glyph names. + # + # The second save+load is unfortunate but also necessary. + + stream = io.BytesIO() + log.info("Saving CFF2 font to memory for downgrade") + varfont.save(stream) + stream.seek(0) + varfont = TTFont(stream, recalcTimestamp=False, recalcBBoxes=False) + + convertCFF2ToCFF(varfont) + + stream = io.BytesIO() + log.info("Saving downgraded CFF font to memory") + varfont.save(stream) + stream.seek(0) + varfont = TTFont(stream, recalcTimestamp=False, recalcBBoxes=False) + + # Uncomment, to see test all tables can be loaded. This fails without + # the extra save+load above. + """ + for tag in varfont.keys(): + print("Loading", tag) + varfont[tag] + """ + + # Restore them + varfont.recalcTimestamp = recalcTimestamp + varfont.recalcBBoxes = recalcBBoxes + + return varfont + + def instantiateAvar(varfont, axisLimits): # 'axisLimits' dict must contain user-space (non-normalized) coordinates. @@ -1665,7 +1717,9 @@ def instantiateVariableFont( instantiateVARC(varfont, normalizedLimits) if "CFF2" in varfont: - instantiateCFF2(varfont, normalizedLimits, downgrade=downgradeCFF2) + downgradeCFF2 = instantiateCFF2( + varfont, normalizedLimits, downgrade=downgradeCFF2 + ) if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) @@ -1720,6 +1774,12 @@ def instantiateVariableFont( # name table has been updated. setRibbiBits(varfont) + if downgradeCFF2: + origVarfont = varfont + varfont = downgradeCFF2ToCFF(varfont) + if inplace: + origVarfont.__dict__ = varfont.__dict__.copy() + return varfont @@ -1929,7 +1989,7 @@ def main(args=None): if limit is None or limit[0] == limit[2] }.issuperset(axis.axisTag for axis in varfont["fvar"].axes) - instantiateVariableFont( + varfont = instantiateVariableFont( varfont, axisLimits, inplace=True, diff --git a/contrib/python/fonttools/fontTools/varLib/mutator.py b/contrib/python/fonttools/fontTools/varLib/mutator.py index f9f93790263..cfa7607c4f2 100644 --- a/contrib/python/fonttools/fontTools/varLib/mutator.py +++ b/contrib/python/fonttools/fontTools/varLib/mutator.py @@ -4,9 +4,16 @@ Instantiate a variation font. Run, eg: .. code-block:: sh $ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85 + +.. warning:: + ``fontTools.varLib.mutator`` is deprecated in favor of :mod:`fontTools.varLib.instancer` + which provides equivalent full instancing and also supports partial instancing. + Please migrate CLI usage to ``fonttools varLib.instancer`` and API usage to + :func:`fontTools.varLib.instancer.instantiateVariableFont`. """ from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed +from fontTools.misc.loggingTools import deprecateFunction from fontTools.misc.roundTools import otRound from fontTools.pens.boundsPen import BoundsPen from fontTools.ttLib import TTFont, newTable @@ -159,6 +166,10 @@ def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc): hmtx[gname] = tuple(entry) +@deprecateFunction( + "use fontTools.varLib.instancer.instantiateVariableFont instead " + "for either full or partial instancing", +) def instantiateVariableFont(varfont, location, inplace=False, overlap=True): """Generate a static instance from a variable TTFont and a dictionary defining the desired location along the variable font's axes. diff --git a/contrib/python/fonttools/ya.make b/contrib/python/fonttools/ya.make index 0632f1b026f..a084add2583 100644 --- a/contrib/python/fonttools/ya.make +++ b/contrib/python/fonttools/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(4.59.0) +VERSION(4.59.1) LICENSE(MIT) |