Merge pull request #2416 from daltonmaag/add-conf

Add config module as per #2349
This commit is contained in:
Jany Belluz 2022-04-14 16:54:18 +02:00 committed by GitHub
commit afa0998be2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 549 additions and 95 deletions

3
.gitignore vendored
View File

@ -58,3 +58,6 @@ Lib/**/*.c
# Ctags
tags
# Documentation
Doc/source/_build

8
Doc/source/config.rst Normal file
View File

@ -0,0 +1,8 @@
###########################
config: configure fontTools
###########################
.. automodule:: fontTools.config
:inherited-members:
:members:
:undoc-members:

View File

@ -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

View File

@ -0,0 +1,8 @@
###########
configTools
###########
.. automodule:: fontTools.misc.configTools
:inherited-members:
:members:
:undoc-members:

View File

@ -12,6 +12,7 @@ utilities by fontTools, but some of which may be more generally useful.
bezierTools
classifyTools
cliTools
configTools
eexec
encodingTools
etree

View File

@ -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),
)

View File

@ -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)})"

View File

@ -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

View File

@ -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)

View File

@ -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)
)
grouped_pairs = cluster_pairs_by_class2_coverage_custom_cost(font, all_pairs, level)
for pairs in grouped_pairs:
subtables.append(
buildPairPosClassesSubtable(pairs, font.getReverseGlyphMap())
)
else:
raise ValueError(f"Bad {GPOS_COMPACT_MODE_ENV_KEY}={mode}")
subtables.append(buildPairPosClassesSubtable(pairs, font.getReverseGlyphMap()))
return subtables

View File

@ -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
@ -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:

View File

@ -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:

121
Tests/config_test.py Normal file
View File

@ -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)

View File

@ -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")

View File

@ -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,14 +159,9 @@ 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)
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)