Spaces:
Sleeping
Sleeping
"""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.""" | |
def apply(self, font: ttLib.TTFont, value: otBase.BaseTable) -> None: ... | |
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) | |
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) | |