diff options
| author | robot-piglet <[email protected]> | 2026-03-24 22:03:23 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-03-24 22:34:09 +0300 |
| commit | 6092233e61d1dc129fe1eb007399cc192c5ceb59 (patch) | |
| tree | 90522e5b7449e5cdb06bd24eafb333b9e9d3e9f1 /contrib/python/fonttools | |
| parent | c8c3fda4b2e47ceaad9790b7a5fb192110162f15 (diff) | |
Intermediate changes
commit_hash:5e2a2254279501ad2bde571fbd53c1a27a00e898
Diffstat (limited to 'contrib/python/fonttools')
49 files changed, 1426 insertions, 1080 deletions
diff --git a/contrib/python/fonttools/.dist-info/METADATA b/contrib/python/fonttools/.dist-info/METADATA index fcaae0d0409..c3a12aacdd4 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.61.1 +Version: 4.62.0 Summary: Tools to manipulate font files Home-page: http://github.com/fonttools/fonttools Author: Just van Rossum @@ -258,7 +258,7 @@ are required to unlock the extra features named "ufo", etc. Simplify TrueType glyphs by merging overlapping contours and components. - * `skia-pathops <https://pypi.python.org/pypy/skia-pathops>`__: Python + * `skia-pathops <https://pypi.python.org/pypi/skia-pathops>`__: Python bindings for the Skia library's PathOps module, performing boolean operations on paths (union, intersection, etc.). @@ -392,6 +392,55 @@ Have fun! Changelog ~~~~~~~~~ +4.62.0 (released 2026-03-09) +---------------------------- + +- [diff] Add new ``fonttools diff`` command for comparing font files, imported from the + ``fdiff`` project and heavily reworked (#1190, #4007, #4009, #4011, #4013, #4019). +- [feaLib] Fix ``VariableScalar`` interpolation bug with non-linear avar mappings. Also + decouple ``VariableScalar`` from compiled fonts, allowing it to work with designspace data + before compilation (#3938, #4054). +- [feaLib] Fix ``VariableScalar`` axis ordering and iterative delta rounding to match fontc + behavior (#4053). +- [feaLib] Merge chained multi subst rules with same context into a single subtable instead of + emitting one subtable per glyph (#4016, #4058). +- [feaLib] Pass location to ``ConditionsetStatement`` to fix glyphsLib round-tripping + (fontra/fontra-glyphs#130, #4057). +- [feaLib] Write ``0xFFFF`` instead of ``0`` for missing nameIDs in ``cv`` feature params + (#4010, #4012). +- [cmap] Fix ``CmapSubtable.__lt__()`` ``TypeError`` on Python 3 when subtables share the + same encoding record, and add compile-time validation for unique encoding records (#4035, + #4055). +- [svgLib] Skip non-element XML nodes (comments, processing instructions) when drawing SVG + paths (#4042, #4043). +- [glifLib] Fix regression reading glyph outlines when ``glyphObject=None`` (#4030, #4031). +- [pointPen] Fix ``SegmentToPointPen`` edge case: only remove a duplicate final point on + ``closePath()`` if it is an on-curve point (#4014, #4015). +- [cffLib] **SECURITY** Replace ``eval()`` with ``safeEval()`` in ``parseBlendList()`` to + prevent arbitrary code execution from crafted TTX files (#4039, #4040). +- [ttLib] Remove defunct Adobe SING Glyphlet tables (``META``, ``SING``, ``GMAP``, ``GPKG``) + (#4044). +- [varLib.interpolatable] Various bugfixes: fix swapped nodeTypes assignment, duplicate + kink-detector condition, typos, CFF2 vsindex parsing, glyph existence check, and plot + helpers (#4046). +- [varLib.models] Fix ``getSubModel`` not forwarding ``extrapolate``/``axisRanges``; check + location uniqueness after stripping zeros (#4047). +- [varLib] Fix ``--variable-fonts`` filter in ``build_many``; remove dead code and fix + comments (#4048). +- [avar] Preserve existing name table in build; keep ``unbuild`` return types consistent; + validate ``map`` CLI coordinates (#4051). +- [cu2qu/qu2cu] Add input validation: reject non-positive tolerances, validate curve inputs + and list lengths (#4052). +- [colorLib] Raise a clear ``ColorLibError`` when base glyphs are missing from glyphMap, + instead of a confusing ``KeyError`` (#4041). +- [glyf] Remove unnecessary ``fvar`` table dependency (#4017). +- [fvar/trak] Remove unnecessary ``name`` table dependency (#4018). +- [ufoLib] Relax guideline validation to follow the updated spec (#3537, #3553). +- [ttFont] Fix ``saveXML`` regression with empty table lists, clarify docstring (#4025, #4026, + #4056). +- [setup.py] Link ``libm`` for Cython extensions using math functions (#4028, #4029). +- Add typing annotations for ``DSIG``, ``DefaultTable``, ``ttProgram`` (#4033). + 4.61.1 (released 2025-12-12) ---------------------------- diff --git a/contrib/python/fonttools/README.rst b/contrib/python/fonttools/README.rst index 2c0af437467..b1391d392f1 100644 --- a/contrib/python/fonttools/README.rst +++ b/contrib/python/fonttools/README.rst @@ -171,7 +171,7 @@ are required to unlock the extra features named "ufo", etc. Simplify TrueType glyphs by merging overlapping contours and components. - * `skia-pathops <https://pypi.python.org/pypy/skia-pathops>`__: Python + * `skia-pathops <https://pypi.python.org/pypi/skia-pathops>`__: Python bindings for the Skia library's PathOps module, performing boolean operations on paths (union, intersection, etc.). diff --git a/contrib/python/fonttools/fontTools/__init__.py b/contrib/python/fonttools/fontTools/__init__.py index 953cda045ee..c5eb5eaca3f 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.61.1" +version = __version__ = "4.62.0" __all__ = ["version", "log", "configLogger"] diff --git a/contrib/python/fonttools/fontTools/cffLib/__init__.py b/contrib/python/fonttools/fontTools/cffLib/__init__.py index 4ad724a27a8..8ced9c79fc2 100644 --- a/contrib/python/fonttools/fontTools/cffLib/__init__.py +++ b/contrib/python/fonttools/fontTools/cffLib/__init__.py @@ -1264,7 +1264,7 @@ def parseBlendList(s): continue name, attrs, content = element blendList = attrs["value"].split() - blendList = [eval(val) for val in blendList] + blendList = [safeEval(val) for val in blendList] valueList.append(blendList) if len(valueList) == 1: valueList = valueList[0] diff --git a/contrib/python/fonttools/fontTools/colorLib/builder.py b/contrib/python/fonttools/fontTools/colorLib/builder.py index 6e45e7a8850..8ec90170771 100644 --- a/contrib/python/fonttools/fontTools/colorLib/builder.py +++ b/contrib/python/fonttools/fontTools/colorLib/builder.py @@ -154,6 +154,7 @@ def populateCOLRv0( ``TTFont.getReverseGlyphMap()``, to optionally sort base records by GID. """ if glyphMap is not None: + _check_base_glyphs_exist(colorGlyphsV0, glyphMap, "populateCOLRv0") colorGlyphItems = sorted( colorGlyphsV0.items(), key=lambda item: glyphMap[item[0]] ) @@ -621,6 +622,24 @@ def buildBaseGlyphPaintRecord( return self +def _check_base_glyphs_exist(colorGlyphs, glyphMap, where): + """Checks that every base glyph name in colorGlyphs exists in glyphMap.""" + + missing = [] + for baseGlyph in colorGlyphs.keys(): + if baseGlyph not in glyphMap: + missing.append(baseGlyph) + + if missing: + preview = ", ".join(missing[:10]) + extra = "" + if len(missing) > 10: + extra = f" (and {len(missing) - 10} more)" + raise ColorLibError( + f"{where}: base glyph(s) not found in glyphMap: {preview}{extra}" + ) + + def _format_glyph_errors(errors: Mapping[str, Exception]) -> str: lines = [] for baseGlyph, error in sorted(errors.items()): @@ -635,6 +654,7 @@ def buildColrV1( allowLayerReuse: bool = True, ) -> Tuple[Optional[ot.LayerList], ot.BaseGlyphList]: if glyphMap is not None: + _check_base_glyphs_exist(colorGlyphs, glyphMap, "buildColrV1") colorGlyphItems = sorted( colorGlyphs.items(), key=lambda item: glyphMap[item[0]] ) diff --git a/contrib/python/fonttools/fontTools/cu2qu/cu2qu.py b/contrib/python/fonttools/fontTools/cu2qu/cu2qu.py index 150c03fb4a0..575bbc7b451 100644 --- a/contrib/python/fonttools/fontTools/cu2qu/cu2qu.py +++ b/contrib/python/fonttools/fontTools/cu2qu/cu2qu.py @@ -480,13 +480,15 @@ def curve_to_quadratic(curve, max_err, all_quadratic=True): curve or a single cubic curve. Returns: - If all_quadratic is True: A list of 2D tuples, representing - control points of the quadratic spline if it fits within the - given tolerance, or ``None`` if no suitable spline could be - calculated. + If all_quadratic is True: A list of 2D tuples representing + control points of the quadratic spline. If all_quadratic is False: Either a quadratic curve (if length of output is 3), or a cubic curve (if length of output is 4). + + Raises: + fontTools.cu2qu.errors.ApproxNotFoundError: if no suitable + approximation can be found with the given parameters. """ curve = [complex(*p) for p in curve] @@ -529,18 +531,22 @@ def curves_to_quadratic(curves, max_errors, all_quadratic=True): Returns: If all_quadratic is True, a list of splines, each spline being a list - of 2D tuples. + of 2D tuples. If ``curves`` is empty, returns an empty list. If all_quadratic is False, a list of curves, each curve being a quadratic (length 3), or cubic (length 4). Raises: - fontTools.cu2qu.Errors.ApproxNotFoundError: if no suitable approximation + ValueError: if ``max_errors`` does not match the number of curves. + fontTools.cu2qu.errors.ApproxNotFoundError: if no suitable approximation can be found for all curves with the given parameters. """ curves = [[complex(*p) for p in curve] for curve in curves] - assert len(max_errors) == len(curves) + if len(max_errors) != len(curves): + raise ValueError("max_errors must match the number of curves") + if not curves: + return [] l = len(curves) splines = [None] * l diff --git a/contrib/python/fonttools/fontTools/cu2qu/ufo.py b/contrib/python/fonttools/fontTools/cu2qu/ufo.py index db9a1b0384b..6b114d73d7f 100644 --- a/contrib/python/fonttools/fontTools/cu2qu/ufo.py +++ b/contrib/python/fonttools/fontTools/cu2qu/ufo.py @@ -62,6 +62,21 @@ def zip(*args): return list(_zip(*args)) +def _validate_positive_tolerance(value, name): + if value <= 0: + raise ValueError(f"{name} must be greater than zero") + + +def _validate_positive_tolerances(values, name): + for value in values: + _validate_positive_tolerance(value, name) + + +def _validate_length(values, expected, name): + if len(values) != expected: + raise ValueError(f"{name} must match the number of inputs") + + class GetSegmentsPen(AbstractPen): """Pen to collect segments into lists of points for conversion. @@ -231,7 +246,7 @@ def glyphs_to_quadratic( if stats is None: stats = {} - if not max_err: + if max_err is None: # assume 1000 is the default UPEM max_err = DEFAULT_MAX_ERR * 1000 @@ -239,7 +254,8 @@ def glyphs_to_quadratic( max_errors = max_err else: max_errors = [max_err] * len(glyphs) - assert len(max_errors) == len(glyphs) + _validate_length(max_errors, len(glyphs), "max_err") + _validate_positive_tolerances(max_errors, "max_err") return _glyphs_to_quadratic( glyphs, max_errors, reverse_direction, stats, all_quadratic @@ -293,21 +309,25 @@ def fonts_to_quadratic( if stats is None: stats = {} - if max_err_em and max_err: + if max_err_em is not None and max_err is not None: raise TypeError("Only one of max_err and max_err_em can be specified.") - if not (max_err_em or max_err): + if max_err_em is None and max_err is None: max_err_em = DEFAULT_MAX_ERR if isinstance(max_err, (list, tuple)): - assert len(max_err) == len(fonts) + _validate_length(max_err, len(fonts), "max_err") max_errors = max_err - elif max_err: + _validate_positive_tolerances(max_errors, "max_err") + elif max_err is not None: + _validate_positive_tolerance(max_err, "max_err") max_errors = [max_err] * len(fonts) if isinstance(max_err_em, (list, tuple)): - assert len(fonts) == len(max_err_em) + _validate_length(max_err_em, len(fonts), "max_err_em") + _validate_positive_tolerances(max_err_em, "max_err_em") max_errors = [f.info.unitsPerEm * e for f, e in zip(fonts, max_err_em)] - elif max_err_em: + elif max_err_em is not None: + _validate_positive_tolerance(max_err_em, "max_err_em") max_errors = [f.info.unitsPerEm * max_err_em for f in fonts] modified = set() diff --git a/contrib/python/fonttools/fontTools/designspaceLib/__init__.py b/contrib/python/fonttools/fontTools/designspaceLib/__init__.py index 4782041b43a..6f4bfdb9591 100644 --- a/contrib/python/fonttools/fontTools/designspaceLib/__init__.py +++ b/contrib/python/fonttools/fontTools/designspaceLib/__init__.py @@ -878,16 +878,19 @@ def tagForAxisName(name): class AbstractAxisDescriptor(SimpleDescriptor): + name: str | None + map: list[tuple[float, float]] + flavor = "axis" def __init__( self, *, tag=None, - name=None, + name: str | None = None, labelNames=None, hidden=False, - map=None, + map: list[tuple[float, float]] | None = None, axisOrdering=None, axisLabels=None, ): @@ -977,13 +980,13 @@ class AxisDescriptor(AbstractAxisDescriptor): self, *, tag=None, - name=None, + name: str | None = None, labelNames=None, minimum=None, default=None, maximum=None, hidden=False, - map=None, + map: list[tuple[float, float]] | None = None, axisOrdering=None, axisLabels=None, ): @@ -1084,12 +1087,12 @@ class DiscreteAxisDescriptor(AbstractAxisDescriptor): self, *, tag=None, - name=None, + name: str | None = None, labelNames=None, values=None, default=None, hidden=False, - map=None, + map: list[tuple[float, float]] | None = None, axisOrdering=None, axisLabels=None, ): diff --git a/contrib/python/fonttools/fontTools/diff/__init__.py b/contrib/python/fonttools/fontTools/diff/__init__.py new file mode 100644 index 00000000000..b876520b2a6 --- /dev/null +++ b/contrib/python/fonttools/fontTools/diff/__init__.py @@ -0,0 +1,441 @@ +import argparse +import os +import sys +import shutil +import subprocess +from typing import Iterable, Iterator, List, Optional, Text, Tuple + +from .color import color_unified_diff_line +from .diff import run_external_diff, u_diff +from .utils import file_exists, get_tables_argument_list + + +def pipe_output(output: str) -> None: + """Pipes output to a pager if stdout is a TTY and a pager is available.""" + + if not output: + return + + if not sys.stdout.isatty(): + sys.stdout.write(output) + return + + pager = os.getenv("PAGER") or shutil.which("less") + + if not pager: + sys.stdout.write(output) + return + + pager_cmd = [pager] + if "less" in os.path.basename(pager): + pager_cmd.append("-R") + + proc = subprocess.Popen(pager_cmd, stdin=subprocess.PIPE, text=True) + try: + proc.stdin.write(output) + proc.stdin.close() + proc.wait() + except (BrokenPipeError, KeyboardInterrupt): + # Pager process was terminated before all output was written. + # This is not an error. The main exception handler will deal with it. + if proc.stdin: + proc.stdin.close() + # The process might still be running, but we have closed our side of the + # pipe. The Popen destructor will send a SIGKILL to the child. + except Exception: + if proc.stdin: + proc.stdin.close() + raise + + +def _is_gnu_diff(diff_tool: str) -> bool: + """Returns True if the provided diff executable is GNU diff.""" + try: + proc = subprocess.run( + [diff_tool, "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + except OSError: + return False + + version_output = (proc.stdout or "") + (proc.stderr or "") + return "GNU diffutils" in version_output + + +def _iter_filtered_table_tags( + tags: Iterable[str], + include_tables: Optional[List[str]] = None, + exclude_tables: Optional[List[str]] = None, +) -> Iterator[str]: + for tag in tags: + if exclude_tables and tag in exclude_tables: + continue + if include_tables and tag not in include_tables: + continue + yield tag + + +def summarize( + file1: str, + file2: str, + include_tables: Optional[List[str]] = None, + exclude_tables: Optional[List[str]] = None, + font_number_1: int = -1, + font_number_2: int = -1, +) -> Tuple[bool, str]: + from fontTools.ttLib import TTFont + + with ( + TTFont(file1, lazy=True, fontNumber=font_number_1) as font1, + TTFont(file2, lazy=True, fontNumber=font_number_2) as font2, + ): + tags1 = {str(tag) for tag in font1.reader.keys()} + tags2 = {str(tag) for tag in font2.reader.keys()} + + all_tags = sorted( + set( + _iter_filtered_table_tags( + tags1 | tags2, + include_tables=include_tables, + exclude_tables=exclude_tables, + ) + ) + ) + + only1 = [tag for tag in all_tags if tag in tags1 and tag not in tags2] + only2 = [tag for tag in all_tags if tag in tags2 and tag not in tags1] + both = [tag for tag in all_tags if tag in tags1 and tag in tags2] + + identical = True + lines: List[str] = [] + + lines.append(f"Binary table summary:\n") + lines.append(f" file1: {file1}\n") + lines.append(f" file2: {file2}\n") + + if only1: + identical = False + lines.append(f"\nTables only in file1 ({len(only1)}):\n") + for tag in only1: + lines.append(f"- {tag} ({len(font1.reader[tag])} bytes)\n") + if only2: + identical = False + lines.append(f"\nTables only in file2 ({len(only2)}):\n") + for tag in only2: + lines.append(f"+ {tag} ({len(font2.reader[tag])} bytes)\n") + + lines.append(f"\nTables in both ({len(both)}):\n") + for tag in both: + data1 = font1.reader[tag] + data2 = font2.reader[tag] + if data1 == data2: + lines.append(f" {tag}: SAME ({len(data1)} bytes)\n") + else: + identical = False + lines.append(f"* {tag}: DIFF ({len(data1)} vs {len(data2)} bytes)\n") + + if identical: + lines.append("\nResult: SAME\n") + else: + lines.append("\nResult: DIFFERENT\n") + + return identical, "".join(lines) + + +def get_binary_exclude_tables( + file1: str, + file2: str, + include_tables: Optional[List[str]] = None, + exclude_tables: Optional[List[str]] = None, + font_number_1: int = -1, + font_number_2: int = -1, +) -> Tuple[bool, str]: + from fontTools.ttLib import TTFont + + with ( + TTFont(file1, lazy=True, fontNumber=font_number_1) as font1, + TTFont(file2, lazy=True, fontNumber=font_number_2) as font2, + ): + tags1 = {str(tag) for tag in font1.reader.keys()} + tags2 = {str(tag) for tag in font2.reader.keys()} + + all_tags = sorted( + set( + _iter_filtered_table_tags( + tags1 | tags2, + include_tables=include_tables, + exclude_tables=exclude_tables, + ) + ) + ) + + both = [tag for tag in all_tags if tag in tags1 and tag in tags2] + out = set() + + for tag in both: + data1 = font1.reader[tag] + data2 = font2.reader[tag] + if data1 == data2: + out.add(tag) + + return out + + +def main(): + """Compare two fonts for differences""" + # try/except block rationale: + # handles "premature" socket closure exception that is + # raised by Python when stdout is piped to tools like + # the `head` executable and socket is closed early + # see: https://docs.python.org/3/library/signal.html#note-on-sigpipe + ret = 0 + try: + ret = run(sys.argv[1:]) + except KeyboardInterrupt: + pass + except BrokenPipeError: + # Python flushes standard streams on exit; redirect remaining output + # to devnull to avoid another BrokenPipeError at shutdown + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + return ret + + +def run(argv: List[Text]): + # ------------------------------------------ + # argparse command line argument definitions + # ------------------------------------------ + parser = argparse.ArgumentParser( + description="An OpenType table diff tool for fonts." + ) + parser.add_argument( + "-l", + "--summary", + action="store_true", + help="Report table presence and binary equality only", + ) + parser.add_argument( + "-U", + "--lines", + type=int, + default=3, + help="Number of context lines for unified diff (default: 3)", + ) + parser.add_argument( + "-t", + "--include", + type=str, + nargs="+", + default=None, + help="Font tables to include. Multiple options are allowed.", + ) + parser.add_argument( + "-x", + "--exclude", + type=str, + nargs="+", + default=None, + help="Font tables to exclude. Multiple options are allowed.", + ) + parser.add_argument( + "--diff", type=str, help="Run external diff tool command (default: diff)" + ) + parser.add_argument( + "--diff-arg", + type=str, + default=None, + help="External diff tool arguments (default: -u)", + ) + parser.add_argument( + "--color", + choices=["auto", "never", "always"], + default="auto", + help="Whether to colorize output (default: auto)", + ) + parser.add_argument( + "--y1", + type=int, + default=-1, + metavar="NUMBER", + help="Select font number for TrueType Collection (.ttc/.otc) FILE1, starting from 0", + ) + parser.add_argument( + "--y2", + type=int, + default=-1, + metavar="NUMBER", + help="Select font number for TrueType Collection (.ttc/.otc) FILE2, starting from 0", + ) + parser.add_argument( + "-a", + "--always", + action="store_true", + help="Compare tables even if binary identical", + ) + parser.add_argument( + "-b", + "--binary", + action="store_true", + help="Compare tables only if binaries differ (default)", + ) + parser.add_argument( + "-q", "--quiet", action="store_true", help="Suppress all output" + ) + parser.add_argument("FILE1", help="Font file path 1") + parser.add_argument("FILE2", help="Font file path 2") + + args: argparse.Namespace = parser.parse_args(argv) + + # ///////////////////////////////////////////////////////// + # + # Validations + # + # ///////////////////////////////////////////////////////// + + # ---------------------------------- + # Incompatible argument validations + # ---------------------------------- + + if args.always and args.binary: + if not args.quiet: + sys.stderr.write( + f"[*] Error: --always and --binary are mutually exclusive options. " + f"Please use ONLY one of these options in your command.{os.linesep}" + ) + return 2 + if not args.always: + args.binary = True + + # ------------------------------- + # File path argument validations + # ------------------------------- + + if not file_exists(args.FILE1): + if not args.quiet: + sys.stderr.write( + f"[*] ERROR: The file path '{args.FILE1}' can not be found.{os.linesep}" + ) + return 2 + if not file_exists(args.FILE2): + if not args.quiet: + sys.stderr.write( + f"[*] ERROR: The file path '{args.FILE2}' can not be found.{os.linesep}" + ) + return 2 + + # ///////////////////////////////////////////////////////// + # + # Command line logic + # + # ///////////////////////////////////////////////////////// + + # parse explicitly included or excluded tables in + # the command line arguments + # set as a Python list if it was defined on the command line + # or as None if it was not set on the command line + include_list: Optional[List[Text]] = get_tables_argument_list(args.include) + exclude_list: Optional[List[Text]] = get_tables_argument_list(args.exclude) + + if args.summary: + try: + identical, output = summarize( + args.FILE1, + args.FILE2, + include_tables=include_list, + exclude_tables=exclude_list, + font_number_1=args.y1, + font_number_2=args.y2, + ) + if not args.quiet: + sys.stdout.write(output) + return 0 if identical else 1 + except Exception as e: + if not args.quiet: + sys.stderr.write(f"[*] ERROR: {e}{os.linesep}") + return 2 + + if args.binary: + excluded_binary_tables = get_binary_exclude_tables( + args.FILE1, + args.FILE2, + include_tables=include_list, + exclude_tables=exclude_list, + font_number_1=args.y1, + font_number_2=args.y2, + ) + if include_list is not None: + include_list = [ + tag for tag in include_list if tag not in excluded_binary_tables + ] + else: + if exclude_list is None: + exclude_list = [] + exclude_list.extend(sorted(excluded_binary_tables)) + + diff_tool = args.diff + color_output = args.color == "always" or ( + args.color == "auto" and sys.stdout.isatty + ) + + if diff_tool is None: + diff_tool = shutil.which("diff") + elif diff_tool: + diff_tool = shutil.which(diff_tool) + if diff_tool is None: + if not args.quiet: + sys.stderr.write( + f"[*] ERROR: The external diff tool executable " + f"'{args.diff}' was not found.{os.linesep}" + ) + return 2 + + try: + if diff_tool: + diff_arg = args.diff_arg + if diff_arg is None: + if args.lines == 3: + diff_arg = ["-u"] + else: + diff_arg = ["-u{}".format(args.lines)] + if _is_gnu_diff(diff_tool): + diff_arg.append(r"-F^\s\s<") + else: + diff_arg = diff_arg.split() + + output = run_external_diff( + diff_tool, + diff_arg, + args.FILE1, + args.FILE2, + include_tables=include_list, + exclude_tables=exclude_list, + font_number_a=args.y1, + font_number_b=args.y2, + use_multiprocess=True, + ) + else: + output = u_diff( + args.FILE1, + args.FILE2, + context_lines=args.lines, + include_tables=include_list, + exclude_tables=exclude_list, + font_number_a=args.y1, + font_number_b=args.y2, + use_multiprocess=True, + ) + + if color_output: + output = [color_unified_diff_line(line) for line in output] + + output = "".join(output) + if not args.quiet: + pipe_output(output) + return 1 if output else 0 + + except Exception as e: + if not args.quiet: + sys.stderr.write(f"[*] ERROR: {e}{os.linesep}") + return 2 diff --git a/contrib/python/fonttools/fontTools/diff/__main__.py b/contrib/python/fonttools/fontTools/diff/__main__.py new file mode 100644 index 00000000000..8dbef1dfc5d --- /dev/null +++ b/contrib/python/fonttools/fontTools/diff/__main__.py @@ -0,0 +1,6 @@ +import sys +from fontTools.diff import main + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/contrib/python/fonttools/fontTools/diff/color.py b/contrib/python/fonttools/fontTools/diff/color.py new file mode 100644 index 00000000000..e6ef489d67e --- /dev/null +++ b/contrib/python/fonttools/fontTools/diff/color.py @@ -0,0 +1,44 @@ +from typing import Dict, Text + +ansicolors: Dict[Text, Text] = { + "BLACK": "\033[30m", + "RED": "\033[31m", + "GREEN": "\033[32m", + "YELLOW": "\033[33m", + "BLUE": "\033[34m", + "MAGENTA": "\033[35m", + "CYAN": "\033[36m", + "WHITE": "\033[37m", + "BOLD": "\033[1m", + "RESET": "\033[0m", +} + +green_start: Text = ansicolors["GREEN"] +red_start: Text = ansicolors["RED"] +cyan_start: Text = ansicolors["CYAN"] +reset: Text = ansicolors["RESET"] + + +def color_unified_diff_line(line: Text) -> Text: + """Returns an ANSI escape code colored string with color based + on the unified diff line type.""" + if line[0:2] == "+ ": + return f"{green_start}{line}{reset}" + elif line == "+\n": + # some lines are formatted as hyphen only with no other characters + # this indicates an added empty line + return f"{green_start}{line}{reset}" + elif line[0:2] == "- ": + return f"{red_start}{line}{reset}" + elif line == "-\n": + # some lines are formatted as hyphen only with no other characters + # this indicates a deleted empty line + return f"{red_start}{line}{reset}" + elif line[0:3] == "@@ ": + return f"{cyan_start}{line}{reset}" + elif line[0:4] == "--- ": + return f"{red_start}{line}{reset}" + elif line[0:4] == "+++ ": + return f"{green_start}{line}{reset}" + else: + return line diff --git a/contrib/python/fonttools/fontTools/diff/diff.py b/contrib/python/fonttools/fontTools/diff/diff.py new file mode 100644 index 00000000000..302f2e677ae --- /dev/null +++ b/contrib/python/fonttools/fontTools/diff/diff.py @@ -0,0 +1,294 @@ +import os +import subprocess +import tempfile +from contextlib import contextmanager +from difflib import unified_diff +from multiprocessing import Pool, cpu_count +from typing import Any, Callable, Iterable, Iterator, List, Optional, Text, Tuple + +from fontTools.ttLib import TTFont # type: ignore + +from .utils import get_file_modtime + +# +# +# Private functions +# +# + + +def _get_fonts_and_save_xml( + filepath_a: Text, + filepath_b: Text, + tmpdirpath: Text, + include_tables: Optional[List[Text]], + exclude_tables: Optional[List[Text]], + font_number_a: int, + font_number_b: int, + use_multiprocess: bool, +) -> Tuple[Text, Text, Text, Text, Text, Text]: + post_pathname, postpath, pre_pathname, prepath = _get_pre_post_paths( + filepath_a, filepath_b + ) + # instantiate left and right fontTools.ttLib.TTFont objects + tt_left = TTFont(prepath, fontNumber=font_number_a) + tt_right = TTFont(postpath, fontNumber=font_number_b) + left_ttxpath = os.path.join(tmpdirpath, "left.ttx") + right_ttxpath = os.path.join(tmpdirpath, "right.ttx") + _mp_save_ttx_xml( + tt_left, + tt_right, + left_ttxpath, + right_ttxpath, + exclude_tables, + include_tables, + use_multiprocess, + ) + return left_ttxpath, right_ttxpath, pre_pathname, prepath, post_pathname, postpath + + +def _get_pre_post_paths( + filepath_a: Text, + filepath_b: Text, +) -> Tuple[Text, Text, Text, Text]: + prepath = filepath_a + postpath = filepath_b + pre_pathname = filepath_a + post_pathname = filepath_b + return post_pathname, postpath, pre_pathname, prepath + + +def _mp_save_ttx_xml( + tt_left: Any, + tt_right: Any, + left_ttxpath: Text, + right_ttxpath: Text, + exclude_tables: Optional[List[Text]], + include_tables: Optional[List[Text]], + use_multiprocess: bool, +) -> None: + if use_multiprocess and cpu_count() > 1: + # Use parallel fontTools.ttLib.TTFont.saveXML dump + # by default on multi CPU systems. This is a performance + # optimization. Profiling demonstrates that this can reduce + # execution time by up to 30% for some fonts + mp_args_list = [ + (tt_left, left_ttxpath, include_tables, exclude_tables), + (tt_right, right_ttxpath, include_tables, exclude_tables), + ] + with Pool(processes=2) as pool: + pool.starmap(_ttfont_save_xml, mp_args_list) + else: + # use sequential fontTools.ttLib.TTFont.saveXML dumps + # when use_multiprocess is False or single CPU system + # detected + _ttfont_save_xml(tt_left, left_ttxpath, include_tables, exclude_tables) + _ttfont_save_xml(tt_right, right_ttxpath, include_tables, exclude_tables) + + +def _ttfont_save_xml( + ttf: Any, + filepath: Text, + include_tables: Optional[List[Text]], + exclude_tables: Optional[List[Text]], +) -> bool: + """Writes TTX specification formatted XML to disk on filepath.""" + ttf.saveXML(filepath, tables=include_tables, skipTables=exclude_tables) + return True + + +@contextmanager +def _saved_ttx_files( + filepath_a: Text, + filepath_b: Text, + include_tables: Optional[List[Text]], + exclude_tables: Optional[List[Text]], + font_number_a: int, + font_number_b: int, + use_multiprocess: bool, +) -> Iterator[Tuple[Text, Text, Text, Text, Text, Text]]: + with tempfile.TemporaryDirectory() as tmpdirpath: + yield _get_fonts_and_save_xml( + filepath_a, + filepath_b, + tmpdirpath, + include_tables, + exclude_tables, + font_number_a, + font_number_b, + use_multiprocess, + ) + + +def _diff_with_saved_ttx_files( + filepath_a: Text, + filepath_b: Text, + include_tables: Optional[List[Text]], + exclude_tables: Optional[List[Text]], + font_number_a: int, + font_number_b: int, + use_multiprocess: bool, + create_differ: Callable[[Text, Text, Text, Text, Text, Text], Iterable[Text]], +) -> Iterator[Text]: + with _saved_ttx_files( + filepath_a, + filepath_b, + include_tables, + exclude_tables, + font_number_a, + font_number_b, + use_multiprocess, + ) as ( + left_ttxpath, + right_ttxpath, + pre_pathname, + prepath, + post_pathname, + postpath, + ): + yield from create_differ( + left_ttxpath, + right_ttxpath, + pre_pathname, + prepath, + post_pathname, + postpath, + ) + + +# +# +# Public functions +# +# + + +def u_diff( + filepath_a: Text, + filepath_b: Text, + context_lines: int = 3, + include_tables: Optional[List[Text]] = None, + exclude_tables: Optional[List[Text]] = None, + font_number_a: int = -1, + font_number_b: int = -1, + use_multiprocess: bool = True, +) -> Iterator[Text]: + """Performs a unified diff on a TTX serialized data format dump of font binary data using + a modified version of the Python standard libary difflib module. + + filepath_a: (string) pre-file local file path + filepath_b: (string) post-file local file path + context_lines: (int) number of context lines to include in the diff (default=3) + include_tables: (list of str) Python list of OpenType tables to include in the diff + exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff + use_multiprocess: (bool) use multi-processor optimizations (default=True) + + include_tables and exclude_tables are mutually exclusive arguments. Only one should + be defined + + :returns: Generator of ordered diff line strings that include newline line endings + :raises: KeyError if include_tables or exclude_tables includes a mis-specified table + that is not included in filepath_a OR filepath_b + """ + + def _create_unified_diff( + left_ttxpath: Text, + right_ttxpath: Text, + pre_pathname: Text, + prepath: Text, + post_pathname: Text, + postpath: Text, + ) -> Iterable[Text]: + with open(left_ttxpath) as ff: + fromlines = ff.readlines() + with open(right_ttxpath) as tf: + tolines = tf.readlines() + + fromdate = get_file_modtime(prepath) + todate = get_file_modtime(postpath) + + yield from unified_diff( + fromlines, + tolines, + pre_pathname, + post_pathname, + fromdate, + todate, + n=context_lines, + ) + + yield from _diff_with_saved_ttx_files( + filepath_a, + filepath_b, + include_tables, + exclude_tables, + font_number_a, + font_number_b, + use_multiprocess, + _create_unified_diff, + ) + + +def run_external_diff( + diff_tool: Text, + diff_args: List[Text], + filepath_a: Text, + filepath_b: Text, + include_tables: Optional[List[Text]] = None, + exclude_tables: Optional[List[Text]] = None, + font_number_a: int = -1, + font_number_b: int = -1, + use_multiprocess: bool = True, +) -> Iterator[Text]: + """Performs a unified diff on a TTX serialized data format dump of font binary data using + an external diff executable that is requested by the caller via `command` + + diff_tool: (string) command line executable string + diff_args: (list of strings) arguments for the diff tool + filepath_a: (string) pre-file local file path + filepath_b: (string) post-file local file path + include_tables: (list of str) Python list of OpenType tables to include in the diff + exclude_tables: (list of str) Python list of OpentType tables to exclude from the diff + use_multiprocess: (bool) use multi-processor optimizations (default=True) + + include_tables and exclude_tables are mutually exclusive arguments. Only one should + be defined + + :returns: Generator of ordered diff line strings that include newline line endings + :raises: KeyError if include_tables or exclude_tables includes a mis-specified table + that is not included in filepath_a OR filepath_b + :raises: IOError if exception raised during execution of `command` on TTX files + """ + + def _create_external_diff( + left_ttxpath: Text, + right_ttxpath: Text, + _pre_pathname: Text, + _prepath: Text, + _post_pathname: Text, + _postpath: Text, + ) -> Iterable[Text]: + command = [diff_tool] + diff_args + [left_ttxpath, right_ttxpath] + process = subprocess.Popen( + command, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf8", + ) + + for line in process.stdout: + yield line + err = process.stderr.read() + if err: + raise IOError(err) + + yield from _diff_with_saved_ttx_files( + filepath_a, + filepath_b, + include_tables, + exclude_tables, + font_number_a, + font_number_b, + use_multiprocess, + _create_external_diff, + ) diff --git a/contrib/python/fonttools/fontTools/diff/utils.py b/contrib/python/fonttools/fontTools/diff/utils.py new file mode 100644 index 00000000000..aed97063636 --- /dev/null +++ b/contrib/python/fonttools/fontTools/diff/utils.py @@ -0,0 +1,28 @@ +import os +from datetime import datetime, timezone +from typing import List, Optional, Text, Union + + +def file_exists(path: Union[bytes, str, "os.PathLike[Text]"]) -> bool: + """Validates file path as existing local file""" + return os.path.isfile(path) + + +def get_file_modtime(path: Union[bytes, str, "os.PathLike[Text]"]) -> Text: + """Returns ISO formatted file modification time in local system timezone""" + return ( + datetime.fromtimestamp(os.stat(path).st_mtime, timezone.utc) + .astimezone() + .isoformat() + ) + + +def get_tables_argument_list(table_list: Optional[List[Text]]) -> Optional[List[Text]]: + """Converts a list of OpenType table string into a Python list or + return None if the table_list was not defined (i.e., it was not included + in an option on the command line). Tables that are composed of three + characters must be right padded with a space.""" + if table_list is None: + return None + else: + return [table.ljust(4) for table in table_list] diff --git a/contrib/python/fonttools/fontTools/feaLib/builder.py b/contrib/python/fonttools/fontTools/feaLib/builder.py index 21b7f5bdf26..971b87ca610 100644 --- a/contrib/python/fonttools/fontTools/feaLib/builder.py +++ b/contrib/python/fonttools/fontTools/feaLib/builder.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from fontTools.misc import sstruct from fontTools.misc.textTools import Tag, tostr, binary2num, safeEval from fontTools.feaLib.error import FeatureLibError @@ -8,7 +10,7 @@ from fontTools.feaLib.lookupDebugInfo import ( ) from fontTools.feaLib.parser import Parser from fontTools.feaLib.ast import FeatureFile -from fontTools.feaLib.variableScalar import VariableScalar +from fontTools.feaLib.variableScalar import VariableScalar, VariableScalarBuilder from fontTools.otlLib import builder as otl from fontTools.otlLib.maxContextCalc import maxCtxFont from fontTools.ttLib import newTable, getTableModule @@ -124,6 +126,7 @@ class Builder(object): self.varstorebuilder = OnlineVarStoreBuilder( [ax.axisTag for ax in self.axes] ) + self.scalar_builder = VariableScalarBuilder.from_ttf(font) self.default_language_systems_ = set() self.script_ = None self.lookupflag_ = 0 @@ -180,10 +183,6 @@ class Builder(object): self.stat_ = {} # for conditionsets self.conditionsets_ = {} - # We will often use exactly the same locations (i.e. the font's masters) - # for a large number of variable scalars. Instead of creating a model - # for each, let's share the models. - self.model_cache = {} def build(self, tables=None, debug=False): if self.parseTree is None: @@ -392,6 +391,10 @@ class Builder(object): return user_name_id def buildFeatureParams(self, tag): + # by convention, a missing name ID is represented by 0xffff. + # the spec says that these fields can be 'NULL', but 'NULL' is not + # well defined for the purpose of nameIDs? + NO_NAME_ID = 0xFFFF params = None if tag == "size": params = otTables.FeatureParamsSize() @@ -418,17 +421,17 @@ class Builder(object): params = otTables.FeatureParamsCharacterVariants() params.Format = 0 params.FeatUILabelNameID = self.cv_parameters_ids_.get( - (tag, "FeatUILabelNameID"), 0 + (tag, "FeatUILabelNameID"), NO_NAME_ID ) params.FeatUITooltipTextNameID = self.cv_parameters_ids_.get( - (tag, "FeatUITooltipTextNameID"), 0 + (tag, "FeatUITooltipTextNameID"), NO_NAME_ID ) params.SampleTextNameID = self.cv_parameters_ids_.get( - (tag, "SampleTextNameID"), 0 + (tag, "SampleTextNameID"), NO_NAME_ID ) params.NumNamedParameters = self.cv_num_named_params_.get(tag, 0) params.FirstParamUILabelNameID = self.cv_parameters_ids_.get( - (tag, "ParamUILabelNameID_0"), 0 + (tag, "ParamUILabelNameID_0"), NO_NAME_ID ) params.CharCount = len(self.cv_characters_[tag]) params.Character = self.cv_characters_[tag] @@ -805,7 +808,6 @@ class Builder(object): gdef.remap_device_varidxes(varidx_map) if "GPOS" in self.font: self.font["GPOS"].table.remap_device_varidxes(varidx_map) - self.model_cache.clear() if any( ( gdef.GlyphClassDef, @@ -1439,6 +1441,19 @@ class Builder(object): if sub is None: sub = self.get_chained_lookup_(location, MultipleSubstBuilder) sub.mapping[glyph] = replacements + # https://github.com/fonttools/fonttools/issues/4016 + # If the last rule has the same context and lookup, merge the glyph + # into it instead of creating a new rule (avoids unnecessary subtables). + if chain.rules and not chain.rules[-1].is_subtable_break: + last = chain.rules[-1] + if ( + last.prefix == prefix + and last.suffix == suffix + and last.lookups == [sub] + ): + assert len(last.glyphs) == 1 + last.glyphs[0].add(glyph) + return chain.rules.append(ChainContextualRule(prefix, [{glyph}], suffix, [sub])) def add_ligature_subst_chained_( @@ -1725,19 +1740,24 @@ class Builder(object): self.conditionsets_[key] = value - def makeVariablePos(self, location, varscalar): - if not self.varstorebuilder: + def makeVariablePos( + self, location, varscalar: VariableScalar + ) -> tuple[int, int | None]: + """Make a pos statement from a VariableScalar, returning the default + value, and optionally the variation index if the scalar genuinely + requires variation too.""" + + if self.varstorebuilder is None or self.scalar_builder is None: raise FeatureLibError( "Can't define a variable scalar in a non-variable font", location ) - varscalar.axes = self.axes if not varscalar.does_vary: - return varscalar.default, None + return self.scalar_builder.default_value(varscalar), None try: - default, index = varscalar.add_to_variation_store( - self.varstorebuilder, self.model_cache, self.font.get("avar") + default, index = self.scalar_builder.add_to_variation_store( + varscalar, self.varstorebuilder ) except VarLibError as e: raise FeatureLibError( diff --git a/contrib/python/fonttools/fontTools/feaLib/parser.py b/contrib/python/fonttools/fontTools/feaLib/parser.py index 0e211e0032a..9233b8739fc 100644 --- a/contrib/python/fonttools/fontTools/feaLib/parser.py +++ b/contrib/python/fonttools/fontTools/feaLib/parser.py @@ -1953,6 +1953,7 @@ class Parser(object): return self.ast.FontRevisionStatement(version, location=location) def parse_conditionset_(self): + location = self.cur_token_location_ name = self.expect_name_() conditions = {} @@ -1987,7 +1988,7 @@ class Parser(object): finalname = self.expect_name_() if finalname != name: raise FeatureLibError('Expected "%s"' % name, self.cur_token_location_) - return self.ast.ConditionsetStatement(name, conditions) + return self.ast.ConditionsetStatement(name, conditions, location=location) def parse_block_( self, block, vertical, stylisticset=None, size_feature=False, cv_feature=None diff --git a/contrib/python/fonttools/fontTools/feaLib/variableScalar.py b/contrib/python/fonttools/fontTools/feaLib/variableScalar.py index 31f1bd19f2d..b20c68fd0cb 100644 --- a/contrib/python/fonttools/fontTools/feaLib/variableScalar.py +++ b/contrib/python/fonttools/fontTools/feaLib/variableScalar.py @@ -1,18 +1,47 @@ -from fontTools.varLib.models import VariationModel, normalizeValue, piecewiseLinearMap +from __future__ import annotations +from collections.abc import Mapping +from dataclasses import dataclass -def Location(loc): - return tuple(sorted(loc.items())) +from fontTools.designspaceLib import DesignSpaceDocument +from fontTools.ttLib.ttFont import TTFont +from fontTools.varLib.models import ( + VariationModel, + noRound, + normalizeValue, + piecewiseLinearMap, +) + +import typing +import warnings + +if typing.TYPE_CHECKING: + from typing import Self + +LocationTuple = tuple[tuple[str, float], ...] +"""A hashable location.""" + + +def Location(location: Mapping[str, float]) -> LocationTuple: + """Create a hashable location from a dictionary-like location.""" + return tuple(sorted(location.items())) class VariableScalar: """A scalar with different values at different points in the designspace.""" - def __init__(self, location_value={}): - self.values = {} - self.axes = {} - for location, value in location_value.items(): - self.add_value(location, value) + values: dict[LocationTuple, int] + """The values across various user-locations. Must always include the default + location by time of building.""" + + def __init__(self, location_value=None): + self.values = { + Location(location): value + for location, value in (location_value or {}).items() + } + # Deprecated: only used by the add_to_variation_store() backwards-compat + # shim. New code should use VariableScalarBuilder instead. + self.axes = [] def __repr__(self): items = [] @@ -27,92 +56,210 @@ class VariableScalar: return "(" + (" ".join(items)) + ")" @property - def does_vary(self): + def does_vary(self) -> bool: values = list(self.values.values()) return any(v != values[0] for v in values[1:]) - @property - def axes_dict(self): + def add_value(self, location: Mapping[str, float], value: int): + self.values[Location(location)] = value + + def add_to_variation_store(self, store_builder, model_cache=None, avar=None): + """Deprecated: use VariableScalarBuilder.add_to_variation_store() instead.""" + warnings.warn( + "VariableScalar.add_to_variation_store() is deprecated. " + "Use VariableScalarBuilder.add_to_variation_store() instead.", + DeprecationWarning, + stacklevel=2, + ) if not self.axes: raise ValueError( - ".axes must be defined on variable scalar before interpolating" + ".axes must be defined on variable scalar before calling " + "add_to_variation_store()" ) - return {ax.axisTag: ax for ax in self.axes} + builder = VariableScalarBuilder( + axis_triples={ + ax.axisTag: (ax.minValue, ax.defaultValue, ax.maxValue) + for ax in self.axes + }, + axis_mappings=({} if avar is None else dict(avar.segments)), + model_cache=model_cache if model_cache is not None else {}, + ) + return builder.add_to_variation_store(self, store_builder) - def _normalized_location(self, location): - location = self.fix_location(location) - normalized_location = {} - for axtag in location.keys(): - if axtag not in self.axes_dict: + +@dataclass +class VariableScalarBuilder: + """A helper class for building variable scalars, or otherwise interrogating + their variation model for interpolation or similar.""" + + axis_triples: dict[str, tuple[float, float, float]] + """Minimum, default, and maximum for each axis in user-coordinates.""" + axis_mappings: dict[str, Mapping[float, float]] + """Optional mappings from normalized user-coordinates to normalized + design-coordinates.""" + + model_cache: dict[tuple[LocationTuple, ...], VariationModel] + """We often use the same exact locations (i.e. font sources) for a large + number of variable scalars. Instead of creating a model for each, cache + them. Cache by user-location to avoid repeated mapping computations.""" + + @classmethod + def from_ttf(cls, ttf: TTFont) -> Self: + return cls( + axis_triples={ + axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue) + for axis in ttf["fvar"].axes + }, + axis_mappings=( + {} + if (avar := ttf.get("avar")) is None + else {axis: segments for axis, segments in avar.segments.items()} + ), + model_cache={}, + ) + + @classmethod + def from_designspace(cls, doc: DesignSpaceDocument) -> Self: + return cls( + axis_triples={ + axis.tag: (axis.minimum, axis.default, axis.maximum) + for axis in doc.axes + }, + axis_mappings={ + axis.tag: { + normalizeValue( + user, (axis.minimum, axis.default, axis.maximum) + ): normalizeValue( + design, + ( + axis.map_forward(axis.minimum), + axis.map_forward(axis.default), + axis.map_forward(axis.maximum), + ), + ) + for user, design in axis.map + } + for axis in doc.axes + if axis.map + }, + model_cache={}, + ) + + def _fully_specify_location(self, location: LocationTuple) -> LocationTuple: + """Validate and fully-specify a user-space location by filling in + missing axes with their user-space defaults.""" + + full = {} + for axtag, value in location: + if axtag not in self.axis_triples: raise ValueError("Unknown axis %s in %s" % (axtag, location)) - axis = self.axes_dict[axtag] - normalized_location[axtag] = normalizeValue( - location[axtag], (axis.minValue, axis.defaultValue, axis.maxValue) - ) + full[axtag] = value - return Location(normalized_location) + for axtag, (_, axis_default, _) in self.axis_triples.items(): + if axtag not in full: + full[axtag] = axis_default - def fix_location(self, location): - location = dict(location) - for tag, axis in self.axes_dict.items(): - if tag not in location: - location[tag] = axis.defaultValue - return location + return Location(full) - def add_value(self, location, value): - if self.axes: - location = self.fix_location(location) + def _normalize_location(self, location: LocationTuple) -> dict[str, float]: + """Normalize a user-space location, applying avar mappings if present. - self.values[Location(location)] = value + TODO: This only handles avar1 (per-axis piecewise linear mappings), + not avar2 (multi-dimensional mappings). + """ - def fix_all_locations(self): - self.values = { - Location(self.fix_location(l)): v for l, v in self.values.items() - } + result = {} + for axtag, value in location: + axis_min, axis_default, axis_max = self.axis_triples[axtag] + normalized = normalizeValue(value, (axis_min, axis_default, axis_max)) + mapping = self.axis_mappings.get(axtag) + if mapping is not None: + normalized = piecewiseLinearMap(normalized, mapping) + result[axtag] = normalized - @property - def default(self): - self.fix_all_locations() - key = Location({ax.axisTag: ax.defaultValue for ax in self.axes}) - if key not in self.values: - raise ValueError("Default value could not be found") - # I *guess* we could interpolate one, but I don't know how. - return self.values[key] + return result - def value_at_location(self, location, model_cache=None, avar=None): - loc = Location(location) - if loc in self.values.keys(): - return self.values[loc] - values = list(self.values.values()) - loc = dict(self._normalized_location(loc)) - return self.model(model_cache, avar).interpolateFromMasters(loc, values) + def _full_locations_and_values( + self, scalar: VariableScalar + ) -> list[tuple[LocationTuple, int]]: + """Return a list of (fully-specified user-space location, value) pairs, + preserving order and length of scalar.values.""" - def model(self, model_cache=None, avar=None): - if model_cache is not None: - key = tuple(self.values.keys()) - if key in model_cache: - return model_cache[key] - locations = [dict(self._normalized_location(k)) for k in self.values.keys()] - if avar is not None: - mapping = avar.segments - locations = [ - { - k: piecewiseLinearMap(v, mapping[k]) if k in mapping else v - for k, v in location.items() - } - for location in locations - ] - m = VariationModel(locations) - if model_cache is not None: - model_cache[key] = m - return m + return [ + (self._fully_specify_location(loc), val) + for loc, val in scalar.values.items() + ] - def get_deltas_and_supports(self, model_cache=None, avar=None): - values = list(self.values.values()) - return self.model(model_cache, avar).getDeltasAndSupports(values) + def default_value(self, scalar: VariableScalar) -> int: + """Get the default value of a variable scalar.""" - def add_to_variation_store(self, store_builder, model_cache=None, avar=None): - deltas, supports = self.get_deltas_and_supports(model_cache, avar) + default_loc = Location( + {tag: default for tag, (_, default, _) in self.axis_triples.items()} + ) + for location, value in self._full_locations_and_values(scalar): + if location == default_loc: + return value + + raise ValueError("Default value could not be found") + + def value_at_location( + self, scalar: VariableScalar, location: LocationTuple + ) -> float: + """Interpolate the value of a scalar from a user-location.""" + + location = self._fully_specify_location(location) + pairs = self._full_locations_and_values(scalar) + + # If user location matches exactly, no axis mapping or variation model needed. + for loc, val in pairs: + if loc == location: + return val + + values = [val for _, val in pairs] + normalized_location = self._normalize_location(location) + + value = self.model(scalar).interpolateFromMasters(normalized_location, values) + if value is None: + raise ValueError("Insufficient number of values to interpolate") + + return value + + def model(self, scalar: VariableScalar) -> VariationModel: + """Return a variation model based on a scalar's values. + + Variable scalars with the same fully-specified user-locations will use + the same cached variation model.""" + + pairs = self._full_locations_and_values(scalar) + cache_key = tuple(loc for loc, _ in pairs) + + cached_model = self.model_cache.get(cache_key) + if cached_model is not None: + return cached_model + + normalized_locations = [self._normalize_location(loc) for loc, _ in pairs] + axisOrder = list(self.axis_triples.keys()) + model = self.model_cache[cache_key] = VariationModel( + normalized_locations, axisOrder=axisOrder + ) + + return model + + def get_deltas_and_supports(self, scalar: VariableScalar): + """Calculate deltas and supports from this scalar's variation model.""" + values = list(scalar.values.values()) + return self.model(scalar).getDeltasAndSupports(values, round=round) + + def add_to_variation_store( + self, scalar: VariableScalar, store_builder + ) -> tuple[int, int]: + """Serialize this scalar's variation model to a store, returning the + default value and variation index.""" + + deltas, supports = self.get_deltas_and_supports(scalar) store_builder.setSupports(supports) - index = store_builder.storeDeltas(deltas) - return int(self.default), index + index = store_builder.storeDeltas(deltas, round=noRound) + + # NOTE: Default value should be an exact integer by construction of + # VariableScalar. + return int(self.default_value(scalar)), index diff --git a/contrib/python/fonttools/fontTools/pens/pointPen.py b/contrib/python/fonttools/fontTools/pens/pointPen.py index 3091b86921b..a6568bb7c57 100644 --- a/contrib/python/fonttools/fontTools/pens/pointPen.py +++ b/contrib/python/fonttools/fontTools/pens/pointPen.py @@ -344,7 +344,17 @@ class SegmentToPointPen(AbstractPen): def closePath(self): if self.contour is None: raise PenError("Contour missing required initial moveTo") - if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]: + + # Remove the last point if it's a duplicate of the first, but only if both + # are on-curve points (segmentType is not None); for quad blobs + # (all off-curve) every point must be preserved: + # https://github.com/fonttools/fonttools/issues/4014 + if ( + len(self.contour) > 1 + and (self.contour[0][0] == self.contour[-1][0]) + and self.contour[0][1] is not None + and self.contour[-1][1] is not None + ): self.contour[0] = self.contour[-1] del self.contour[-1] else: diff --git a/contrib/python/fonttools/fontTools/qu2cu/cli.py b/contrib/python/fonttools/fontTools/qu2cu/cli.py index 101e938a6fc..c3fee2db6c3 100644 --- a/contrib/python/fonttools/fontTools/qu2cu/cli.py +++ b/contrib/python/fonttools/fontTools/qu2cu/cli.py @@ -22,7 +22,8 @@ def _font_to_cubic(input_path, output_path=None, **kwargs): "all_cubic": kwargs["all_cubic"], } - assert "gvar" not in font, "Cannot convert variable font" + if "gvar" in font: + raise ValueError("Cannot convert variable font") glyphSet = font.getGlyphSet() glyphOrder = font.getGlyphOrder() glyf = font["glyf"] @@ -87,6 +88,9 @@ def _main(args=None): options = parser.parse_args(args) + if options.conversion_error <= 0: + parser.error("--conversion-error must be greater than zero") + if not options.verbose: level = "WARNING" elif options.verbose == 1: diff --git a/contrib/python/fonttools/fontTools/qu2cu/qu2cu.py b/contrib/python/fonttools/fontTools/qu2cu/qu2cu.py index 8fe3e18b086..958029e3b6e 100644 --- a/contrib/python/fonttools/fontTools/qu2cu/qu2cu.py +++ b/contrib/python/fonttools/fontTools/qu2cu/qu2cu.py @@ -175,6 +175,22 @@ def add_implicit_on_curves(p): Point = Union[Tuple[float, float], complex] +def _raise_incompatible_point(point, previous_point): + raise ValueError( + f"Quadratic splines must connect end-to-start; got {previous_point!r} then {point!r}" + ) + + +def _validate_spline_length(spline): + if len(spline) < 3: + raise ValueError("Quadratic splines must contain at least 3 points") + + +def _validate_positive_tolerance(max_err): + if max_err <= 0: + raise ValueError("max_err must be greater than zero") + + @cython.locals( cost=cython.int, is_complex=cython.int, @@ -198,6 +214,7 @@ def quadratic_to_curves( Returns: The output is a list of tuples of points. Points are represented in the same format as the input, either as 2-tuples or complex numbers. + If ``quads`` is empty, returns an empty list. Each tuple is either of length three, for a quadratic curve, or four, for a cubic curve. Each curve's last point is the same as the next @@ -209,7 +226,17 @@ def quadratic_to_curves( max_err: absolute error tolerance; defaults to 0.5 all_cubic: if True, only cubic curves are generated; defaults to False + + Raises: + ValueError: if an input spline has fewer than 3 points, or if adjacent + splines do not connect end-to-start. """ + if not quads: + return [] + _validate_positive_tolerance(max_err) + for spline in quads: + _validate_spline_length(spline) + is_complex = type(quads[0][0]) is complex if not is_complex: quads = [[complex(x, y) for (x, y) in p] for p in quads] @@ -218,7 +245,8 @@ def quadratic_to_curves( costs = [1] cost = 1 for p in quads: - assert q[-1] == p[0] + if q[-1] != p[0]: + _raise_incompatible_point(p[0], q[-1]) for i in range(len(p) - 2): cost += 1 costs.append(cost) diff --git a/contrib/python/fonttools/fontTools/svgLib/path/shapes.py b/contrib/python/fonttools/fontTools/svgLib/path/shapes.py index 3f22e6c6a3e..112d17fd4ea 100644 --- a/contrib/python/fonttools/fontTools/svgLib/path/shapes.py +++ b/contrib/python/fonttools/fontTools/svgLib/path/shapes.py @@ -173,6 +173,8 @@ class PathBuilder(object): self.A(rx, ry, cx - rx, cy, large_arc=1) def add_path_from_element(self, el): + if not isinstance(el.tag, str): + return False tag = _strip_xml_ns(el.tag) parse_fn = getattr(self, "_parse_%s" % tag.lower(), None) if not callable(parse_fn): diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/D_S_I_G_.py b/contrib/python/fonttools/fontTools/ttLib/tables/D_S_I_G_.py index f89cc29e49f..deb7d58d92e 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/D_S_I_G_.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/D_S_I_G_.py @@ -1,7 +1,16 @@ -from fontTools.misc.textTools import bytesjoin, strjoin, tobytes, tostr, safeEval +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING + from fontTools.misc import sstruct +from fontTools.misc.textTools import bytesjoin, safeEval, strjoin, tobytes, tostr + from . import DefaultTable -import base64 + +if TYPE_CHECKING: + from fontTools.misc.xmlWriter import XMLWriter + from fontTools.ttLib import TTFont DSIG_HeaderFormat = """ > # big endian @@ -46,11 +55,12 @@ class table_D_S_I_G_(DefaultTable.DefaultTable): See also https://learn.microsoft.com/en-us/typography/opentype/spec/dsig """ - def decompile(self, data, ttFont): + def decompile(self, data: bytes, ttFont: TTFont) -> None: dummy, newData = sstruct.unpack2(DSIG_HeaderFormat, data, self) assert self.ulVersion == 1, "DSIG ulVersion must be 1" assert self.usFlag & ~1 == 0, "DSIG usFlag must be 0x1 or 0x0" - self.signatureRecords = sigrecs = [] + self.signatureRecords: list[SignatureRecord] = [] + sigrecs = self.signatureRecords for n in range(self.usNumSigs): sigrec, newData = sstruct.unpack2( DSIG_SignatureFormat, newData, SignatureRecord() @@ -71,7 +81,7 @@ class table_D_S_I_G_(DefaultTable.DefaultTable): ) sigrec.pkcs7 = newData[: sigrec.cbSignature] - def compile(self, ttFont): + def compile(self, ttFont: TTFont) -> bytes: packed = sstruct.pack(DSIG_HeaderFormat, self) headers = [packed] offset = len(packed) + self.usNumSigs * sstruct.calcsize(DSIG_SignatureFormat) @@ -92,7 +102,7 @@ class table_D_S_I_G_(DefaultTable.DefaultTable): data.append(b"\0") return bytesjoin(headers + data) - def toXML(self, xmlWriter, ttFont): + def toXML(self, xmlWriter: XMLWriter, ttFont: TTFont) -> None: xmlWriter.comment( "note that the Digital Signature will be invalid after recompilation!" ) @@ -139,11 +149,11 @@ def b64encode(b): return strjoin(items) -class SignatureRecord(object): - def __repr__(self): +class SignatureRecord: + def __repr__(self) -> str: return "<%s: %s>" % (self.__class__.__name__, self.__dict__) - def toXML(self, writer, ttFont): + def toXML(self, writer, ttFont: TTFont) -> None: writer.begintag(self.__class__.__name__, format=self.ulFormat) writer.newline() writer.write_noindent("-----BEGIN PKCS7-----\n") @@ -151,8 +161,10 @@ class SignatureRecord(object): writer.write_noindent("-----END PKCS7-----\n") writer.endtag(self.__class__.__name__) - def fromXML(self, name, attrs, content, ttFont): - self.ulFormat = safeEval(attrs["format"]) - self.usReserved1 = safeEval(attrs.get("reserved1", "0")) - self.usReserved2 = safeEval(attrs.get("reserved2", "0")) + def fromXML( + self, name: str, attrs: dict[str, str], content: str, ttFont: TTFont + ) -> None: + self.ulFormat: int = safeEval(attrs["format"]) + self.usReserved1: int = safeEval(attrs.get("reserved1", "0")) + self.usReserved2: int = safeEval(attrs.get("reserved2", "0")) self.pkcs7 = base64.b64decode(tobytes(strjoin(filter(pem_spam, content)))) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/DefaultTable.py b/contrib/python/fonttools/fontTools/ttLib/tables/DefaultTable.py index 92f2aa6523a..9ed065dd4a7 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/DefaultTable.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/DefaultTable.py @@ -1,22 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from fontTools.misc.textTools import Tag from fontTools.ttLib import getClassTag +if TYPE_CHECKING: + from typing import Any + + from fontTools.misc.xmlWriter import XMLWriter + from fontTools.ttLib import TTFont -class DefaultTable(object): - dependencies = [] - def __init__(self, tag=None): +class DefaultTable: + dependencies: list[str] = [] + + def __init__(self, tag: str | bytes | None = None) -> None: if tag is None: tag = getClassTag(self.__class__) self.tableTag = Tag(tag) - def decompile(self, data, ttFont): + def decompile(self, data: bytes, ttFont: TTFont) -> None: self.data = data - def compile(self, ttFont): + def compile(self, ttFont: TTFont) -> bytes: return self.data - def toXML(self, writer, ttFont, **kwargs): + def toXML( + self, writer: XMLWriter, ttFont: TTFont, **kwargs: dict[str, Any] + ) -> None: if hasattr(self, "ERROR"): writer.comment("An error occurred during the decompilation of this table") writer.newline() @@ -28,22 +40,24 @@ class DefaultTable(object): writer.endtag("hexdata") writer.newline() - def fromXML(self, name, attrs, content, ttFont): - from fontTools.misc.textTools import readHex + def fromXML( + self, name: str, attrs: dict[str, str], content: str, ttFont: TTFont + ) -> None: from fontTools import ttLib + from fontTools.misc.textTools import readHex if name != "hexdata": raise ttLib.TTLibError("can't handle '%s' element" % name) self.decompile(readHex(content), ttFont) - def __repr__(self): + def __repr__(self) -> str: return "<'%s' table at %x>" % (self.tableTag, id(self)) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: if type(self) != type(other): return NotImplemented return self.__dict__ == other.__dict__ - def __ne__(self, other): + def __ne__(self, other: Any) -> bool: result = self.__eq__(other) return result if result is NotImplemented else not result diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/G_M_A_P_.py b/contrib/python/fonttools/fontTools/ttLib/tables/G_M_A_P_.py deleted file mode 100644 index 070c61919e1..00000000000 --- a/contrib/python/fonttools/fontTools/ttLib/tables/G_M_A_P_.py +++ /dev/null @@ -1,148 +0,0 @@ -from fontTools.misc import sstruct -from fontTools.misc.textTools import tobytes, tostr, safeEval -from . import DefaultTable - -GMAPFormat = """ - > # big endian - tableVersionMajor: H - tableVersionMinor: H - flags: H - recordsCount: H - recordsOffset: H - fontNameLength: H -""" -# psFontName is a byte string which follows the record above. This is zero padded -# to the beginning of the records array. The recordsOffsst is 32 bit aligned. - -GMAPRecordFormat1 = """ - > # big endian - UV: L - cid: H - gid: H - ggid: H - name: 32s -""" - - -class GMAPRecord(object): - def __init__(self, uv=0, cid=0, gid=0, ggid=0, name=""): - self.UV = uv - self.cid = cid - self.gid = gid - self.ggid = ggid - self.name = name - - def toXML(self, writer, ttFont): - writer.begintag("GMAPRecord") - writer.newline() - writer.simpletag("UV", value=self.UV) - writer.newline() - writer.simpletag("cid", value=self.cid) - writer.newline() - writer.simpletag("gid", value=self.gid) - writer.newline() - writer.simpletag("glyphletGid", value=self.gid) - writer.newline() - writer.simpletag("GlyphletName", value=self.name) - writer.newline() - writer.endtag("GMAPRecord") - writer.newline() - - def fromXML(self, name, attrs, content, ttFont): - value = attrs["value"] - if name == "GlyphletName": - self.name = value - else: - setattr(self, name, safeEval(value)) - - def compile(self, ttFont): - if self.UV is None: - self.UV = 0 - nameLen = len(self.name) - if nameLen < 32: - self.name = self.name + "\0" * (32 - nameLen) - data = sstruct.pack(GMAPRecordFormat1, self) - return data - - def __repr__(self): - return ( - "GMAPRecord[ UV: " - + str(self.UV) - + ", cid: " - + str(self.cid) - + ", gid: " - + str(self.gid) - + ", ggid: " - + str(self.ggid) - + ", Glyphlet Name: " - + str(self.name) - + " ]" - ) - - -class table_G_M_A_P_(DefaultTable.DefaultTable): - """Glyphlets GMAP table - - The ``GMAP`` table is used by Adobe's SING Glyphlets. - - See also https://web.archive.org/web/20080627183635/http://www.adobe.com/devnet/opentype/gdk/topic.html - """ - - dependencies = [] - - def decompile(self, data, ttFont): - dummy, newData = sstruct.unpack2(GMAPFormat, data, self) - self.psFontName = tostr(newData[: self.fontNameLength]) - assert ( - self.recordsOffset % 4 - ) == 0, "GMAP error: recordsOffset is not 32 bit aligned." - newData = data[self.recordsOffset :] - self.gmapRecords = [] - for i in range(self.recordsCount): - gmapRecord, newData = sstruct.unpack2( - GMAPRecordFormat1, newData, GMAPRecord() - ) - gmapRecord.name = gmapRecord.name.strip("\0") - self.gmapRecords.append(gmapRecord) - - def compile(self, ttFont): - self.recordsCount = len(self.gmapRecords) - self.fontNameLength = len(self.psFontName) - self.recordsOffset = 4 * (((self.fontNameLength + 12) + 3) // 4) - data = sstruct.pack(GMAPFormat, self) - data = data + tobytes(self.psFontName) - data = data + b"\0" * (self.recordsOffset - len(data)) - for record in self.gmapRecords: - data = data + record.compile(ttFont) - return data - - def toXML(self, writer, ttFont): - writer.comment("Most of this table will be recalculated by the compiler") - writer.newline() - formatstring, names, fixes = sstruct.getformat(GMAPFormat) - for name in names: - value = getattr(self, name) - writer.simpletag(name, value=value) - writer.newline() - writer.simpletag("PSFontName", value=self.psFontName) - writer.newline() - for gmapRecord in self.gmapRecords: - gmapRecord.toXML(writer, ttFont) - - def fromXML(self, name, attrs, content, ttFont): - if name == "GMAPRecord": - if not hasattr(self, "gmapRecords"): - self.gmapRecords = [] - gmapRecord = GMAPRecord() - self.gmapRecords.append(gmapRecord) - for element in content: - if isinstance(element, str): - continue - name, attrs, content = element - gmapRecord.fromXML(name, attrs, content, ttFont) - else: - value = attrs["value"] - if name == "PSFontName": - self.psFontName = value - else: - setattr(self, name, safeEval(value)) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/G_P_K_G_.py b/contrib/python/fonttools/fontTools/ttLib/tables/G_P_K_G_.py deleted file mode 100644 index 0da99fcda40..00000000000 --- a/contrib/python/fonttools/fontTools/ttLib/tables/G_P_K_G_.py +++ /dev/null @@ -1,133 +0,0 @@ -from fontTools.misc import sstruct -from fontTools.misc.textTools import bytesjoin, safeEval, readHex -from . import DefaultTable -import sys -import array - -GPKGFormat = """ - > # big endian - version: H - flags: H - numGMAPs: H - numGlyplets: H -""" -# psFontName is a byte string which follows the record above. This is zero padded -# to the beginning of the records array. The recordsOffsst is 32 bit aligned. - - -class table_G_P_K_G_(DefaultTable.DefaultTable): - """Glyphlets GPKG table - - The ``GPKG`` table is used by Adobe's SING Glyphlets. - - See also https://web.archive.org/web/20080627183635/http://www.adobe.com/devnet/opentype/gdk/topic.html - """ - - def decompile(self, data, ttFont): - dummy, newData = sstruct.unpack2(GPKGFormat, data, self) - - GMAPoffsets = array.array("I") - endPos = (self.numGMAPs + 1) * 4 - GMAPoffsets.frombytes(newData[:endPos]) - if sys.byteorder != "big": - GMAPoffsets.byteswap() - self.GMAPs = [] - for i in range(self.numGMAPs): - start = GMAPoffsets[i] - end = GMAPoffsets[i + 1] - self.GMAPs.append(data[start:end]) - pos = endPos - endPos = pos + (self.numGlyplets + 1) * 4 - glyphletOffsets = array.array("I") - glyphletOffsets.frombytes(newData[pos:endPos]) - if sys.byteorder != "big": - glyphletOffsets.byteswap() - self.glyphlets = [] - for i in range(self.numGlyplets): - start = glyphletOffsets[i] - end = glyphletOffsets[i + 1] - self.glyphlets.append(data[start:end]) - - def compile(self, ttFont): - self.numGMAPs = len(self.GMAPs) - self.numGlyplets = len(self.glyphlets) - GMAPoffsets = [0] * (self.numGMAPs + 1) - glyphletOffsets = [0] * (self.numGlyplets + 1) - - dataList = [sstruct.pack(GPKGFormat, self)] - - pos = len(dataList[0]) + (self.numGMAPs + 1) * 4 + (self.numGlyplets + 1) * 4 - GMAPoffsets[0] = pos - for i in range(1, self.numGMAPs + 1): - pos += len(self.GMAPs[i - 1]) - GMAPoffsets[i] = pos - gmapArray = array.array("I", GMAPoffsets) - if sys.byteorder != "big": - gmapArray.byteswap() - dataList.append(gmapArray.tobytes()) - - glyphletOffsets[0] = pos - for i in range(1, self.numGlyplets + 1): - pos += len(self.glyphlets[i - 1]) - glyphletOffsets[i] = pos - glyphletArray = array.array("I", glyphletOffsets) - if sys.byteorder != "big": - glyphletArray.byteswap() - dataList.append(glyphletArray.tobytes()) - dataList += self.GMAPs - dataList += self.glyphlets - data = bytesjoin(dataList) - return data - - def toXML(self, writer, ttFont): - writer.comment("Most of this table will be recalculated by the compiler") - writer.newline() - formatstring, names, fixes = sstruct.getformat(GPKGFormat) - for name in names: - value = getattr(self, name) - writer.simpletag(name, value=value) - writer.newline() - - writer.begintag("GMAPs") - writer.newline() - for gmapData in self.GMAPs: - writer.begintag("hexdata") - writer.newline() - writer.dumphex(gmapData) - writer.endtag("hexdata") - writer.newline() - writer.endtag("GMAPs") - writer.newline() - - writer.begintag("glyphlets") - writer.newline() - for glyphletData in self.glyphlets: - writer.begintag("hexdata") - writer.newline() - writer.dumphex(glyphletData) - writer.endtag("hexdata") - writer.newline() - writer.endtag("glyphlets") - writer.newline() - - def fromXML(self, name, attrs, content, ttFont): - if name == "GMAPs": - if not hasattr(self, "GMAPs"): - self.GMAPs = [] - for element in content: - if isinstance(element, str): - continue - itemName, itemAttrs, itemContent = element - if itemName == "hexdata": - self.GMAPs.append(readHex(itemContent)) - elif name == "glyphlets": - if not hasattr(self, "glyphlets"): - self.glyphlets = [] - for element in content: - if isinstance(element, str): - continue - itemName, itemAttrs, itemContent = element - if itemName == "hexdata": - self.glyphlets.append(readHex(itemContent)) - else: - setattr(self, name, safeEval(attrs["value"])) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/M_E_T_A_.py b/contrib/python/fonttools/fontTools/ttLib/tables/M_E_T_A_.py deleted file mode 100644 index 6a6f8bbf840..00000000000 --- a/contrib/python/fonttools/fontTools/ttLib/tables/M_E_T_A_.py +++ /dev/null @@ -1,352 +0,0 @@ -from fontTools.misc import sstruct -from fontTools.misc.textTools import byteord, safeEval -from . import DefaultTable -import pdb -import struct - - -METAHeaderFormat = """ - > # big endian - tableVersionMajor: H - tableVersionMinor: H - metaEntriesVersionMajor: H - metaEntriesVersionMinor: H - unicodeVersion: L - metaFlags: H - nMetaRecs: H -""" -# This record is followed by nMetaRecs of METAGlyphRecordFormat. -# This in turn is followd by as many METAStringRecordFormat entries -# as specified by the METAGlyphRecordFormat entries -# this is followed by the strings specifried in the METAStringRecordFormat -METAGlyphRecordFormat = """ - > # big endian - glyphID: H - nMetaEntry: H -""" -# This record is followd by a variable data length field: -# USHORT or ULONG hdrOffset -# Offset from start of META table to the beginning -# of this glyphs array of ns Metadata string entries. -# Size determined by metaFlags field -# METAGlyphRecordFormat entries must be sorted by glyph ID - -METAStringRecordFormat = """ - > # big endian - labelID: H - stringLen: H -""" -# This record is followd by a variable data length field: -# USHORT or ULONG stringOffset -# METAStringRecordFormat entries must be sorted in order of labelID -# There may be more than one entry with the same labelID -# There may be more than one strign with the same content. - -# Strings shall be Unicode UTF-8 encoded, and null-terminated. - -METALabelDict = { - 0: "MojikumiX4051", # An integer in the range 1-20 - 1: "UNIUnifiedBaseChars", - 2: "BaseFontName", - 3: "Language", - 4: "CreationDate", - 5: "FoundryName", - 6: "FoundryCopyright", - 7: "OwnerURI", - 8: "WritingScript", - 10: "StrokeCount", - 11: "IndexingRadical", -} - - -def getLabelString(labelID): - try: - label = METALabelDict[labelID] - except KeyError: - label = "Unknown label" - return str(label) - - -class table_M_E_T_A_(DefaultTable.DefaultTable): - """Glyphlets META table - - The ``META`` table is used by Adobe's SING Glyphlets. - - See also https://web.archive.org/web/20080627183635/http://www.adobe.com/devnet/opentype/gdk/topic.html - """ - - dependencies = [] - - def decompile(self, data, ttFont): - dummy, newData = sstruct.unpack2(METAHeaderFormat, data, self) - self.glyphRecords = [] - for i in range(self.nMetaRecs): - glyphRecord, newData = sstruct.unpack2( - METAGlyphRecordFormat, newData, GlyphRecord() - ) - if self.metaFlags == 0: - [glyphRecord.offset] = struct.unpack(">H", newData[:2]) - newData = newData[2:] - elif self.metaFlags == 1: - [glyphRecord.offset] = struct.unpack(">H", newData[:4]) - newData = newData[4:] - else: - assert 0, ( - "The metaFlags field in the META table header has a value other than 0 or 1 :" - + str(self.metaFlags) - ) - glyphRecord.stringRecs = [] - newData = data[glyphRecord.offset :] - for j in range(glyphRecord.nMetaEntry): - stringRec, newData = sstruct.unpack2( - METAStringRecordFormat, newData, StringRecord() - ) - if self.metaFlags == 0: - [stringRec.offset] = struct.unpack(">H", newData[:2]) - newData = newData[2:] - else: - [stringRec.offset] = struct.unpack(">H", newData[:4]) - newData = newData[4:] - stringRec.string = data[ - stringRec.offset : stringRec.offset + stringRec.stringLen - ] - glyphRecord.stringRecs.append(stringRec) - self.glyphRecords.append(glyphRecord) - - def compile(self, ttFont): - offsetOK = 0 - self.nMetaRecs = len(self.glyphRecords) - count = 0 - while offsetOK != 1: - count = count + 1 - if count > 4: - pdb.set_trace() - metaData = sstruct.pack(METAHeaderFormat, self) - stringRecsOffset = len(metaData) + self.nMetaRecs * ( - 6 + 2 * (self.metaFlags & 1) - ) - stringRecSize = 6 + 2 * (self.metaFlags & 1) - for glyphRec in self.glyphRecords: - glyphRec.offset = stringRecsOffset - if (glyphRec.offset > 65535) and ((self.metaFlags & 1) == 0): - self.metaFlags = self.metaFlags + 1 - offsetOK = -1 - break - metaData = metaData + glyphRec.compile(self) - stringRecsOffset = stringRecsOffset + ( - glyphRec.nMetaEntry * stringRecSize - ) - # this will be the String Record offset for the next GlyphRecord. - if offsetOK == -1: - offsetOK = 0 - continue - - # metaData now contains the header and all of the GlyphRecords. Its length should bw - # the offset to the first StringRecord. - stringOffset = stringRecsOffset - for glyphRec in self.glyphRecords: - assert glyphRec.offset == len( - metaData - ), "Glyph record offset did not compile correctly! for rec:" + str( - glyphRec - ) - for stringRec in glyphRec.stringRecs: - stringRec.offset = stringOffset - if (stringRec.offset > 65535) and ((self.metaFlags & 1) == 0): - self.metaFlags = self.metaFlags + 1 - offsetOK = -1 - break - metaData = metaData + stringRec.compile(self) - stringOffset = stringOffset + stringRec.stringLen - if offsetOK == -1: - offsetOK = 0 - continue - - if ((self.metaFlags & 1) == 1) and (stringOffset < 65536): - self.metaFlags = self.metaFlags - 1 - continue - else: - offsetOK = 1 - - # metaData now contains the header and all of the GlyphRecords and all of the String Records. - # Its length should be the offset to the first string datum. - for glyphRec in self.glyphRecords: - for stringRec in glyphRec.stringRecs: - assert stringRec.offset == len( - metaData - ), "String offset did not compile correctly! for string:" + str( - stringRec.string - ) - metaData = metaData + stringRec.string - - return metaData - - def toXML(self, writer, ttFont): - writer.comment( - "Lengths and number of entries in this table will be recalculated by the compiler" - ) - writer.newline() - formatstring, names, fixes = sstruct.getformat(METAHeaderFormat) - for name in names: - value = getattr(self, name) - writer.simpletag(name, value=value) - writer.newline() - for glyphRec in self.glyphRecords: - glyphRec.toXML(writer, ttFont) - - def fromXML(self, name, attrs, content, ttFont): - if name == "GlyphRecord": - if not hasattr(self, "glyphRecords"): - self.glyphRecords = [] - glyphRec = GlyphRecord() - self.glyphRecords.append(glyphRec) - for element in content: - if isinstance(element, str): - continue - name, attrs, content = element - glyphRec.fromXML(name, attrs, content, ttFont) - glyphRec.offset = -1 - glyphRec.nMetaEntry = len(glyphRec.stringRecs) - else: - setattr(self, name, safeEval(attrs["value"])) - - -class GlyphRecord(object): - def __init__(self): - self.glyphID = -1 - self.nMetaEntry = -1 - self.offset = -1 - self.stringRecs = [] - - def toXML(self, writer, ttFont): - writer.begintag("GlyphRecord") - writer.newline() - writer.simpletag("glyphID", value=self.glyphID) - writer.newline() - writer.simpletag("nMetaEntry", value=self.nMetaEntry) - writer.newline() - for stringRec in self.stringRecs: - stringRec.toXML(writer, ttFont) - writer.endtag("GlyphRecord") - writer.newline() - - def fromXML(self, name, attrs, content, ttFont): - if name == "StringRecord": - stringRec = StringRecord() - self.stringRecs.append(stringRec) - for element in content: - if isinstance(element, str): - continue - stringRec.fromXML(name, attrs, content, ttFont) - stringRec.stringLen = len(stringRec.string) - else: - setattr(self, name, safeEval(attrs["value"])) - - def compile(self, parentTable): - data = sstruct.pack(METAGlyphRecordFormat, self) - if parentTable.metaFlags == 0: - datum = struct.pack(">H", self.offset) - elif parentTable.metaFlags == 1: - datum = struct.pack(">L", self.offset) - data = data + datum - return data - - def __repr__(self): - return ( - "GlyphRecord[ glyphID: " - + str(self.glyphID) - + ", nMetaEntry: " - + str(self.nMetaEntry) - + ", offset: " - + str(self.offset) - + " ]" - ) - - -# XXX The following two functions are really broken around UTF-8 vs Unicode - - -def mapXMLToUTF8(string): - uString = str() - strLen = len(string) - i = 0 - while i < strLen: - prefixLen = 0 - if string[i : i + 3] == "&#x": - prefixLen = 3 - elif string[i : i + 7] == "&#x": - prefixLen = 7 - if prefixLen: - i = i + prefixLen - j = i - while string[i] != ";": - i = i + 1 - valStr = string[j:i] - - uString = uString + chr(eval("0x" + valStr)) - else: - uString = uString + chr(byteord(string[i])) - i = i + 1 - - return uString.encode("utf_8") - - -def mapUTF8toXML(string): - uString = string.decode("utf_8") - string = "" - for uChar in uString: - i = ord(uChar) - if (i < 0x80) and (i > 0x1F): - string = string + uChar - else: - string = string + "&#x" + hex(i)[2:] + ";" - return string - - -class StringRecord(object): - def toXML(self, writer, ttFont): - writer.begintag("StringRecord") - writer.newline() - writer.simpletag("labelID", value=self.labelID) - writer.comment(getLabelString(self.labelID)) - writer.newline() - writer.newline() - writer.simpletag("string", value=mapUTF8toXML(self.string)) - writer.newline() - writer.endtag("StringRecord") - writer.newline() - - def fromXML(self, name, attrs, content, ttFont): - for element in content: - if isinstance(element, str): - continue - name, attrs, content = element - value = attrs["value"] - if name == "string": - self.string = mapXMLToUTF8(value) - else: - setattr(self, name, safeEval(value)) - - def compile(self, parentTable): - data = sstruct.pack(METAStringRecordFormat, self) - if parentTable.metaFlags == 0: - datum = struct.pack(">H", self.offset) - elif parentTable.metaFlags == 1: - datum = struct.pack(">L", self.offset) - data = data + datum - return data - - def __repr__(self): - return ( - "StringRecord [ labelID: " - + str(self.labelID) - + " aka " - + getLabelString(self.labelID) - + ", offset: " - + str(self.offset) - + ", length: " - + str(self.stringLen) - + ", string: " - + self.string - + " ]" - ) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/S_I_N_G_.py b/contrib/python/fonttools/fontTools/ttLib/tables/S_I_N_G_.py deleted file mode 100644 index 1a367a92f2f..00000000000 --- a/contrib/python/fonttools/fontTools/ttLib/tables/S_I_N_G_.py +++ /dev/null @@ -1,99 +0,0 @@ -from fontTools.misc import sstruct -from fontTools.misc.textTools import bytechr, byteord, tobytes, tostr, safeEval -from . import DefaultTable - -SINGFormat = """ - > # big endian - tableVersionMajor: H - tableVersionMinor: H - glyphletVersion: H - permissions: h - mainGID: H - unitsPerEm: H - vertAdvance: h - vertOrigin: h - uniqueName: 28s - METAMD5: 16s - nameLength: 1s -""" -# baseGlyphName is a byte string which follows the record above. - - -class table_S_I_N_G_(DefaultTable.DefaultTable): - """Glyphlets SING table - - The ``SING`` table is used by Adobe's SING Glyphlets. - - See also https://web.archive.org/web/20080627183635/http://www.adobe.com/devnet/opentype/gdk/topic.html - """ - - dependencies = [] - - def decompile(self, data, ttFont): - dummy, rest = sstruct.unpack2(SINGFormat, data, self) - self.uniqueName = self.decompileUniqueName(self.uniqueName) - self.nameLength = byteord(self.nameLength) - assert len(rest) == self.nameLength - self.baseGlyphName = tostr(rest) - - rawMETAMD5 = self.METAMD5 - self.METAMD5 = "[" + hex(byteord(self.METAMD5[0])) - for char in rawMETAMD5[1:]: - self.METAMD5 = self.METAMD5 + ", " + hex(byteord(char)) - self.METAMD5 = self.METAMD5 + "]" - - def decompileUniqueName(self, data): - name = "" - for char in data: - val = byteord(char) - if val == 0: - break - if (val > 31) or (val < 128): - name += chr(val) - else: - octString = oct(val) - if len(octString) > 3: - octString = octString[1:] # chop off that leading zero. - elif len(octString) < 3: - octString.zfill(3) - name += "\\" + octString - return name - - def compile(self, ttFont): - d = self.__dict__.copy() - d["nameLength"] = bytechr(len(self.baseGlyphName)) - d["uniqueName"] = self.compilecompileUniqueName(self.uniqueName, 28) - METAMD5List = eval(self.METAMD5) - d["METAMD5"] = b"" - for val in METAMD5List: - d["METAMD5"] += bytechr(val) - assert len(d["METAMD5"]) == 16, "Failed to pack 16 byte MD5 hash in SING table" - data = sstruct.pack(SINGFormat, d) - data = data + tobytes(self.baseGlyphName) - return data - - def compilecompileUniqueName(self, name, length): - nameLen = len(name) - if length <= nameLen: - name = name[: length - 1] + "\000" - else: - name += (nameLen - length) * "\000" - return name - - def toXML(self, writer, ttFont): - writer.comment("Most of this table will be recalculated by the compiler") - writer.newline() - formatstring, names, fixes = sstruct.getformat(SINGFormat) - for name in names: - value = getattr(self, name) - writer.simpletag(name, value=value) - writer.newline() - writer.simpletag("baseGlyphName", value=self.baseGlyphName) - writer.newline() - - def fromXML(self, name, attrs, content, ttFont): - value = attrs["value"] - if name in ["uniqueName", "METAMD5", "baseGlyphName"]: - setattr(self, name, value) - else: - setattr(self, name, safeEval(value)) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/TupleVariation.py b/contrib/python/fonttools/fontTools/ttLib/tables/TupleVariation.py index bd6217e2ed9..4916f91d993 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/TupleVariation.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/TupleVariation.py @@ -8,10 +8,10 @@ from fontTools.misc.fixedTools import ( from fontTools.misc.textTools import safeEval import array from collections import Counter, defaultdict -import io import logging import struct import sys +import warnings # https://www.microsoft.com/typography/otspec/otvarcommonformats.htm @@ -620,6 +620,12 @@ class TupleVariation(object): def optimize(self, origCoords, endPts, tolerance=0.5, isComposite=False): from fontTools.varLib.iup import iup_delta_optimize + if isComposite: + warnings.warn( + "The isComposite argument is deprecated and may be removed in a future version", + DeprecationWarning, + ) + if None in self.coordinates: return # already optimized @@ -627,10 +633,6 @@ class TupleVariation(object): self.coordinates, origCoords, endPts, tolerance=tolerance ) if None in deltaOpt: - if isComposite and all(d is None for d in deltaOpt): - # Fix for macOS composites - # https://github.com/fonttools/fonttools/issues/1381 - deltaOpt = [(0, 0)] + [None] * (len(deltaOpt) - 1) # Use "optimized" version only if smaller... varOpt = TupleVariation(self.axes, deltaOpt) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/__init__.py b/contrib/python/fonttools/fontTools/ttLib/tables/__init__.py index b111097a804..fd98db78223 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/__init__.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/__init__.py @@ -19,8 +19,6 @@ def _moduleFinderHint(): from . import F_F_T_M_ from . import F__e_a_t from . import G_D_E_F_ - from . import G_M_A_P_ - from . import G_P_K_G_ from . import G_P_O_S_ from . import G_S_U_B_ from . import G_V_A_R_ @@ -30,10 +28,8 @@ def _moduleFinderHint(): from . import J_S_T_F_ from . import L_T_S_H_ from . import M_A_T_H_ - from . import M_E_T_A_ from . import M_V_A_R_ from . import O_S_2f_2 - from . import S_I_N_G_ from . import S_T_A_T_ from . import S_V_G_ from . import S__i_l_f diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/_c_m_a_p.py b/contrib/python/fonttools/fontTools/ttLib/tables/_c_m_a_p.py index e935313a18c..18d4bc64187 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/_c_m_a_p.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/_c_m_a_p.py @@ -204,6 +204,14 @@ class table__c_m_a_p(DefaultTable.DefaultTable): def compile(self, ttFont): self.tables.sort() # sort according to the spec; see CmapSubtable.__lt__() + keys = [(t.platformID, t.platEncID, t.language) for t in self.tables] + seen = set() + duplicates = {k for k in keys if k in seen or seen.add(k)} + if duplicates: + raise ValueError( + "cmap subtables have duplicate (platformID, platEncID, language) " + f"entries, which the OpenType spec does not allow: {duplicates}" + ) numSubTables = len(self.tables) totalOffset = 4 + 8 * numSubTables data = struct.pack(">HH", self.tableVersion, numSubTables) @@ -371,13 +379,11 @@ class CmapSubtable(object): getattr(self, "platformID", None), getattr(self, "platEncID", None), getattr(self, "language", None), - self.__dict__, ) otherTuple = ( getattr(other, "platformID", None), getattr(other, "platEncID", None), getattr(other, "language", None), - other.__dict__, ) return selfTuple < otherTuple diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/_f_v_a_r.py b/contrib/python/fonttools/fontTools/ttLib/tables/_f_v_a_r.py index f2536cb288a..a7e61d01347 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/_f_v_a_r.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/_f_v_a_r.py @@ -51,8 +51,6 @@ class table__f_v_a_r(DefaultTable.DefaultTable): See also https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6fvar.html """ - dependencies = ["name"] - def __init__(self, tag=None): DefaultTable.DefaultTable.__init__(self, tag) self.axes = [] diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/_g_l_y_f.py b/contrib/python/fonttools/fontTools/ttLib/tables/_g_l_y_f.py index 3dea653baa0..ffc7eaadbc7 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/_g_l_y_f.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/_g_l_y_f.py @@ -84,8 +84,6 @@ class table__g_l_y_f(DefaultTable.DefaultTable): """ - dependencies = ["fvar"] - # this attribute controls the amount of padding applied to glyph data upon compile. # Glyph lenghts are aligned to multiples of the specified value. # Allowed values are (0, 1, 2, 4). '0' means no padding; '1' (default) also means @@ -93,9 +91,6 @@ class table__g_l_y_f(DefaultTable.DefaultTable): padding = 1 def decompile(self, data, ttFont): - self.axisTags = ( - [axis.axisTag for axis in ttFont["fvar"].axes] if "fvar" in ttFont else [] - ) loca = ttFont["loca"] pos = int(loca[0]) nextPos = 0 @@ -135,10 +130,6 @@ class table__g_l_y_f(DefaultTable.DefaultTable): def compile(self, ttFont): optimizeSpeed = ttFont.cfg[ttLib.OPTIMIZE_FONT_SPEED] - - self.axisTags = ( - [axis.axisTag for axis in ttFont["fvar"].axes] if "fvar" in ttFont else [] - ) if not hasattr(self, "glyphOrder"): self.glyphOrder = ttFont.getGlyphOrder() padding = self.padding diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/_t_r_a_k.py b/contrib/python/fonttools/fontTools/ttLib/tables/_t_r_a_k.py index b0e8e19e08a..c49509e6fd1 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/_t_r_a_k.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/_t_r_a_k.py @@ -65,8 +65,6 @@ class table__t_r_a_k(DefaultTable.DefaultTable): See also https://developer.apple.com/fonts/TrueType-Reference-Manual/RM06/Chap6trak.html """ - dependencies = ["name"] - def compile(self, ttFont): dataList = [] offset = TRAK_HEADER_FORMAT_SIZE diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/otConverters.py b/contrib/python/fonttools/fontTools/ttLib/tables/otConverters.py index 75bfd7f6ca5..f2ba809b215 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/otConverters.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/otConverters.py @@ -430,6 +430,8 @@ class NameID(UShort): xmlWriter.write(" ") if name: xmlWriter.comment(name) + elif value == 0xFFFF: + xmlWriter.comment("None") else: xmlWriter.comment("missing from name table") log.warning("name id %d missing from name table" % value) diff --git a/contrib/python/fonttools/fontTools/ttLib/tables/ttProgram.py b/contrib/python/fonttools/fontTools/ttLib/tables/ttProgram.py index 32a4ec8b20f..41ea098a86f 100644 --- a/contrib/python/fonttools/fontTools/ttLib/tables/ttProgram.py +++ b/contrib/python/fonttools/fontTools/ttLib/tables/ttProgram.py @@ -157,7 +157,7 @@ instructions = [ # fmt: on -def bitRepr(value, bits): +def bitRepr(value: int, bits: int) -> str: s = "" for i in range(bits): s = "01"[value & 0x1] + s @@ -168,7 +168,9 @@ def bitRepr(value, bits): _mnemonicPat = re.compile(r"[A-Z][A-Z0-9]*$") -def _makeDict(instructionList): +def _makeDict( + instructionList: list[tuple[int, str, int, str, int, int]], +) -> tuple[dict, dict]: opcodeDict = {} mnemonicDict = {} for op, mnemonic, argBits, name, pops, pushes in instructionList: @@ -188,10 +190,10 @@ opcodeDict, mnemonicDict = _makeDict(instructions) class tt_instructions_error(Exception): - def __init__(self, error): + def __init__(self, error: str) -> None: self.error = error - def __str__(self): + def __str__(self) -> str: return "TT instructions error: %s" % repr(self.error) @@ -209,14 +211,14 @@ _indentRE = re.compile(r"^FDEF|IF|ELSE\[ \]\t.+") _unindentRE = re.compile(r"^ELSE|ENDF|EIF\[ \]\t.+") -def _skipWhite(data, pos): +def _skipWhite(data: str, pos: int) -> int: m = _whiteRE.match(data, pos) newPos = m.regs[0][1] assert newPos >= pos return newPos -class Program(object): +class Program: def __init__(self) -> None: pass @@ -225,7 +227,7 @@ class Program(object): if hasattr(self, "assembly"): del self.assembly - def fromAssembly(self, assembly: List[str] | str) -> None: + def fromAssembly(self, assembly: list[str] | str) -> None: if isinstance(assembly, list): self.assembly = assembly elif isinstance(assembly, str): diff --git a/contrib/python/fonttools/fontTools/ttLib/ttFont.py b/contrib/python/fonttools/fontTools/ttLib/ttFont.py index 4407e567a36..925250511d7 100644 --- a/contrib/python/fonttools/fontTools/ttLib/ttFont.py +++ b/contrib/python/fonttools/fontTools/ttLib/ttFont.py @@ -41,8 +41,6 @@ if TYPE_CHECKING: E_B_L_C_, F_F_T_M_, G_D_E_F_, - G_M_A_P_, - G_P_K_G_, G_P_O_S_, G_S_U_B_, G_V_A_R_, @@ -50,9 +48,7 @@ if TYPE_CHECKING: J_S_T_F_, L_T_S_H_, M_A_T_H_, - M_E_T_A_, M_V_A_R_, - S_I_N_G_, S_T_A_T_, S_V_G_, T_S_I__0, @@ -442,9 +438,9 @@ class TTFont(object): """Export the font as TTX (an XML-based text file), or as a series of text files when splitTables is true. In the latter case, the 'fileOrPath' argument should be a path to a directory. - The 'tables' argument must either be false (dump all tables) or a - list of tables to dump. The 'skipTables' argument may be a list of tables - to skip, but only when the 'tables' argument is false. + The 'tables' argument must either be falsy (None or empty list, meaning + dump all tables) or a non-empty list of tables to dump. The 'skipTables' + argument may be a list of tables to skip, but only when 'tables' is falsy. """ writer = xmlWriter.XMLWriter(fileOrPath, newlinestr=newlinestr) @@ -647,10 +643,6 @@ class TTFont(object): @overload def __getitem__(self, tag: Literal["GDEF"]) -> G_D_E_F_.table_G_D_E_F_: ... @overload - def __getitem__(self, tag: Literal["GMAP"]) -> G_M_A_P_.table_G_M_A_P_: ... - @overload - def __getitem__(self, tag: Literal["GPKG"]) -> G_P_K_G_.table_G_P_K_G_: ... - @overload def __getitem__(self, tag: Literal["GPOS"]) -> G_P_O_S_.table_G_P_O_S_: ... @overload def __getitem__(self, tag: Literal["GSUB"]) -> G_S_U_B_.table_G_S_U_B_: ... @@ -665,12 +657,8 @@ class TTFont(object): @overload def __getitem__(self, tag: Literal["MATH"]) -> M_A_T_H_.table_M_A_T_H_: ... @overload - def __getitem__(self, tag: Literal["META"]) -> M_E_T_A_.table_M_E_T_A_: ... - @overload def __getitem__(self, tag: Literal["MVAR"]) -> M_V_A_R_.table_M_V_A_R_: ... @overload - def __getitem__(self, tag: Literal["SING"]) -> S_I_N_G_.table_S_I_N_G_: ... - @overload def __getitem__(self, tag: Literal["STAT"]) -> S_T_A_T_.table_S_T_A_T_: ... @overload def __getitem__(self, tag: Literal["SVG "]) -> S_V_G_.table_S_V_G_: ... @@ -879,10 +867,6 @@ class TTFont(object): @overload def get(self, tag: Literal["GDEF"]) -> G_D_E_F_.table_G_D_E_F_ | None: ... @overload - def get(self, tag: Literal["GMAP"]) -> G_M_A_P_.table_G_M_A_P_ | None: ... - @overload - def get(self, tag: Literal["GPKG"]) -> G_P_K_G_.table_G_P_K_G_ | None: ... - @overload def get(self, tag: Literal["GPOS"]) -> G_P_O_S_.table_G_P_O_S_ | None: ... @overload def get(self, tag: Literal["GSUB"]) -> G_S_U_B_.table_G_S_U_B_ | None: ... @@ -897,12 +881,8 @@ class TTFont(object): @overload def get(self, tag: Literal["MATH"]) -> M_A_T_H_.table_M_A_T_H_ | None: ... @overload - def get(self, tag: Literal["META"]) -> M_E_T_A_.table_M_E_T_A_ | None: ... - @overload def get(self, tag: Literal["MVAR"]) -> M_V_A_R_.table_M_V_A_R_ | None: ... @overload - def get(self, tag: Literal["SING"]) -> S_I_N_G_.table_S_I_N_G_ | None: ... - @overload def get(self, tag: Literal["STAT"]) -> S_T_A_T_.table_S_T_A_T_ | None: ... @overload def get(self, tag: Literal["SVG "]) -> S_V_G_.table_S_V_G_ | None: ... diff --git a/contrib/python/fonttools/fontTools/ufoLib/glifLib.py b/contrib/python/fonttools/fontTools/ufoLib/glifLib.py index 040c31c4990..c9929b2f69b 100644 --- a/contrib/python/fonttools/fontTools/ufoLib/glifLib.py +++ b/contrib/python/fonttools/fontTools/ufoLib/glifLib.py @@ -1230,9 +1230,6 @@ def _readGlyphFromTreeFormat1( unicodes = [] haveSeenAdvance = haveSeenOutline = haveSeenLib = haveSeenNote = False for element in tree: - if glyphObject is None: - continue - if element.tag == "outline": if validate: if haveSeenOutline: @@ -1245,6 +1242,10 @@ def _readGlyphFromTreeFormat1( raise GlifLibError("Invalid outline structure.") haveSeenOutline = True buildOutlineFormat1(glyphObject, pointPen, element, validate) + elif glyphObject is None: + # Skip remaining elements if no glyphObject, but outline is still + # processed above to allow drawing via pointPen without a glyphObject. + continue elif element.tag == "advance": if validate and haveSeenAdvance: raise GlifLibError("The advance element occurs more than once.") @@ -1299,8 +1300,6 @@ def _readGlyphFromTreeFormat2( ) identifiers: set[str] = set() for element in tree: - if glyphObject is None: - continue if element.tag == "outline": if validate: if haveSeenOutline: @@ -1316,6 +1315,10 @@ def _readGlyphFromTreeFormat2( buildOutlineFormat2( glyphObject, pointPen, element, identifiers, validate ) + elif glyphObject is None: + # Skip remaining elements if no glyphObject, but outline is still + # processed above to allow drawing via pointPen without a glyphObject. + continue elif element.tag == "advance": if validate and haveSeenAdvance: raise GlifLibError("The advance element occurs more than once.") diff --git a/contrib/python/fonttools/fontTools/ufoLib/validators.py b/contrib/python/fonttools/fontTools/ufoLib/validators.py index 54c65fb6cf6..baed5688d04 100644 --- a/contrib/python/fonttools/fontTools/ufoLib/validators.py +++ b/contrib/python/fonttools/fontTools/ufoLib/validators.py @@ -623,19 +623,8 @@ def guidelineValidator(value: Any) -> bool: """ if not genericDictValidator(value, _guidelineDictPrototype): return False - x = value.get("x") - y = value.get("y") + angle = value.get("angle") - # x or y must be present - if x is None and y is None: - return False - # if x or y are None, angle must not be present - if x is None or y is None: - if angle is not None: - return False - # if x and y are defined, angle must be defined - if x is not None and y is not None and angle is None: - return False # angle must be between 0 and 360 if angle is not None: if angle < 0: diff --git a/contrib/python/fonttools/fontTools/varLib/__init__.py b/contrib/python/fonttools/fontTools/varLib/__init__.py index c19bd151588..34bf8fc2015 100644 --- a/contrib/python/fonttools/fontTools/varLib/__init__.py +++ b/contrib/python/fonttools/fontTools/varLib/__init__.py @@ -382,25 +382,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): continue var = TupleVariation(support, delta) if optimize: - delta_opt = iup_delta_optimize( - delta, origCoords, endPts, tolerance=tolerance - ) - - if None in delta_opt: - # Use "optimized" version only if smaller... - var_opt = TupleVariation(support, delta_opt) - - axis_tags = sorted( - support.keys() - ) # Shouldn't matter that this is different from fvar...? - tupleData, auxData = var.compile(axis_tags) - unoptimized_len = len(tupleData) + len(auxData) - tupleData, auxData = var_opt.compile(axis_tags) - optimized_len = len(tupleData) + len(auxData) - - if optimized_len < unoptimized_len: - var = var_opt - + var.optimize(origCoords, endPts, tolerance=tolerance) gvar.variations[glyph].append(var) @@ -768,7 +750,7 @@ def _add_MVAR(font, masterModel, master_ttfs, axisTags): # and unilaterally/arbitrarily define a sentinel value to distinguish the case # when a post table is present in a given master simply because that's where # the glyph names in TrueType must be stored, but the underline values are not - # meant to be used for building MVAR's deltas. The value of -0x8000 (-36768) + # meant to be used for building MVAR's deltas. The value of -0x8000 (-32768) # the minimum FWord (int16) value, was chosen for its unlikelyhood to appear # in real-world underline position/thickness values. specialTags = {"unds": -0x8000, "undo": -0x8000} @@ -864,7 +846,6 @@ def _merge_OTL(font, model, master_fonts, axisTags): GDEF = font["GDEF"].table assert GDEF.Version <= 0x00010002 except KeyError: - font["GDEF"] = newTable("GDEF") GDEFTable = font["GDEF"] = newTable("GDEF") GDEF = GDEFTable.table = ot.GDEF() GDEF.GlyphClassDef = None @@ -1154,7 +1135,6 @@ def drop_implied_oncurve_points(*masters: TTFont) -> int: https://developer.apple.com/fonts/TrueType-Reference-Manual/RM01/Chap1.html """ - count = 0 glyph_masters = defaultdict(list) # multiple DS source may point to the same TTFont object and we want to # avoid processing the same glyph twice as they are modified in-place @@ -1569,6 +1549,7 @@ def main(args=None): filename = vf.name + ".{ext}" vf_name_to_output_path[vf.name] = os.path.join(output_dir, filename) + vf_names_to_build = {vf.name for vf in vfs_to_build} finder = MasterFinder(options.master_finder) vfs = build_many( @@ -1576,6 +1557,7 @@ def main(args=None): finder, exclude=options.exclude, optimize=options.optimize, + skip_vf=lambda name: name not in vf_names_to_build, colr_layer_reuse=options.colr_layer_reuse, drop_implied_oncurves=options.drop_implied_oncurves, ) diff --git a/contrib/python/fonttools/fontTools/varLib/avar/build.py b/contrib/python/fonttools/fontTools/varLib/avar/build.py index e70925cbd74..d9706705db8 100644 --- a/contrib/python/fonttools/fontTools/varLib/avar/build.py +++ b/contrib/python/fonttools/fontTools/varLib/avar/build.py @@ -9,8 +9,8 @@ def build(font, designspace_file): ds = load_designspace(designspace_file, require_sources=False) if not "fvar" in font: - # if "name" not in font: - font["name"] = newTable("name") + if "name" not in font: + font["name"] = newTable("name") _add_fvar(font, ds.axes, ds.instances) axisTags = [a.axisTag for a in font["fvar"].axes] diff --git a/contrib/python/fonttools/fontTools/varLib/avar/map.py b/contrib/python/fonttools/fontTools/varLib/avar/map.py index 68eed6c83eb..a5331f109b8 100644 --- a/contrib/python/fonttools/fontTools/varLib/avar/map.py +++ b/contrib/python/fonttools/fontTools/varLib/avar/map.py @@ -16,6 +16,9 @@ def map( fvar = font["fvar"] axes = {a.axisTag: (a.minValue, a.defaultValue, a.maxValue) for a in fvar.axes} + unknownAxes = sorted(tag for tag in location if tag not in axes) + if unknownAxes: + raise ValueError(f"Unknown axis tag(s): {', '.join(unknownAxes)}") if not inputNormalized: location = { @@ -83,17 +86,30 @@ def main(args=None): if "fvar" not in font: parser.error(f"Font '{options.font}' does not contain an 'fvar' table.") - location = { - tag: float(value) for tag, value in (item.split("=") for item in options.coords) - } + location = {} + for item in options.coords: + tag, sep, value = item.partition("=") + if not sep or not tag or not value: + parser.error( + f"Invalid coordinate {item!r}. Expected AXIS=value, e.g. wght=500" + ) + try: + location[tag] = float(value) + except ValueError: + parser.error( + f"Invalid coordinate value in {item!r}. Expected a number after '='" + ) - mapped = map( - font, - location, - inputNormalized=options.i, - outputNormalized=options.o, - dropZeroes=not options.f, - ) + try: + mapped = map( + font, + location, + inputNormalized=options.i, + outputNormalized=options.o, + dropZeroes=not options.f, + ) + except ValueError as e: + parser.error(str(e)) assert mapped is not None for tag in mapped: diff --git a/contrib/python/fonttools/fontTools/varLib/avar/unbuild.py b/contrib/python/fonttools/fontTools/varLib/avar/unbuild.py index d592bd73104..ddf677902ce 100644 --- a/contrib/python/fonttools/fontTools/varLib/avar/unbuild.py +++ b/contrib/python/fonttools/fontTools/varLib/avar/unbuild.py @@ -81,7 +81,7 @@ def mappings_from_avar(font, denormalize=True): axisTags = [a.axisTag for a in fvarAxes] axisIndexes = {a.axisTag: i for i, a in enumerate(fvarAxes)} if "avar" not in font: - return {}, {} + return {}, [] avar = font["avar"] axisMaps = { tag: seg @@ -185,7 +185,10 @@ def unbuild(font, f=sys.stdout): if "name" in font: name = font["name"] - axisNames = {axis.axisTag: name.getDebugName(axis.axisNameID) for axis in axes} + axisNames = { + axis.axisTag: name.getDebugName(axis.axisNameID) or axis.axisTag + for axis in axes + } else: axisNames = {a.axisTag: a.axisTag for a in axes} diff --git a/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py b/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py index 2993bf38bfa..dc6bf18e586 100644 --- a/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py +++ b/contrib/python/fonttools/fontTools/varLib/instancer/__init__.py @@ -954,11 +954,8 @@ def _instantiateGvarGlyph( # preserve backwards compatibility. # See 0010a3cd9aa25f84a3a6250dafb119743d32aa40 coordinates.toInt() - - isComposite = glyf[glyphname].isComposite() - for var in tupleVarStore: - var.optimize(coordinates, endPts, isComposite=isComposite) + var.optimize(coordinates, endPts) def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True): diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatable.py b/contrib/python/fonttools/fontTools/varLib/interpolatable.py index c5d7ecf525e..64b3a6d1968 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatable.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatable.py @@ -116,12 +116,12 @@ class Glyph: # Add mirrored rotations add_isomorphisms(points.value, isomorphisms, True) - def draw(self, pen, countor_idx=None): - if countor_idx is None: + def draw(self, pen, contour_idx=None): + if contour_idx is None: for contour in self.recordings: contour.draw(pen) else: - self.recordings[countor_idx].draw(pen) + self.recordings[contour_idx].draw(pen) def test_gen( @@ -168,7 +168,7 @@ def test_gen( for glyph_name in glyphs: log.info("Testing glyph %s", glyph_name) allGlyphs = [Glyph(glyph_name, glyphset) for glyphset in glyphsets] - if len([1 for glyph in allGlyphs if glyph is not None]) <= 1: + if len([1 for glyph in allGlyphs if not glyph.doesnt_exist]) <= 1: continue for master_idx, (glyph, glyphset, name) in enumerate( zip(allGlyphs, glyphsets, names) @@ -219,8 +219,8 @@ def test_gen( # Basic compatibility checks # - m1 = glyph0.nodeTypes - m0 = glyph1.nodeTypes + m0 = glyph0.nodeTypes + m1 = glyph1.nodeTypes if len(m0) != len(m1): yield ( glyph_name, @@ -387,7 +387,7 @@ def test_gen( ): if overweight: expectedSize = max(size0, size1) - continue + continue # disabled else: expectedSize = sqrt(size0 * size1) @@ -467,7 +467,7 @@ def test_gen( if pt0_prev[1] and pt1_prev[1]: # At least one off-curve is required continue - if pt0_prev[1] and pt1_prev[1]: + if pt0_next[1] and pt1_next[1]: # At least one off-curve is required continue @@ -826,7 +826,7 @@ def main(args=None): # The spec says vsindex can only appear once and must be the first # operator in the charstring, but we support multiple. # https://github.com/harfbuzz/boring-expansion-spec/issues/158 - for op in enumerate(cs.program): + for op in cs.program: if op == "blend": vsindices.add(vsindex) elif op == "vsindex": @@ -1048,7 +1048,7 @@ def main(args=None): ) elif p["type"] == InterpolatableProblem.NODE_INCOMPATIBILITY: print( - " Node %o incompatible in path %i: %s in %s, %s in %s" + " Node %d incompatible in path %i: %s in %s, %s in %s" % ( p["node"], p["path"], diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatableHelpers.py b/contrib/python/fonttools/fontTools/varLib/interpolatableHelpers.py index 67b9ea27c68..bbe072c83cf 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatableHelpers.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatableHelpers.py @@ -5,7 +5,6 @@ from fontTools.pens.recordingPen import RecordingPen, DecomposingRecordingPen from fontTools.misc.transform import Transform from collections import defaultdict, deque from math import sqrt, copysign, atan2, pi -from enum import Enum import itertools import logging @@ -42,7 +41,7 @@ class InterpolatableProblem: def sort_problems(problems): - """Sort problems by severity, then by glyph name, then by problem message.""" + """Sort problem groups by their most severe problem type.""" return dict( sorted( problems.items(), @@ -66,7 +65,6 @@ def rot_list(l, k): class PerContourPen(BasePen): def __init__(self, Pen, glyphset=None): BasePen.__init__(self, glyphset) - self._glyphset = glyphset self._Pen = Pen self._pen = None self.value = [] @@ -212,8 +210,6 @@ def contour_vector_from_stats(stats): def matching_for_vectors(m0, m1): n = len(m0) - identity_matching = list(range(n)) - costs = [[vdiff_hypot2(v0, v1) for v1 in m1] for v0 in m0] ( matching, @@ -307,9 +303,9 @@ def find_parents_and_order(glyphsets, locations, *, discrete_axes=set()): if all(v == 0 for k, v in l.items() if k not in discrete_axes) ] if bases: - logging.info("Found %s base masters: %s", len(bases), bases) + log.info("Found %s base masters: %s", len(bases), bases) else: - logging.warning("No base master location found") + log.warning("No base master location found") # Form a minimum spanning tree of the locations try: @@ -362,38 +358,3 @@ def find_parents_and_order(glyphsets, locations, *, discrete_axes=set()): log.info("Parents: %s", parents) log.info("Order: %s", order) return parents, order - - -def transform_from_stats(stats, inverse=False): - # https://cookierobotics.com/007/ - a = stats.varianceX - b = stats.covariance - c = stats.varianceY - - delta = (((a - c) * 0.5) ** 2 + b * b) ** 0.5 - lambda1 = (a + c) * 0.5 + delta # Major eigenvalue - lambda2 = (a + c) * 0.5 - delta # Minor eigenvalue - theta = atan2(lambda1 - a, b) if b != 0 else (pi * 0.5 if a < c else 0) - trans = Transform() - - if lambda2 < 0: - # XXX This is a hack. - # The problem is that the covariance matrix is singular. - # This happens when the contour is a line, or a circle. - # In that case, the covariance matrix is not a good - # representation of the contour. - # We should probably detect this earlier and avoid - # computing the covariance matrix in the first place. - # But for now, we just avoid the division by zero. - lambda2 = 0 - - if inverse: - trans = trans.translate(-stats.meanX, -stats.meanY) - trans = trans.rotate(-theta) - trans = trans.scale(1 / sqrt(lambda1), 1 / sqrt(lambda2)) - else: - trans = trans.scale(sqrt(lambda1), sqrt(lambda2)) - trans = trans.rotate(theta) - trans = trans.translate(stats.meanX, stats.meanY) - - return trans diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py b/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py index 3c206c6ee2e..6685cd763a0 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatablePlot.py @@ -13,12 +13,7 @@ from fontTools.pens.pointPen import ( PointToSegmentPen, ReverseContourPointPen, ) -from fontTools.varLib.interpolatableHelpers import ( - PerContourOrComponentPen, - SimpleRecordingPointPen, -) from itertools import cycle -from functools import wraps from io import BytesIO import cairo import math @@ -141,7 +136,6 @@ class InterpolatablePlot: ): pad = self.pad width = self.width - 3 * self.pad - height = self.height - 2 * self.pad x = y = pad self.draw_label( @@ -163,7 +157,8 @@ class InterpolatablePlot: y += self.font_size + self.pad try: - h = hashlib.sha1(open(file, "rb").read()).hexdigest() + with open(file, "rb") as f: + h = hashlib.sha1(f.read()).hexdigest() self.draw_label("sha1: %s" % h, x=x + pad, y=y, width=width) y += self.font_size except IsADirectoryError: @@ -359,12 +354,12 @@ class InterpolatablePlot: y += self.title_font_size glyphs_per_problem = defaultdict(set) - for glyphname, problems in sorted(problems.items()): - for problem in problems: + for glyphname, glyph_problems in sorted(problems.items()): + for problem in glyph_problems: glyphs_per_problem[problem["type"]].add(glyphname) - if "nothing" in glyphs_per_problem: - del glyphs_per_problem["nothing"] + if InterpolatableProblem.NOTHING in glyphs_per_problem: + del glyphs_per_problem[InterpolatableProblem.NOTHING] for problem_type in sorted( glyphs_per_problem, key=lambda x: InterpolatableProblem.severity[x] @@ -867,7 +862,7 @@ class InterpolatablePlot: if scale is None: scale = self.panel_width / glyph_width else: - scale = min(scale, self.panel_height / glyph_height) + scale = min(scale, self.panel_width / glyph_width) if glyph_height: if scale is None: scale = self.panel_height / glyph_height diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatableTestContourOrder.py b/contrib/python/fonttools/fontTools/varLib/interpolatableTestContourOrder.py index 36885297984..f593e2bd4db 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatableTestContourOrder.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatableTestContourOrder.py @@ -8,7 +8,7 @@ def test_contour_order(glyph0, glyph1): # We try matching both the StatisticsControlPen vector # and the StatisticsPen vector. # - # If either method found a identity matching, accept it. + # If either method found an identity matching, accept it. # This is crucial for fonts like Kablammo[MORF].ttf and # Nabla[EDPT,EHLT].ttf, since they really confuse the # StatisticsPen vector because of their area=0 contours. @@ -43,28 +43,27 @@ def test_contour_order(glyph0, glyph1): # test will fix them. # # Reverse the sign of the area (0); the rest stay the same. - if not done: - m1ControlReversed = [(-m[0],) + m[1:] for m in m1Control] - ( - matching_control_reversed, - matching_cost_control_reversed, - identity_cost_control_reversed, - ) = matching_for_vectors(m0Control, m1ControlReversed) - done = matching_cost_control_reversed == identity_cost_control_reversed + m1ControlReversed = [(-m[0],) + m[1:] for m in m1Control] + ( + matching_control_reversed, + matching_cost_control_reversed, + identity_cost_control_reversed, + ) = matching_for_vectors(m0Control, m1ControlReversed) + done = matching_cost_control_reversed == identity_cost_control_reversed if not done: m1GreenReversed = [(-m[0],) + m[1:] for m in m1Green] ( - matching_control_reversed, + matching_green_reversed, matching_cost_green_reversed, identity_cost_green_reversed, ) = matching_for_vectors(m0Green, m1GreenReversed) done = matching_cost_green_reversed == identity_cost_green_reversed if not done: - # Otherwise, use the worst of the two matchings. + # Otherwise, use the best of the two matchings. if ( - matching_cost_control / identity_cost_control - < matching_cost_green / identity_cost_green + matching_cost_control * identity_cost_green + < matching_cost_green * identity_cost_control ): matching = matching_control matching_cost = matching_cost_control diff --git a/contrib/python/fonttools/fontTools/varLib/interpolatableTestStartingPoint.py b/contrib/python/fonttools/fontTools/varLib/interpolatableTestStartingPoint.py index e91dacf288b..6c619ebe689 100644 --- a/contrib/python/fonttools/fontTools/varLib/interpolatableTestStartingPoint.py +++ b/contrib/python/fonttools/fontTools/varLib/interpolatableTestStartingPoint.py @@ -45,8 +45,8 @@ def test_starting_point(glyph0, glyph1, ix, tolerance, matching): # This is a 2x2 matrix. transforms = [] for vector in (m0Vectors[ix], m1Vectors[ix]): - meanX = vector[1] - meanY = vector[2] + # meanX = vector[1] + # meanY = vector[2] stddevX = vector[3] * 0.5 stddevY = vector[4] * 0.5 correlation = vector[5] @@ -68,6 +68,8 @@ def test_starting_point(glyph0, glyph1, ix, tolerance, matching): # we are doing anyway... # trans = trans.translate(meanX, meanY) trans = trans.rotate(theta) + if lambda2 < 0: + lambda2 = 0 trans = trans.scale(sqrt(lambda1), sqrt(lambda2)) transforms.append(trans) diff --git a/contrib/python/fonttools/fontTools/varLib/models.py b/contrib/python/fonttools/fontTools/varLib/models.py index 1d836aeb163..05f92c252e0 100644 --- a/contrib/python/fonttools/fontTools/varLib/models.py +++ b/contrib/python/fonttools/fontTools/varLib/models.py @@ -10,6 +10,7 @@ __all__ = [ "VariationModel", ] +from collections.abc import Mapping from typing import TYPE_CHECKING from fontTools.misc.roundTools import noRound from .errors import VariationModelError @@ -274,6 +275,8 @@ class VariationModel(object): def __init__( self, locations, axisOrder=None, extrapolate=False, *, axisRanges=None ): + locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations] + if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations): raise VariationModelError("Locations must be unique.") @@ -287,8 +290,6 @@ class VariationModel(object): allAxes = {axis for loc in locations for axis in loc.keys()} axisRanges = {axis: (-1, 1) for axis in allAxes} self.axisRanges = axisRanges - - locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations] keyFunc = self.getMasterLocationsSortKeyFunc( locations, axisOrder=self.axisOrder ) @@ -313,7 +314,12 @@ class VariationModel(object): key = tuple(v is not None for v in items) subModel = self._subModels.get(key) if subModel is None: - subModel = VariationModel(subList(key, self.origLocations), self.axisOrder) + subModel = VariationModel( + subList(key, self.origLocations), + self.axisOrder, + extrapolate=self.extrapolate, + axisRanges=self.axisRanges, + ) self._subModels[key] = subModel return subModel, subList(key, items) @@ -401,7 +407,7 @@ class VariationModel(object): locAxes = set(region.keys()) # Walk over previous masters now for prev_region in regions[:i]: - # Master with different axes do not participte + # Master with different axes do not participate if set(prev_region.keys()) != locAxes: continue # If it's NOT in the current box, it does not participate @@ -572,7 +578,7 @@ class VariationModel(object): return self.interpolateFromDeltasAndScalars(deltas, scalars) -def piecewiseLinearMap(v, mapping): +def piecewiseLinearMap(v: float, mapping: Mapping[float, float]) -> float: keys = mapping.keys() if not keys: return v diff --git a/contrib/python/fonttools/ya.make b/contrib/python/fonttools/ya.make index 3f8f9d4e88a..da4de1fc839 100644 --- a/contrib/python/fonttools/ya.make +++ b/contrib/python/fonttools/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(4.61.1) +VERSION(4.62.0) LICENSE(MIT) @@ -49,6 +49,11 @@ PY_SRCS( fontTools/designspaceLib/split.py fontTools/designspaceLib/statNames.py fontTools/designspaceLib/types.py + fontTools/diff/__init__.py + fontTools/diff/__main__.py + fontTools/diff/color.py + fontTools/diff/diff.py + fontTools/diff/utils.py fontTools/encodings/MacRoman.py fontTools/encodings/StandardEncoding.py fontTools/encodings/__init__.py @@ -202,8 +207,6 @@ PY_SRCS( fontTools/ttLib/tables/F_F_T_M_.py fontTools/ttLib/tables/F__e_a_t.py fontTools/ttLib/tables/G_D_E_F_.py - fontTools/ttLib/tables/G_M_A_P_.py - fontTools/ttLib/tables/G_P_K_G_.py fontTools/ttLib/tables/G_P_O_S_.py fontTools/ttLib/tables/G_S_U_B_.py fontTools/ttLib/tables/G_V_A_R_.py @@ -213,10 +216,8 @@ PY_SRCS( fontTools/ttLib/tables/J_S_T_F_.py fontTools/ttLib/tables/L_T_S_H_.py fontTools/ttLib/tables/M_A_T_H_.py - fontTools/ttLib/tables/M_E_T_A_.py fontTools/ttLib/tables/M_V_A_R_.py fontTools/ttLib/tables/O_S_2f_2.py - fontTools/ttLib/tables/S_I_N_G_.py fontTools/ttLib/tables/S_T_A_T_.py fontTools/ttLib/tables/S_V_G_.py fontTools/ttLib/tables/S__i_l_f.py |
