diff --git a/.gitignore b/.gitignore index 9c564fd54..e62c33dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ Lib/**/*.c # Ctags tags + +# Documentation +Doc/source/_build diff --git a/Doc/source/config.rst b/Doc/source/config.rst new file mode 100644 index 000000000..6be575d5e --- /dev/null +++ b/Doc/source/config.rst @@ -0,0 +1,8 @@ +########################### +config: configure fontTools +########################### + +.. automodule:: fontTools.config + :inherited-members: + :members: + :undoc-members: diff --git a/Doc/source/index.rst b/Doc/source/index.rst index 784834d83..571ef8ddf 100644 --- a/Doc/source/index.rst +++ b/Doc/source/index.rst @@ -69,6 +69,7 @@ libraries in the fontTools suite: - :py:mod:`fontTools.agl`: Access to the Adobe Glyph List - :py:mod:`fontTools.cffLib`: Read/write tools for Adobe CFF fonts - :py:mod:`fontTools.colorLib`: Module for handling colors in CPAL/COLR fonts +- :py:mod:`fontTools.config`: Configure fontTools - :py:mod:`fontTools.cu2qu`: Module for cubic to quadratic conversion - :py:mod:`fontTools.designspaceLib`: Read and write designspace files - :py:mod:`fontTools.encodings`: Support for font-related character encodings @@ -120,6 +121,7 @@ Table of Contents agl cffLib/index colorLib/index + config cu2qu/index designspaceLib/index encodings/index @@ -152,4 +154,4 @@ Table of Contents :target: https://pypi.org/project/FontTools .. |Gitter Chat| image:: https://badges.gitter.im/fonttools-dev/Lobby.svg :alt: Join the chat at https://gitter.im/fonttools-dev/Lobby - :target: https://gitter.im/fonttools-dev/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge \ No newline at end of file + :target: https://gitter.im/fonttools-dev/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge diff --git a/Doc/source/misc/configTools.rst b/Doc/source/misc/configTools.rst new file mode 100644 index 000000000..1d9354119 --- /dev/null +++ b/Doc/source/misc/configTools.rst @@ -0,0 +1,8 @@ +########### +configTools +########### + +.. automodule:: fontTools.misc.configTools + :inherited-members: + :members: + :undoc-members: diff --git a/Doc/source/misc/index.rst b/Doc/source/misc/index.rst index 003c48a5f..cfe520557 100644 --- a/Doc/source/misc/index.rst +++ b/Doc/source/misc/index.rst @@ -12,6 +12,7 @@ utilities by fontTools, but some of which may be more generally useful. bezierTools classifyTools cliTools + configTools eexec encodingTools etree diff --git a/Lib/fontTools/config/__init__.py b/Lib/fontTools/config/__init__.py new file mode 100644 index 000000000..b2834cdd2 --- /dev/null +++ b/Lib/fontTools/config/__init__.py @@ -0,0 +1,41 @@ +""" +Define all configuration options that can affect the working of fontTools +modules. E.g. optimization levels of varLib IUP, otlLib GPOS compression level, +etc. If this file gets too big, split it into smaller files per-module. + +An instance of the Config class can be attached to a TTFont object, so that +the various modules can access their configuration options from it. +""" +from textwrap import dedent + +from fontTools.misc.configTools import * + + +class Config(AbstractConfig): + options = Options() + +OPTIONS = Config.options + +Config.register_option( + name="fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL", + help=dedent( + """\ + GPOS Lookup type 2 (PairPos) compression level: + 0 = do not attempt to compact PairPos lookups; + 1 to 8 = create at most 1 to 8 new subtables for each existing + subtable, provided that it would yield a 50%% file size saving; + 9 = create as many new subtables as needed to yield a file size saving. + Default: 0. + + This compaction aims to save file size, by splitting large class + kerning subtables (Format 2) that contain many zero values into + smaller and denser subtables. It's a trade-off between the overhead + of several subtables versus the sparseness of one big subtable. + + See the pull request: https://github.com/fonttools/fonttools/pull/2326 + """ + ), + default=0, + parse=int, + validate=lambda v: v in range(10), +) diff --git a/Lib/fontTools/misc/configTools.py b/Lib/fontTools/misc/configTools.py new file mode 100644 index 000000000..24d51fa7d --- /dev/null +++ b/Lib/fontTools/misc/configTools.py @@ -0,0 +1,274 @@ +""" +Code of the config system; not related to fontTools or fonts in particular. + +The options that are specific to fontTools are in :mod:`fontTools.config`. + +To create your own config system, you need to create an instance of +:class:`Options`, and a subclass of :class:`AbstractConfig` with its +``options`` class variable set to your instance of Options. + +""" +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import ( + Any, + Callable, + ClassVar, + Dict, + Iterator, + Mapping, + MutableMapping, + Union, +) + + +log = logging.getLogger(__name__) + +__all__ = [ + "AbstractConfig", + "ConfigAlreadyRegisteredError", + "ConfigError", + "ConfigUnknownOptionError", + "ConfigValueParsingError", + "ConfigValueValidationError", + "Option", + "Options", +] + + +class ConfigError(Exception): + """Base exception for the config module.""" + + +class ConfigAlreadyRegisteredError(ConfigError): + """Raised when a module tries to register a configuration option that + already exists. + + Should not be raised too much really, only when developing new fontTools + modules. + """ + + def __init__(self, name): + super().__init__(f"Config option {name} is already registered.") + + +class ConfigValueParsingError(ConfigError): + """Raised when a configuration value cannot be parsed.""" + + def __init__(self, name, value): + super().__init__( + f"Config option {name}: value cannot be parsed (given {repr(value)})" + ) + + +class ConfigValueValidationError(ConfigError): + """Raised when a configuration value cannot be validated.""" + + def __init__(self, name, value): + super().__init__( + f"Config option {name}: value is invalid (given {repr(value)})" + ) + + +_NO_VALUE = object() + + +class ConfigUnknownOptionError(ConfigError): + """Raised when a configuration option is unknown.""" + + def __init__(self, name, value=_NO_VALUE): + super().__init__( + f"Config option {name} is unknown" + + ("" if value is _NO_VALUE else f" (with given value {repr(value)})") + ) + + +@dataclass +class Option: + help: str + """Help text for this option.""" + default: Any + """Default value for this option.""" + parse: Callable[[str], Any] + """Turn input (e.g. string) into proper type. Only when reading from file.""" + validate: Callable[[Any], bool] + """Return true if the given value is an acceptable value.""" + + +class Options(Mapping): + """Registry of available options for a given config system. + + Define new options using the :meth:`register()` method. + + Access existing options using the Mapping interface. + """ + + __options: Dict[str, Option] + + def __init__(self) -> None: + self.__options = {} + + def register( + self, + name: str, + help: str, + default: Any, + parse: Callable[[str], Any], + validate: Callable[[Any], bool], + ) -> Option: + """Register a new option.""" + return self.register_option(name, Option(help, default, parse, validate)) + + def register_option(self, name: str, option: Option) -> Option: + """Register a new option.""" + if name in self.__options: + raise ConfigAlreadyRegisteredError(name) + self.__options[name] = option + return option + + def __getitem__(self, __k: str) -> Option: + return self.__options.__getitem__(__k) + + def __iter__(self) -> Iterator[str]: + return self.__options.__iter__() + + def __len__(self) -> int: + return self.__options.__len__() + + +_USE_GLOBAL_DEFAULT = object() + + +class AbstractConfig(MutableMapping): + """ + Create a set of config values, optionally pre-filled with values from + the given dictionary. + + .. seealso:: :meth:`set()` + + This config class is abstract because it needs its ``options`` class + var to be set to an instance of :class:`Options` before it can be + instanciated and used. + + .. code:: python + + class MyConfig(AbstractConfig): + options = Options() + + MyConfig.register_option( "test:option_name", "This is an option", 0, int, lambda v: isinstance(v, int)) + + cfg = MyConfig({"test:option_name": 10}) + """ + + options: ClassVar[Options] + + @classmethod + def register_option(cls, *args, **kwargs) -> Option: + """Register an available option in this config system.""" + return cls.options.register(*args, **kwargs) + + _values: Dict[str, Any] + + def __init__( + self, + values: Union[AbstractConfig, Dict] = {}, + parse_values=False, + skip_unknown=False, + ): + self._values = {} + values_dict = values._values if isinstance(values, AbstractConfig) else values + for name, value in values_dict.items(): + self.set(name, value, parse_values, skip_unknown) + + def set(self, name: str, value: Any, parse_values=False, skip_unknown=False): + """Set the value of an option. + + Args: + * `parse_values`: parse the configuration value from a string into + its proper type, as per its `Option` object. The default + behavior is to raise `ConfigValueValidationError` when the value + is not of the right type. Useful when reading options from a + file type that doesn't support as many types as Python. + * `skip_unknown`: skip unknown configuration options. The default + behaviour is to raise `ConfigUnknownOptionError`. Useful when + reading options from a configuration file that has extra entries + (e.g. for a later version of fontTools) + """ + try: + option = self.options[name] + except KeyError: + if skip_unknown: + log.debug( + "Config option %s is unknown (with given value %r)", name, value + ) + return + else: + raise ConfigUnknownOptionError(name, value) + + # Can be useful if the values come from a source that doesn't have + # strict typing (.ini file? Terminal input?) + if parse_values: + try: + value = option.parse(value) + except Exception as e: + raise ConfigValueParsingError(name, value) from e + + if not option.validate(value): + raise ConfigValueValidationError(name, value) + + self._values[name] = value + + def get(self, name: str, default=_USE_GLOBAL_DEFAULT): + """ + Get the value of an option. The value which is returned is the first + provided among: + + 1. a user-provided value in the options's ``self._values`` dict + 2. a caller-provided default value to this method call + 3. the global default for the option provided in ``fontTools.config`` + + This is to provide the ability to migrate progressively from config + options passed as arguments to fontTools APIs to config options read + from the current TTFont, e.g. + + .. code:: python + + def fontToolsAPI(font, some_option): + value = font.cfg.get("someLib.module:SOME_OPTION", some_option) + # use value + + That way, the function will work the same for users of the API that + still pass the option to the function call, but will favour the new + config mechanism if the given font specifies a value for that option. + """ + if name in self._values: + return self._values[name] + if default is not _USE_GLOBAL_DEFAULT: + return default + try: + return self.options[name].default + except KeyError as e: + raise ConfigUnknownOptionError(name) from e + + def copy(self): + return self.__class__(self._values) + + def __getitem__(self, name: str) -> Any: + return self.get(name) + + def __setitem__(self, name: str, value: Any) -> None: + return self.set(name, value) + + def __delitem__(self, name: str) -> None: + del self._values[name] + + def __iter__(self) -> Iterator: + return self._values.__iter__() + + def __len__(self) -> int: + return len(self._values) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({repr(self._values)})" diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index cec1ec317..0e2de3778 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -11,11 +11,7 @@ from fontTools.ttLib.tables.otBase import ( ) from fontTools.ttLib.tables import otBase from fontTools.feaLib.ast import STATNameStatement -from fontTools.otlLib.optimize.gpos import ( - GPOS_COMPACT_MODE_DEFAULT, - GPOS_COMPACT_MODE_ENV_KEY, - compact_lookup, -) +from fontTools.otlLib.optimize.gpos import compact_lookup from fontTools.otlLib.error import OpenTypeLibError from functools import reduce import logging @@ -1414,10 +1410,10 @@ class PairPosBuilder(LookupBuilder): # Compact the lookup # This is a good moment to do it because the compaction should create # smaller subtables, which may prevent overflows from happening. - mode = os.environ.get(GPOS_COMPACT_MODE_ENV_KEY, GPOS_COMPACT_MODE_DEFAULT) - if mode and mode != "0": + level = self.font.cfg["fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL"] + if level and level != 0: log.info("Compacting GPOS...") - compact_lookup(self.font, mode, lookup) + compact_lookup(self.font, level, lookup) return lookup diff --git a/Lib/fontTools/otlLib/optimize/__init__.py b/Lib/fontTools/otlLib/optimize/__init__.py index 5c007e891..38c02815b 100644 --- a/Lib/fontTools/otlLib/optimize/__init__.py +++ b/Lib/fontTools/otlLib/optimize/__init__.py @@ -1,39 +1,30 @@ from argparse import RawTextHelpFormatter -from textwrap import dedent +from fontTools.config import OPTIONS +from fontTools.otlLib.optimize.gpos import compact from fontTools.ttLib import TTFont -from fontTools.otlLib.optimize.gpos import compact, GPOS_COMPACT_MODE_DEFAULT + def main(args=None): """Optimize the layout tables of an existing font.""" from argparse import ArgumentParser + from fontTools import configLogger - parser = ArgumentParser(prog="otlLib.optimize", description=main.__doc__, formatter_class=RawTextHelpFormatter) + parser = ArgumentParser( + prog="otlLib.optimize", + description=main.__doc__, + formatter_class=RawTextHelpFormatter, + ) parser.add_argument("font") parser.add_argument( "-o", metavar="OUTPUTFILE", dest="outfile", default=None, help="output file" ) + COMPRESSION_LEVEL = OPTIONS["fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL"] parser.add_argument( - "--gpos-compact-mode", - help=dedent( - f"""\ - GPOS Lookup type 2 (PairPos) compaction mode: - 0 = do not attempt to compact PairPos lookups; - 1 to 8 = create at most 1 to 8 new subtables for each existing - subtable, provided that it would yield a 50%% file size saving; - 9 = create as many new subtables as needed to yield a file size saving. - Default: {GPOS_COMPACT_MODE_DEFAULT}. - - This compaction aims to save file size, by splitting large class - kerning subtables (Format 2) that contain many zero values into - smaller and denser subtables. It's a trade-off between the overhead - of several subtables versus the sparseness of one big subtable. - - See the pull request: https://github.com/fonttools/fonttools/pull/2326 - """ - ), - default=int(GPOS_COMPACT_MODE_DEFAULT), + "--gpos-compression-level", + help=COMPRESSION_LEVEL.help, + default=COMPRESSION_LEVEL.default, choices=list(range(10)), type=int, ) @@ -51,12 +42,10 @@ def main(args=None): ) font = TTFont(options.font) - # TODO: switch everything to have type(mode) = int when using the Config class - compact(font, str(options.gpos_compact_mode)) + compact(font, options.gpos_compression_level) font.save(options.outfile or options.font) - if __name__ == "__main__": import sys @@ -65,4 +54,3 @@ if __name__ == "__main__": import doctest sys.exit(doctest.testmod().failed) - diff --git a/Lib/fontTools/otlLib/optimize/gpos.py b/Lib/fontTools/otlLib/optimize/gpos.py index 79873fadb..eb0456f29 100644 --- a/Lib/fontTools/otlLib/optimize/gpos.py +++ b/Lib/fontTools/otlLib/optimize/gpos.py @@ -9,16 +9,10 @@ from fontTools.misc.intTools import bit_count, bit_indices from fontTools.ttLib import TTFont from fontTools.ttLib.tables import otBase, otTables -# NOTE: activating this optimization via the environment variable is -# experimental and may not be supported once an alternative mechanism -# is in place. See: https://github.com/fonttools/fonttools/issues/2349 -GPOS_COMPACT_MODE_ENV_KEY = "FONTTOOLS_GPOS_COMPACT_MODE" -GPOS_COMPACT_MODE_DEFAULT = "0" - log = logging.getLogger("fontTools.otlLib.optimize.gpos") -def compact(font: TTFont, mode: str) -> TTFont: +def compact(font: TTFont, level: int) -> TTFont: # Ideal plan: # 1. Find lookups of Lookup Type 2: Pair Adjustment Positioning Subtable # https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-2-pair-adjustment-positioning-subtable @@ -35,21 +29,21 @@ def compact(font: TTFont, mode: str) -> TTFont: gpos = font["GPOS"] for lookup in gpos.table.LookupList.Lookup: if lookup.LookupType == 2: - compact_lookup(font, mode, lookup) + compact_lookup(font, level, lookup) elif lookup.LookupType == 9 and lookup.SubTable[0].ExtensionLookupType == 2: - compact_ext_lookup(font, mode, lookup) + compact_ext_lookup(font, level, lookup) return font -def compact_lookup(font: TTFont, mode: str, lookup: otTables.Lookup) -> None: - new_subtables = compact_pair_pos(font, mode, lookup.SubTable) +def compact_lookup(font: TTFont, level: int, lookup: otTables.Lookup) -> None: + new_subtables = compact_pair_pos(font, level, lookup.SubTable) lookup.SubTable = new_subtables lookup.SubTableCount = len(new_subtables) -def compact_ext_lookup(font: TTFont, mode: str, lookup: otTables.Lookup) -> None: +def compact_ext_lookup(font: TTFont, level: int, lookup: otTables.Lookup) -> None: new_subtables = compact_pair_pos( - font, mode, [ext_subtable.ExtSubTable for ext_subtable in lookup.SubTable] + font, level, [ext_subtable.ExtSubTable for ext_subtable in lookup.SubTable] ) new_ext_subtables = [] for subtable in new_subtables: @@ -62,7 +56,7 @@ def compact_ext_lookup(font: TTFont, mode: str, lookup: otTables.Lookup) -> None def compact_pair_pos( - font: TTFont, mode: str, subtables: Sequence[otTables.PairPos] + font: TTFont, level: int, subtables: Sequence[otTables.PairPos] ) -> Sequence[otTables.PairPos]: new_subtables = [] for subtable in subtables: @@ -70,12 +64,12 @@ def compact_pair_pos( # Not doing anything to Format 1 (yet?) new_subtables.append(subtable) elif subtable.Format == 2: - new_subtables.extend(compact_class_pairs(font, mode, subtable)) + new_subtables.extend(compact_class_pairs(font, level, subtable)) return new_subtables def compact_class_pairs( - font: TTFont, mode: str, subtable: otTables.PairPos + font: TTFont, level: int, subtable: otTables.PairPos ) -> List[otTables.PairPos]: from fontTools.otlLib.builder import buildPairPosClassesSubtable @@ -95,17 +89,9 @@ def compact_class_pairs( getattr(class2, "Value1", None), getattr(class2, "Value2", None), ) - - if len(mode) == 1 and mode in "123456789": - grouped_pairs = cluster_pairs_by_class2_coverage_custom_cost( - font, all_pairs, int(mode) - ) - for pairs in grouped_pairs: - subtables.append( - buildPairPosClassesSubtable(pairs, font.getReverseGlyphMap()) - ) - else: - raise ValueError(f"Bad {GPOS_COMPACT_MODE_ENV_KEY}={mode}") + grouped_pairs = cluster_pairs_by_class2_coverage_custom_cost(font, all_pairs, level) + for pairs in grouped_pairs: + subtables.append(buildPairPosClassesSubtable(pairs, font.getReverseGlyphMap())) return subtables diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py index 9c779243a..d7f7ef835 100644 --- a/Lib/fontTools/ttLib/ttFont.py +++ b/Lib/fontTools/ttLib/ttFont.py @@ -1,4 +1,6 @@ +from fontTools.config import Config from fontTools.misc import xmlWriter +from fontTools.misc.configTools import AbstractConfig from fontTools.misc.textTools import Tag, byteord, tostr from fontTools.misc.loggingTools import deprecateArgument from fontTools.ttLib import TTLibError @@ -49,7 +51,7 @@ class TTFont(object): >> tt2.importXML("afont.ttx") >> tt2['maxp'].numGlyphs 242 - + The TTFont object may be used as a context manager; this will cause the file reader to be closed after the context ``with`` block is exited:: @@ -89,7 +91,7 @@ class TTFont(object): sfntVersion="\000\001\000\000", flavor=None, checkChecksums=0, verbose=None, recalcBBoxes=True, allowVID=NotImplemented, ignoreDecompileErrors=False, recalcTimestamp=True, fontNumber=-1, lazy=None, quiet=None, - _tableCache=None): + _tableCache=None, cfg={}): for name in ("verbose", "quiet"): val = locals().get(name) if val is not None: @@ -101,6 +103,7 @@ class TTFont(object): self.recalcTimestamp = recalcTimestamp self.tables = {} self.reader = None + self.cfg = cfg.copy() if isinstance(cfg, AbstractConfig) else Config(cfg) self.ignoreDecompileErrors = ignoreDecompileErrors if not file: diff --git a/Lib/fontTools/varLib/merger.py b/Lib/fontTools/varLib/merger.py index 5a3a4f343..515e66b1a 100644 --- a/Lib/fontTools/varLib/merger.py +++ b/Lib/fontTools/varLib/merger.py @@ -15,11 +15,7 @@ from fontTools.varLib.models import nonNone, allNone, allEqual, allEqualTo from fontTools.varLib.varStore import VarStoreInstancer from functools import reduce from fontTools.otlLib.builder import buildSinglePos -from fontTools.otlLib.optimize.gpos import ( - compact_pair_pos, - GPOS_COMPACT_MODE_DEFAULT, - GPOS_COMPACT_MODE_ENV_KEY, -) +from fontTools.otlLib.optimize.gpos import compact_pair_pos log = logging.getLogger("fontTools.varLib.merger") @@ -850,10 +846,10 @@ def merge(merger, self, lst): # Compact the merged subtables # This is a good moment to do it because the compaction should create # smaller subtables, which may prevent overflows from happening. - mode = os.environ.get(GPOS_COMPACT_MODE_ENV_KEY, GPOS_COMPACT_MODE_DEFAULT) - if mode and mode != "0": + level = merger.font.cfg["fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL"] + if level and level != 0: log.info("Compacting GPOS...") - self.SubTable = compact_pair_pos(merger.font, mode, self.SubTable) + self.SubTable = compact_pair_pos(merger.font, level, self.SubTable) self.SubTableCount = len(self.SubTable) elif isSinglePos and flattened: diff --git a/Tests/config_test.py b/Tests/config_test.py new file mode 100644 index 000000000..592a925f2 --- /dev/null +++ b/Tests/config_test.py @@ -0,0 +1,121 @@ +from fontTools.misc.configTools import AbstractConfig, Options +import pytest +from fontTools.config import ( + Config, + ConfigUnknownOptionError, + ConfigValueParsingError, + ConfigValueValidationError, +) +from fontTools.ttLib import TTFont + + +def test_can_register_option(): + MY_OPTION = Config.register_option( + name="tests:MY_OPTION", + help="Test option, value should be True or False, default = True", + default=True, + parse=lambda v: v in ("True", "true", 1, True), + validate=lambda v: v == True or v == False, + ) + + assert ( + MY_OPTION.help == "Test option, value should be True or False, default = True" + ) + assert MY_OPTION.default == True + assert MY_OPTION.parse("true") == True + assert MY_OPTION.validate("hello") == False + + ttFont = TTFont(cfg={"tests:MY_OPTION": True}) + assert True == ttFont.cfg.get("tests:MY_OPTION") + + +COMPRESSION_LEVEL = "fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL" + + +def test_ttfont_has_config(): + ttFont = TTFont(cfg={COMPRESSION_LEVEL: 8}) + assert 8 == ttFont.cfg.get(COMPRESSION_LEVEL) + + +def test_ttfont_can_take_superset_of_fonttools_config(): + # Create MyConfig with all options from fontTools.config plus some + my_options = Options() + for name, option in Config.options.items(): + my_options.register_option(name, option) + + my_options.register("custom:my_option", "help", "default", str, any) + + class MyConfig(AbstractConfig): + options = my_options + + ttFont = TTFont(cfg=MyConfig({"custom:my_option": "my_value"})) + assert 0 == ttFont.cfg.get(COMPRESSION_LEVEL) + assert "my_value" == ttFont.cfg.get("custom:my_option") + + +def test_no_config_returns_default_values(): + ttFont = TTFont() + assert 0 == ttFont.cfg.get(COMPRESSION_LEVEL) + assert 3 == ttFont.cfg.get(COMPRESSION_LEVEL, 3) + + +def test_can_set_config(): + ttFont = TTFont() + ttFont.cfg.set(COMPRESSION_LEVEL, 5) + assert 5 == ttFont.cfg.get(COMPRESSION_LEVEL) + ttFont.cfg.set(COMPRESSION_LEVEL, 6) + assert 6 == ttFont.cfg.get(COMPRESSION_LEVEL) + + +def test_different_ttfonts_have_different_configs(): + cfg = Config({COMPRESSION_LEVEL: 5}) + ttFont1 = TTFont(cfg=cfg) + ttFont2 = TTFont(cfg=cfg) + ttFont2.cfg.set(COMPRESSION_LEVEL, 6) + assert 5 == ttFont1.cfg.get(COMPRESSION_LEVEL) + assert 6 == ttFont2.cfg.get(COMPRESSION_LEVEL) + + +def test_cannot_set_inexistent_key(): + with pytest.raises(ConfigUnknownOptionError): + TTFont(cfg={"notALib.notAModule.inexistent": 4}) + + +def test_value_not_parsed_by_default(): + # Note: value given as a string + with pytest.raises(ConfigValueValidationError): + TTFont(cfg={COMPRESSION_LEVEL: "8"}) + + +def test_value_gets_parsed_if_asked(): + # Note: value given as a string + ttFont = TTFont(cfg=Config({COMPRESSION_LEVEL: "8"}, parse_values=True)) + assert 8 == ttFont.cfg.get(COMPRESSION_LEVEL) + + +def test_value_parsing_can_error(): + with pytest.raises(ConfigValueParsingError): + TTFont( + cfg=Config( + {COMPRESSION_LEVEL: "not an int"}, + parse_values=True, + ) + ) + + +def test_value_gets_validated(): + # Note: 12 is not a valid value for GPOS compression level (must be in 0-9) + with pytest.raises(ConfigValueValidationError): + TTFont(cfg={COMPRESSION_LEVEL: 12}) + + +def test_implements_mutable_mapping(): + cfg = Config() + cfg[COMPRESSION_LEVEL] = 2 + assert 2 == cfg[COMPRESSION_LEVEL] + assert [COMPRESSION_LEVEL] == list(iter(cfg)) + assert 1 == len(cfg) + del cfg[COMPRESSION_LEVEL] + assert 0 == cfg[COMPRESSION_LEVEL] + assert [] == list(iter(cfg)) + assert 0 == len(cfg) diff --git a/Tests/misc/configTools_test.py b/Tests/misc/configTools_test.py new file mode 100644 index 000000000..bb03f2377 --- /dev/null +++ b/Tests/misc/configTools_test.py @@ -0,0 +1,34 @@ +import pytest + +from fontTools.misc.configTools import AbstractConfig, Options, ConfigUnknownOptionError + + +def test_can_create_custom_config_system(): + class MyConfig(AbstractConfig): + options = Options() + + MyConfig.register_option( + "test:option_name", + "This is an option", + 0, + int, + lambda v: isinstance(v, int), + ) + + cfg = MyConfig({"test:option_name": "10"}, parse_values=True) + + assert 10 == cfg["test:option_name"] + + # This config is independent from "the" fontTools config + with pytest.raises(ConfigUnknownOptionError): + MyConfig({"fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL": 4}) + + # Test the repr() + assert repr(cfg) == "MyConfig({'test:option_name': 10})" + + # Test the skip_unknown param: just check that the following does not raise + MyConfig({"test:unknown": "whatever"}, skip_unknown=True) + + # Test that it raises on unknown option + with pytest.raises(ConfigUnknownOptionError): + cfg.get("test:unknown") diff --git a/Tests/otlLib/optimize_test.py b/Tests/otlLib/optimize_test.py index 40cf389e3..a2e433225 100644 --- a/Tests/otlLib/optimize_test.py +++ b/Tests/otlLib/optimize_test.py @@ -1,17 +1,15 @@ +import contextlib import logging +import os from pathlib import Path from subprocess import run -import contextlib -import os from typing import List, Optional, Tuple -from fontTools.ttLib import TTFont import pytest - from fontTools.feaLib.builder import addOpenTypeFeaturesFromString from fontTools.fontBuilder import FontBuilder - -from fontTools.ttLib.tables.otBase import OTTableWriter, ValueRecord +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables.otBase import OTTableWriter def test_main(tmpdir: Path): @@ -34,7 +32,7 @@ def test_main(tmpdir: Path): [ "fonttools", "otlLib.optimize", - "--gpos-compact-mode", + "--gpos-compression-level", "5", str(input), "-o", @@ -127,9 +125,9 @@ def get_kerning_by_blocks(blocks: List[Tuple[int, int]]) -> Tuple[List[str], str @pytest.mark.parametrize( - ("blocks", "mode", "expected_subtables", "expected_bytes"), + ("blocks", "level", "expected_subtables", "expected_bytes"), [ - # Mode = 0 = no optimization leads to 650 bytes of GPOS + # Level = 0 = no optimization leads to 650 bytes of GPOS ([(15, 3), (2, 10)], None, 1, 602), # Optimization level 1 recognizes the 2 blocks and splits into 2 # subtables = adds 1 subtable leading to a size reduction of @@ -143,13 +141,13 @@ def get_kerning_by_blocks(blocks: List[Tuple[int, int]]) -> Tuple[List[str], str ([(4, 4) for _ in range(20)], 9, 20, 1886), # On a fully occupied kerning matrix, even the strategy 9 doesn't # split anything. - ([(10, 10)], 9, 1, 304) + ([(10, 10)], 9, 1, 304), ], ) def test_optimization_mode( caplog, blocks: List[Tuple[int, int]], - mode: Optional[int], + level: Optional[int], expected_subtables: int, expected_bytes: int, ): @@ -161,15 +159,10 @@ def test_optimization_mode( glyphs, features = get_kerning_by_blocks(blocks) glyphs = [".notdef space"] + glyphs - env = {} - if mode is not None: - # NOTE: activating this optimization via the environment variable is - # experimental and may not be supported once an alternative mechanism - # is in place. See: https://github.com/fonttools/fonttools/issues/2349 - env["FONTTOOLS_GPOS_COMPACT_MODE"] = str(mode) - with set_env(**env): - fb = FontBuilder(1000) - fb.setupGlyphOrder(glyphs) - addOpenTypeFeaturesFromString(fb.font, features) - assert expected_subtables == count_pairpos_subtables(fb.font) - assert expected_bytes == count_pairpos_bytes(fb.font) + fb = FontBuilder(1000) + if level is not None: + fb.font.cfg["fontTools.otlLib.optimize.gpos:COMPRESSION_LEVEL"] = level + fb.setupGlyphOrder(glyphs) + addOpenTypeFeaturesFromString(fb.font, features) + assert expected_subtables == count_pairpos_subtables(fb.font) + assert expected_bytes == count_pairpos_bytes(fb.font)