|
"""Reorder glyphs in a font.""" |
|
|
|
__author__ = "Rod Sheeter" |
|
|
|
|
|
|
|
|
|
|
|
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" |
|
|
|
|
|
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.""" |
|
|
|
|
|
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: |
|
|
|
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: |
|
|
|
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))) |
|
|
|
|
|
|
|
|
|
_REORDER_RULES = { |
|
|
|
(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"), |
|
], |
|
|
|
(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"), |
|
], |
|
|
|
(ot.AttachList, None): [ReorderCoverage(parallel_list_attr="AttachPoint")], |
|
(ot.LigCaretList, None): [ReorderCoverage(parallel_list_attr="LigGlyph")], |
|
(ot.MarkGlyphSetsDef, None): [ReorderCoverage()], |
|
|
|
(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", |
|
), |
|
], |
|
} |
|
|
|
|
|
|
|
|
|
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) |
|
) |
|
|
|
|
|
|
|
AddToFrontierFn = Callable[[Deque[SubTablePath], List[SubTablePath]], None] |
|
|
|
|
|
def _traverse_ot_data( |
|
root: otBase.BaseTable, root_accessor: str, add_to_frontier_fn: AddToFrontierFn |
|
) -> Iterable[SubTablePath]: |
|
|
|
|
|
frontier: Deque[SubTablePath] = deque() |
|
frontier.append((otBase.BaseTable.SubTableEntry(root_accessor, root),)) |
|
while frontier: |
|
|
|
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)}" |
|
) |
|
|
|
|
|
|
|
|
|
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) |
|
|