diff --git a/Lib/fontTools/ttLib/reorderGlyphs.py b/Lib/fontTools/ttLib/reorderGlyphs.py new file mode 100644 index 000000000..3221261f1 --- /dev/null +++ b/Lib/fontTools/ttLib/reorderGlyphs.py @@ -0,0 +1,278 @@ +"""Reorder glyphs in a font.""" + +__author__ = "Rod Sheeter" + +# See https://docs.google.com/document/d/1h9O-C_ndods87uY0QeIIcgAMiX2gDTpvO_IhMJsKAqs/ +# for details. + + +from fontTools import ttLib +from fontTools.ttLib.tables import otBase +from fontTools.ttLib.tables import otTables as ot +from abc import ABC, abstractmethod +from dataclasses import dataclass +from collections import deque +from typing import ( + Optional, + Any, + Callable, + Deque, + Iterable, + List, + NamedTuple, + Tuple, + Union, +) + + +_COVERAGE_ATTR = "Coverage" # tables that have one coverage use this name + + +def _sort_by_gid( + get_glyph_id: Callable[[str], int], + glyphs: List[str], + parallel_list: Optional[List[Any]], +): + if parallel_list: + reordered = sorted( + ((g, e) for g, e in zip(glyphs, parallel_list)), + key=lambda t: get_glyph_id(t[0]), + ) + sorted_glyphs, sorted_parallel_list = map(list, zip(*reordered)) + parallel_list[:] = sorted_parallel_list + else: + sorted_glyphs = sorted(glyphs, key=get_glyph_id) + + glyphs[:] = sorted_glyphs + + +def _get_dotted_attr(value: Any, dotted_attr: str) -> Any: + attr_names = dotted_attr.split(".") + assert attr_names + + while attr_names: + attr_name = attr_names.pop(0) + value = getattr(value, attr_name) + return value + + +class ReorderRule(ABC): + """A rule to reorder something in a font to match the fonts glyph order.""" + + @abstractmethod + def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: ... + + +@dataclass(frozen=True) +class ReorderCoverage(ReorderRule): + """Reorder a Coverage table, and optionally a list that is sorted parallel to it.""" + + # A list that is parallel to Coverage + parallel_list_attr: Optional[str] = None + coverage_attr: str = _COVERAGE_ATTR + + def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: + coverage = _get_dotted_attr(value, self.coverage_attr) + + if type(coverage) is not list: + # Normal path, process one coverage that might have a parallel list + parallel_list = None + if self.parallel_list_attr: + parallel_list = _get_dotted_attr(value, self.parallel_list_attr) + assert ( + type(parallel_list) is list + ), f"{self.parallel_list_attr} should be a list" + assert len(parallel_list) == len(coverage.glyphs), "Nothing makes sense" + + _sort_by_gid(font.getGlyphID, coverage.glyphs, parallel_list) + + else: + # A few tables have a list of coverage. No parallel list can exist. + assert ( + not self.parallel_list_attr + ), f"Can't have multiple coverage AND a parallel list; {self}" + for coverage_entry in coverage: + _sort_by_gid(font.getGlyphID, coverage_entry.glyphs, None) + + +@dataclass(frozen=True) +class ReorderList(ReorderRule): + """Reorder the items within a list to match the updated glyph order. + + Useful when a list ordered by coverage itself contains something ordered by a gid. + For example, the PairSet table of https://docs.microsoft.com/en-us/typography/opentype/spec/gpos#lookup-type-2-pair-adjustment-positioning-subtable. + """ + + list_attr: str + key: str + + def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: + lst = _get_dotted_attr(value, self.list_attr) + assert isinstance(lst, list), f"{self.list_attr} should be a list" + lst.sort(key=lambda v: font.getGlyphID(getattr(v, self.key))) + + +# (Type, Optional Format) => List[ReorderRule] +# Encodes the relationships Cosimo identified +_REORDER_RULES = { + # GPOS + (ot.SinglePos, 1): [ReorderCoverage()], + (ot.SinglePos, 2): [ReorderCoverage(parallel_list_attr="Value")], + (ot.PairPos, 1): [ReorderCoverage(parallel_list_attr="PairSet")], + (ot.PairSet, None): [ReorderList("PairValueRecord", key="SecondGlyph")], + (ot.PairPos, 2): [ReorderCoverage()], + (ot.CursivePos, 1): [ReorderCoverage(parallel_list_attr="EntryExitRecord")], + (ot.MarkBasePos, 1): [ + ReorderCoverage( + coverage_attr="MarkCoverage", parallel_list_attr="MarkArray.MarkRecord" + ), + ReorderCoverage( + coverage_attr="BaseCoverage", parallel_list_attr="BaseArray.BaseRecord" + ), + ], + (ot.MarkLigPos, 1): [ + ReorderCoverage( + coverage_attr="MarkCoverage", parallel_list_attr="MarkArray.MarkRecord" + ), + ReorderCoverage( + coverage_attr="LigatureCoverage", + parallel_list_attr="LigatureArray.LigatureAttach", + ), + ], + (ot.MarkMarkPos, 1): [ + ReorderCoverage( + coverage_attr="Mark1Coverage", parallel_list_attr="Mark1Array.MarkRecord" + ), + ReorderCoverage( + coverage_attr="Mark2Coverage", parallel_list_attr="Mark2Array.Mark2Record" + ), + ], + (ot.ContextPos, 1): [ReorderCoverage(parallel_list_attr="PosRuleSet")], + (ot.ContextPos, 2): [ReorderCoverage()], + (ot.ContextPos, 3): [ReorderCoverage()], + (ot.ChainContextPos, 1): [ReorderCoverage(parallel_list_attr="ChainPosRuleSet")], + (ot.ChainContextPos, 2): [ReorderCoverage()], + (ot.ChainContextPos, 3): [ + ReorderCoverage(coverage_attr="BacktrackCoverage"), + ReorderCoverage(coverage_attr="InputCoverage"), + ReorderCoverage(coverage_attr="LookAheadCoverage"), + ], + # GSUB + (ot.ContextSubst, 1): [ReorderCoverage(parallel_list_attr="SubRuleSet")], + (ot.ContextSubst, 2): [ReorderCoverage()], + (ot.ContextSubst, 3): [ReorderCoverage()], + (ot.ChainContextSubst, 1): [ReorderCoverage(parallel_list_attr="ChainSubRuleSet")], + (ot.ChainContextSubst, 2): [ReorderCoverage()], + (ot.ChainContextSubst, 3): [ + ReorderCoverage(coverage_attr="BacktrackCoverage"), + ReorderCoverage(coverage_attr="InputCoverage"), + ReorderCoverage(coverage_attr="LookAheadCoverage"), + ], + (ot.ReverseChainSingleSubst, 1): [ + ReorderCoverage(parallel_list_attr="Substitute"), + ReorderCoverage(coverage_attr="BacktrackCoverage"), + ReorderCoverage(coverage_attr="LookAheadCoverage"), + ], + # GDEF + (ot.AttachList, None): [ReorderCoverage(parallel_list_attr="AttachPoint")], + (ot.LigCaretList, None): [ReorderCoverage(parallel_list_attr="LigGlyph")], + (ot.MarkGlyphSetsDef, None): [ReorderCoverage()], + # MATH + (ot.MathGlyphInfo, None): [ReorderCoverage(coverage_attr="ExtendedShapeCoverage")], + (ot.MathItalicsCorrectionInfo, None): [ + ReorderCoverage(parallel_list_attr="ItalicsCorrection") + ], + (ot.MathTopAccentAttachment, None): [ + ReorderCoverage( + coverage_attr="TopAccentCoverage", parallel_list_attr="TopAccentAttachment" + ) + ], + (ot.MathKernInfo, None): [ + ReorderCoverage( + coverage_attr="MathKernCoverage", parallel_list_attr="MathKernInfoRecords" + ) + ], + (ot.MathVariants, None): [ + ReorderCoverage( + coverage_attr="VertGlyphCoverage", + parallel_list_attr="VertGlyphConstruction", + ), + ReorderCoverage( + coverage_attr="HorizGlyphCoverage", + parallel_list_attr="HorizGlyphConstruction", + ), + ], +} + + +# TODO Port to otTraverse + +SubTablePath = Tuple[otBase.BaseTable.SubTableEntry, ...] + + +def _bfs_base_table( + root: otBase.BaseTable, root_accessor: str +) -> Iterable[SubTablePath]: + yield from _traverse_ot_data( + root, root_accessor, lambda frontier, new: frontier.extend(new) + ) + + +# Given f(current frontier, new entries) add new entries to frontier +AddToFrontierFn = Callable[[Deque[SubTablePath], List[SubTablePath]], None] + + +def _traverse_ot_data( + root: otBase.BaseTable, root_accessor: str, add_to_frontier_fn: AddToFrontierFn +) -> Iterable[SubTablePath]: + # no visited because general otData is forward-offset only and thus cannot cycle + + frontier: Deque[SubTablePath] = deque() + frontier.append((otBase.BaseTable.SubTableEntry(root_accessor, root),)) + while frontier: + # path is (value, attr_name) tuples. attr_name is attr of parent to get value + path = frontier.popleft() + current = path[-1].value + + yield path + + new_entries = [] + for subtable_entry in current.iterSubTables(): + new_entries.append(path + (subtable_entry,)) + + add_to_frontier_fn(frontier, new_entries) + + +def reorderGlyphs(font: ttLib.TTFont, new_glyph_order: List[str]): + old_glyph_order = font.getGlyphOrder() + if len(new_glyph_order) != len(old_glyph_order): + raise ValueError( + f"New glyph order contains {len(new_glyph_order)} glyphs, " + f"but font has {len(old_glyph_order)} glyphs" + ) + + if set(old_glyph_order) != set(new_glyph_order): + raise ValueError( + "New glyph order does not contain the same set of glyphs as the font:\n" + f"* only in new: {set(new_glyph_order) - set(old_glyph_order)}\n" + f"* only in old: {set(old_glyph_order) - set(new_glyph_order)}" + ) + + # Changing the order of glyphs in a TTFont requires that all tables that use + # glyph indexes have been fully. + # Cf. https://github.com/fonttools/fonttools/issues/2060 + font.ensureDecompiled() + not_loaded = sorted(t for t in font.keys() if not font.isLoaded(t)) + if not_loaded: + raise ValueError(f"Everything should be loaded, following aren't: {not_loaded}") + + font.setGlyphOrder(new_glyph_order) + + coverage_containers = {"GDEF", "GPOS", "GSUB", "MATH"} + for tag in coverage_containers: + if tag in font.keys(): + for path in _bfs_base_table(font[tag].table, f'font["{tag}"]'): + value = path[-1].value + reorder_key = (type(value), getattr(value, "Format", None)) + for reorder in _REORDER_RULES.get(reorder_key, []): + reorder.apply(font, value) diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py index ad62a187d..52e048b5f 100644 --- a/Lib/fontTools/ttLib/ttFont.py +++ b/Lib/fontTools/ttLib/ttFont.py @@ -840,6 +840,11 @@ class TTFont(object): """ return self["cmap"].getBestCmap(cmapPreferences=cmapPreferences) + def reorderGlyphs(self, new_glyph_order): + from .reorderGlyphs import reorderGlyphs + + reorderGlyphs(self, new_glyph_order) + class GlyphOrder(object): """A pseudo table. The glyph order isn't in the font as a separate