Skip to content

Commit

Permalink
Merge pull request #996 from googlefonts/transfer-hints
Browse files Browse the repository at this point in the history
transfer_vtt_hints added
  • Loading branch information
m4rc1e authored Jul 2, 2024
2 parents 0fb8ab7 + 3dd6c87 commit f30c37a
Showing 1 changed file with 270 additions and 0 deletions.
270 changes: 270 additions & 0 deletions Lib/gftools/scripts/transfer_vtt_hints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
"""gftools transfer_vtt_hintgs
Match nodes by euclidean distance, instead of node index
Usage:
gftools transfer-vtt-hints src.ttf dst.ttf
"""

from pyparsing import (
Word,
alphas,
Suppress,
delimitedList,
nums,
Group,
ZeroOrMore,
Optional,
cppStyleComment,
Literal,
)
from fontTools.ttLib import TTFont
from fontTools.misc.cliTools import makeOutputFileName
from copy import deepcopy
import argparse
from types import SimpleNamespace


__all__ = ["transfer_hints"]


# TSI3 parser
tsi3_func_name = Word(alphas) # Function name consists of alphabetic characters
integer = Word(nums).setParseAction(
lambda t: int(t[0])
) # Define integers and convert them to int
tsi3_args = (
Suppress("(") + Optional(delimitedList(integer)) + Suppress(")")
) # Arguments within parentheses, optional for functions with no arguments

# Combine the grammar to define a function call, ensuring each call is grouped
tsi3_function_call = Group(tsi3_func_name("name") + tsi3_args("args"))

# Define a grammar for multiple function calls, ignoring comments
tsi3_parser = ZeroOrMore(tsi3_function_call)
tsi3_parser.ignore(cppStyleComment)


# TSI1 parser
tsi1_func_name = Word(
alphas + "[]>=" + nums
) # Function name consists of alphabetic characters
comma = Literal(",") # Comma separator
tsi1_args = Suppress(Optional(comma)) + Optional(
delimitedList(integer)
) # Arguments within parentheses, optional for functions with no arguments

# Combine the grammar to define a function call, ensuring each call is grouped
tsi1_function_call = Group(tsi1_func_name("name") + tsi1_args("args"))

# Define a grammar for multiple function calls, ignoring comments
tsi1_parser = ZeroOrMore(tsi1_function_call)
tsi1_parser.ignore(cppStyleComment)


def _glyph_index_map(source_glyph, source_glyphset, target_glyph, target_glyphset):
# Euclidiean node matching
res = {}
seen = set()
for idx_a, [x_a, y_a] in enumerate(source_glyph.getCoordinates(source_glyphset)[0]):
res[idx_a] = (float("inf"), float("inf"))
for idx_b, (x_b, y_b) in enumerate(
target_glyph.getCoordinates(target_glyphset)[0]
):
if idx_b in seen:
continue
distance = ((x_a - x_b) ** 2 + (y_a - y_b) ** 2) ** 0.5
if distance < res[idx_a][0]:
res[idx_a] = (distance, idx_b)
key = res[idx_a][1]
seen.add(key)
return {idx_a: idx_b for idx_a, (_, idx_b) in res.items()}


def _update_tsi3(instructions: list[SimpleNamespace], glyph_map):
# Instruction node positions
NODE_FUNCTION_ARGUMENTS = {
"ResYAnchor": [0],
"ResYDist": [0, 1],
"YShift": [0, 1],
"YAnchor": [0],
"YInterpolate": [0, 1, 2],
"YDist": [0, 1],
"YDelta": [0, 1],
"YDownToGrid": [0],
"YIPAnchor": [0, 1, 2],
"YLink": [0, 1],
"YUpToGrid": [0],
}

res = []
for instruction in instructions:
if instruction.name in NODE_FUNCTION_ARGUMENTS:
for idx in NODE_FUNCTION_ARGUMENTS[instruction.name]:
instruction.args[idx] = glyph_map[instruction.args[idx]]
res.append(instruction)
return res


def _tsi3_to_string(instructions: list[SimpleNamespace]):
res = []
for instruction in instructions:
res.append(instruction.name + "(" + ",".join(map(str, instruction.args)) + ")")
return "\n".join(res)


def _update_tsi1(instructions: list[SimpleNamespace], gid_map, glyf):
new_instructions = [
i for i in instructions if i.name != "OFFSET[R]" if i.name != "SVTCA[Y]"
]
if instructions[0].name != "USEMYMETRICS[]":
comp_pos = 0
else:
comp_pos = 1
for component in glyf.components:
new_instructions.insert(
comp_pos,
SimpleNamespace(
name="OFFSET[R]",
args=[gid_map[component.glyphName], component.x, component.y],
),
)
comp_pos += 1
if new_instructions[-1].name == "USEMYMETRICS[]":
new_instructions.pop()
return new_instructions


def transfer_tsi3(source_font: TTFont, target_font: TTFont, glyph_name: str):
existing_program = source_font["TSI3"].glyphPrograms[glyph_name]
glyph_map = _glyph_index_map(
source_font["glyf"][glyph_name],
source_font["glyf"],
target_font["glyf"][glyph_name],
target_font["glyf"],
)
if any(v == float("inf") for k, v in glyph_map.items()):
target_font["TSI3"].glyphPrograms[glyph_name] = ""
return glyph_name
glyph_instructions = tsi3_parser.parseString(existing_program)
updated_instructions = _update_tsi3(glyph_instructions, glyph_map)
target_font["TSI3"].glyphPrograms[glyph_name] = _tsi3_to_string(
updated_instructions
)
return None


def _tsi1_to_string(instructions: list[SimpleNamespace]):
res = []
for instruction in instructions:
if len(instruction.args) == 0:
res.append(instruction.name)
else:
res.append(instruction.name + "," + ",".join(map(str, instruction.args)))
return "\n".join(res)


def transfer_tsi1(source_font: TTFont, target_font: TTFont, glyph_name: str):
existing_program = source_font["TSI1"].glyphPrograms[glyph_name]
target_glyph_order = {
name: idx for idx, name in enumerate(target_font.getGlyphOrder())
}

glyph_instructions = tsi1_parser.parseString(existing_program)
glyph = target_font["glyf"][glyph_name]
updated_instructions = _update_tsi1(
glyph_instructions, target_glyph_order, target_font["glyf"][glyph_name]
)
target_font["TSI1"].glyphPrograms[glyph_name] = _tsi1_to_string(
updated_instructions
)


def printer(msg, items):
items = sorted(items, key=lambda x: x[0])
item_list = "\n".join([f"{idx},{name}" for idx, name in items])
print(f"{msg}:\nGID,Glyph_Name:\n{item_list}\n")


def transfer_hints(source_font: TTFont, target_font: TTFont):
# transfer TSI3 (VTT talk glyph instructions)
target_gid = {name: idx for idx, name in enumerate(target_font.getGlyphOrder())}
matched_glyphs = source_font.getGlyphSet().keys() & target_font.getGlyphSet().keys()
unmatched_glyphs = (
target_font.getGlyphSet().keys() - source_font.getGlyphSet().keys()
)
unmatched_glyphs = set((target_gid[g], g) for g in unmatched_glyphs)
for tbl in ("TSI1", "TSI3"):
if tbl not in source_font:
raise ValueError(f"Source font does not have {tbl} table")
target_font[tbl] = deepcopy(source_font[tbl])

missing_hints = set()
for glyph_name in matched_glyphs:
source_is_composite = source_font["glyf"][glyph_name].isComposite()
target_is_composite = target_font["glyf"][glyph_name].isComposite()
if source_is_composite and target_is_composite:
transfer_tsi1(source_font, target_font, glyph_name)
elif source_is_composite and not target_is_composite:
missing_hints.add((target_gid[glyph_name], glyph_name))
target_font["TSI1"].glyphPrograms[glyph_name] = ""
target_font["TSI3"].glyphPrograms[glyph_name] = ""
elif not source_is_composite and target_is_composite:
target_font["TSI1"].glyphPrograms[glyph_name] = ""
target_font["TSI3"].glyphPrograms[glyph_name] = ""
missing_hints.add((target_gid[glyph_name], glyph_name))
elif glyph_name in source_font["TSI3"].glyphPrograms:
failed_glyph = transfer_tsi3(source_font, target_font, glyph_name)
if failed_glyph:
missing_hints.add((target_gid[failed_glyph], failed_glyph))
else:
missing_hints.add((target_gid[glyph_name], glyph_name))

if unmatched_glyphs:
printer("Following glyphs are new", unmatched_glyphs)
if missing_hints:
printer(
"Following glyphs are missing hints, have changed from components to outlines, or points differ too much",
missing_hints,
)

# copy over other hinting tables
for tbl in ("TSI0", "TSI2", "TSI5", "fpgm", "prep", "TSIC", "maxp", "cvt "):
target_font[tbl] = deepcopy(source_font[tbl])

transferred = len(matched_glyphs) - len(missing_hints)
total = len(target_font.getGlyphSet().keys())
print(f"Transferred {transferred}/{total} glyphs")
print("Please still check glyphs look good on Windows platforms")


def main():
parser = argparse.ArgumentParser(description="Transfer VTT hints between two fonts")
parser.add_argument("source", type=str, help="Source font file")
parser.add_argument("target", type=str, help="Target font file")
output = parser.add_mutually_exclusive_group(required=False)
output.add_argument("-o", "--out", type=str, help="Output file")
output.add_argument(
"-i", "--inplace", action="store_true", help="Inplace modification"
)
args = parser.parse_args()

source_font = TTFont(args.source)
target_font = TTFont(args.target)

transfer_hints(source_font, target_font)

if args.inplace:
target_font.save(args.target)
elif args.out:
target_font.save(args.out)
else:
fp = makeOutputFileName(
args.target, outputDir=None, extension=None, overWrite=False
)
target_font.save(fp)


if __name__ == "__main__":
main()

0 comments on commit f30c37a

Please sign in to comment.