Merge pull request #2447 from fonttools/merge-cff-rebased

Merge CFF rebased
This commit is contained in:
Khaled Hosny 2021-11-19 14:20:26 +02:00 committed by GitHub
commit 0a7164a452
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 60460 additions and 120 deletions

View File

@ -38,6 +38,85 @@ maxStackLimit = 513
# maxstack operator has been deprecated. max stack is now always 513. # maxstack operator has been deprecated. max stack is now always 513.
class StopHintCountEvent(Exception):
pass
class _DesubroutinizingT2Decompiler(psCharStrings.SimpleT2Decompiler):
stop_hintcount_ops = ("op_hintmask", "op_cntrmask", "op_rmoveto", "op_hmoveto",
"op_vmoveto")
def __init__(self, localSubrs, globalSubrs, private=None):
psCharStrings.SimpleT2Decompiler.__init__(self, localSubrs, globalSubrs,
private)
def execute(self, charString):
self.need_hintcount = True # until proven otherwise
for op_name in self.stop_hintcount_ops:
setattr(self, op_name, self.stop_hint_count)
if hasattr(charString, '_desubroutinized'):
# If a charstring has already been desubroutinized, we will still
# need to execute it if we need to count hints in order to
# compute the byte length for mask arguments, and haven't finished
# counting hints pairs.
if self.need_hintcount and self.callingStack:
try:
psCharStrings.SimpleT2Decompiler.execute(self, charString)
except StopHintCountEvent:
del self.callingStack[-1]
return
charString._patches = []
psCharStrings.SimpleT2Decompiler.execute(self, charString)
desubroutinized = charString.program[:]
for idx, expansion in reversed(charString._patches):
assert idx >= 2
assert desubroutinized[idx - 1] in ['callsubr', 'callgsubr'], desubroutinized[idx - 1]
assert type(desubroutinized[idx - 2]) == int
if expansion[-1] == 'return':
expansion = expansion[:-1]
desubroutinized[idx-2:idx] = expansion
if not self.private.in_cff2:
if 'endchar' in desubroutinized:
# Cut off after first endchar
desubroutinized = desubroutinized[:desubroutinized.index('endchar') + 1]
else:
if not len(desubroutinized) or desubroutinized[-1] != 'return':
desubroutinized.append('return')
charString._desubroutinized = desubroutinized
del charString._patches
def op_callsubr(self, index):
subr = self.localSubrs[self.operandStack[-1]+self.localBias]
psCharStrings.SimpleT2Decompiler.op_callsubr(self, index)
self.processSubr(index, subr)
def op_callgsubr(self, index):
subr = self.globalSubrs[self.operandStack[-1]+self.globalBias]
psCharStrings.SimpleT2Decompiler.op_callgsubr(self, index)
self.processSubr(index, subr)
def stop_hint_count(self, *args):
self.need_hintcount = False
for op_name in self.stop_hintcount_ops:
setattr(self, op_name, None)
cs = self.callingStack[-1]
if hasattr(cs, '_desubroutinized'):
raise StopHintCountEvent()
def op_hintmask(self, index):
psCharStrings.SimpleT2Decompiler.op_hintmask(self, index)
if self.need_hintcount:
self.stop_hint_count()
def processSubr(self, index, subr):
cs = self.callingStack[-1]
if not hasattr(cs, '_desubroutinized'):
cs._patches.append((index, subr._desubroutinized))
class CFFFontSet(object): class CFFFontSet(object):
"""A CFF font "file" can contain more than one font, although this is """A CFF font "file" can contain more than one font, although this is
extremely rare (and not allowed within OpenType fonts). extremely rare (and not allowed within OpenType fonts).
@ -368,6 +447,35 @@ class CFFFontSet(object):
file.seek(0) file.seek(0)
self.decompile(file, otFont, isCFF2=True) self.decompile(file, otFont, isCFF2=True)
def desubroutinize(self):
for fontName in self.fontNames:
font = self[fontName]
cs = font.CharStrings
for g in font.charset:
c, _ = cs.getItemAndSelector(g)
c.decompile()
subrs = getattr(c.private, "Subrs", [])
decompiler = _DesubroutinizingT2Decompiler(subrs, c.globalSubrs, c.private)
decompiler.execute(c)
c.program = c._desubroutinized
del c._desubroutinized
# Delete all the local subrs
if hasattr(font, 'FDArray'):
for fd in font.FDArray:
pd = fd.Private
if hasattr(pd, 'Subrs'):
del pd.Subrs
if 'Subrs' in pd.rawDict:
del pd.rawDict['Subrs']
else:
pd = font.Private
if hasattr(pd, 'Subrs'):
del pd.Subrs
if 'Subrs' in pd.rawDict:
del pd.rawDict['Subrs']
# as well as the global subrs
self.GlobalSubrs.clear()
class CFFWriter(object): class CFFWriter(object):
"""Helper class for serializing CFF data to binary. Used by """Helper class for serializing CFF data to binary. Used by

View File

@ -13,6 +13,7 @@ import sys
import time import time
import operator import operator
import logging import logging
import os
log = logging.getLogger("fontTools.merge") log = logging.getLogger("fontTools.merge")
@ -200,7 +201,7 @@ ttLib.getTableClass('head').mergeMap = {
'macStyle': first, 'macStyle': first,
'lowestRecPPEM': max, 'lowestRecPPEM': max,
'fontDirectionHint': lambda lst: 2, 'fontDirectionHint': lambda lst: 2,
'indexToLocFormat': recalculate, 'indexToLocFormat': first,
'glyphDataFormat': equal, 'glyphDataFormat': equal,
} }
@ -371,6 +372,51 @@ ttLib.getTableClass('fpgm').mergeMap = lambda self, lst: first(lst)
ttLib.getTableClass('cvt ').mergeMap = lambda self, lst: first(lst) ttLib.getTableClass('cvt ').mergeMap = lambda self, lst: first(lst)
ttLib.getTableClass('gasp').mergeMap = lambda self, lst: first(lst) # FIXME? Appears irreconcilable ttLib.getTableClass('gasp').mergeMap = lambda self, lst: first(lst) # FIXME? Appears irreconcilable
@_add_method(ttLib.getTableClass('CFF '))
def merge(self, m, tables):
if any(hasattr(table, "FDSelect") for table in tables):
raise NotImplementedError(
"Merging CID-keyed CFF tables is not supported yet"
)
newcff = tables[0]
newfont = newcff.cff[0]
private = newfont.Private
storedNamesStrings = []
glyphOrderStrings = []
glyphOrder = set(newfont.getGlyphOrder())
for name in newfont.strings.strings:
if name not in glyphOrder:
storedNamesStrings.append(name)
else:
glyphOrderStrings.append(name)
chrset = list(newfont.charset)
newcs = newfont.CharStrings
log.debug("FONT 0 CharStrings: %d.", len(newcs))
for i, table in enumerate(tables[1:], start=1):
font = table.cff[0]
font.Private = private
fontGlyphOrder = set(font.getGlyphOrder())
for name in font.strings.strings:
if name in fontGlyphOrder:
glyphOrderStrings.append(name)
cs = font.CharStrings
gs = table.cff.GlobalSubrs
log.debug("Font %d CharStrings: %d.", i, len(cs))
chrset.extend(font.charset)
if newcs.charStringsAreIndexed:
for i, name in enumerate(cs.charStrings, start=len(newcs)):
newcs.charStrings[name] = i
newcs.charStringsIndex.items.append(None)
for name in cs.charStrings:
newcs[name] = cs[name]
newfont.charset = chrset
newfont.numGlyphs = len(chrset)
newfont.strings.strings = glyphOrderStrings + storedNamesStrings
return newcff
def _glyphsAreSame(glyphSet1, glyphSet2, glyph1, glyph2): def _glyphsAreSame(glyphSet1, glyphSet2, glyph1, glyph2):
pen1 = DecomposingRecordingPen(glyphSet1) pen1 = DecomposingRecordingPen(glyphSet1)
pen2 = DecomposingRecordingPen(glyphSet2) pen2 = DecomposingRecordingPen(glyphSet2)
@ -865,9 +911,10 @@ class Options(object):
op = k[-1]+'=' # Ops is '-=' or '+=' now. op = k[-1]+'=' # Ops is '-=' or '+=' now.
k = k[:-1] k = k[:-1]
v = a[i+1:] v = a[i+1:]
ok = k
k = k.replace('-', '_') k = k.replace('-', '_')
if not hasattr(self, k): if not hasattr(self, k):
if ignore_unknown is True or k in ignore_unknown: if ignore_unknown is True or ok in ignore_unknown:
ret.append(orig_a) ret.append(orig_a)
continue continue
else: else:
@ -993,20 +1040,35 @@ class Merger(object):
A :class:`fontTools.ttLib.TTFont` object. Call the ``save`` method on A :class:`fontTools.ttLib.TTFont` object. Call the ``save`` method on
this to write it out to an OTF file. this to write it out to an OTF file.
""" """
mega = ttLib.TTFont()
# #
# Settle on a mega glyph order. # Settle on a mega glyph order.
# #
fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
glyphOrders = [font.getGlyphOrder() for font in fonts] glyphOrders = [font.getGlyphOrder() for font in fonts]
megaGlyphOrder = self._mergeGlyphOrders(glyphOrders) megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
# Take first input file sfntVersion
sfntVersion = fonts[0].sfntVersion
cffTables = [None] * len(fonts)
if sfntVersion == "OTTO":
for i, font in enumerate(fonts):
font['CFF '].cff.desubroutinize()
cffTables[i] = font['CFF ']
# Reload fonts and set new glyph names on them. # Reload fonts and set new glyph names on them.
# TODO Is it necessary to reload font? I think it is. At least # TODO Is it necessary to reload font? I think it is. At least
# it's safer, in case tables were loaded to provide glyph names. # it's safer, in case tables were loaded to provide glyph names.
fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
for font,glyphOrder in zip(fonts, glyphOrders): for font, glyphOrder, cffTable in zip(fonts, glyphOrders, cffTables):
font.setGlyphOrder(glyphOrder) font.setGlyphOrder(glyphOrder)
if cffTable:
# Rename CFF CharStrings to match the new glyphOrder.
# Using cffTable from before reloading the fonts, because reasons.
self._renameCFFCharStrings(glyphOrder, cffTable)
font['CFF '] = cffTable
mega = ttLib.TTFont(sfntVersion=sfntVersion)
mega.setGlyphOrder(megaGlyphOrder) mega.setGlyphOrder(megaGlyphOrder)
for font in fonts: for font in fonts:
@ -1064,6 +1126,15 @@ class Merger(object):
mega[glyphName] = 1 mega[glyphName] = 1
return list(mega.keys()) return list(mega.keys())
def _renameCFFCharStrings(self, glyphOrder, cffTable):
"""Rename topDictIndex charStrings based on glyphOrder."""
td = cffTable.cff.topDictIndex[0]
charStrings = {}
for i, v in enumerate(td.CharStrings.charStrings.values()):
glyphName = glyphOrder[i]
charStrings[glyphName] = v
cffTable.cff.topDictIndex[0].CharStrings.charStrings = charStrings
def mergeObjects(self, returnTable, logic, tables): def mergeObjects(self, returnTable, logic, tables):
# Right now we don't use self at all. Will use in the future # Right now we don't use self at all. Will use in the future
# for options and logging. # for options and logging.
@ -1182,7 +1253,14 @@ def main(args=None):
args = sys.argv[1:] args = sys.argv[1:]
options = Options() options = Options()
args = options.parse_opts(args) args = options.parse_opts(args, ignore_unknown=['output-file'])
outfile = 'merged.ttf'
fontfiles = []
for g in args:
if g.startswith('--output-file='):
outfile = g[14:]
continue
fontfiles.append(g)
if len(args) < 1: if len(args) < 1:
print("usage: pyftmerge font...", file=sys.stderr) print("usage: pyftmerge font...", file=sys.stderr)
@ -1195,8 +1273,7 @@ def main(args=None):
timer.logger.disabled = True timer.logger.disabled = True
merger = Merger(options=options) merger = Merger(options=options)
font = merger.merge(args) font = merger.merge(fontfiles)
outfile = 'merged.ttf'
with timer("compile and save font"): with timer("compile and save font"):
font.save(outfile) font.save(outfile)

View File

@ -2,6 +2,7 @@ from fontTools.misc import psCharStrings
from fontTools import ttLib from fontTools import ttLib
from fontTools.pens.basePen import NullPen from fontTools.pens.basePen import NullPen
from fontTools.misc.roundTools import otRound from fontTools.misc.roundTools import otRound
from fontTools.misc.loggingTools import deprecateFunction
from fontTools.varLib.varStore import VarStoreInstancer from fontTools.varLib.varStore import VarStoreInstancer
from fontTools.subset.util import _add_method, _uniq_sort from fontTools.subset.util import _add_method, _uniq_sort
@ -345,86 +346,6 @@ class _DehintingT2Decompiler(psCharStrings.T2WidthExtractor):
hints.status = max(hints.status, subr_hints.status) hints.status = max(hints.status, subr_hints.status)
class StopHintCountEvent(Exception):
pass
class _DesubroutinizingT2Decompiler(psCharStrings.SimpleT2Decompiler):
stop_hintcount_ops = ("op_hintmask", "op_cntrmask", "op_rmoveto", "op_hmoveto",
"op_vmoveto")
def __init__(self, localSubrs, globalSubrs, private=None):
psCharStrings.SimpleT2Decompiler.__init__(self, localSubrs, globalSubrs,
private)
def execute(self, charString):
self.need_hintcount = True # until proven otherwise
for op_name in self.stop_hintcount_ops:
setattr(self, op_name, self.stop_hint_count)
if hasattr(charString, '_desubroutinized'):
# If a charstring has already been desubroutinized, we will still
# need to execute it if we need to count hints in order to
# compute the byte length for mask arguments, and haven't finished
# counting hints pairs.
if self.need_hintcount and self.callingStack:
try:
psCharStrings.SimpleT2Decompiler.execute(self, charString)
except StopHintCountEvent:
del self.callingStack[-1]
return
charString._patches = []
psCharStrings.SimpleT2Decompiler.execute(self, charString)
desubroutinized = charString.program[:]
for idx, expansion in reversed(charString._patches):
assert idx >= 2
assert desubroutinized[idx - 1] in ['callsubr', 'callgsubr'], desubroutinized[idx - 1]
assert type(desubroutinized[idx - 2]) == int
if expansion[-1] == 'return':
expansion = expansion[:-1]
desubroutinized[idx-2:idx] = expansion
if not self.private.in_cff2:
if 'endchar' in desubroutinized:
# Cut off after first endchar
desubroutinized = desubroutinized[:desubroutinized.index('endchar') + 1]
else:
if not len(desubroutinized) or desubroutinized[-1] != 'return':
desubroutinized.append('return')
charString._desubroutinized = desubroutinized
del charString._patches
def op_callsubr(self, index):
subr = self.localSubrs[self.operandStack[-1]+self.localBias]
psCharStrings.SimpleT2Decompiler.op_callsubr(self, index)
self.processSubr(index, subr)
def op_callgsubr(self, index):
subr = self.globalSubrs[self.operandStack[-1]+self.globalBias]
psCharStrings.SimpleT2Decompiler.op_callgsubr(self, index)
self.processSubr(index, subr)
def stop_hint_count(self, *args):
self.need_hintcount = False
for op_name in self.stop_hintcount_ops:
setattr(self, op_name, None)
cs = self.callingStack[-1]
if hasattr(cs, '_desubroutinized'):
raise StopHintCountEvent()
def op_hintmask(self, index):
psCharStrings.SimpleT2Decompiler.op_hintmask(self, index)
if self.need_hintcount:
self.stop_hint_count()
def processSubr(self, index, subr):
cs = self.callingStack[-1]
if not hasattr(cs, '_desubroutinized'):
cs._patches.append((index, subr._desubroutinized))
@_add_method(ttLib.getTableClass('CFF ')) @_add_method(ttLib.getTableClass('CFF '))
def prune_post_subset(self, ttfFont, options): def prune_post_subset(self, ttfFont, options):
@ -444,7 +365,7 @@ def prune_post_subset(self, ttfFont, options):
# Desubroutinize if asked for # Desubroutinize if asked for
if options.desubroutinize: if options.desubroutinize:
self.desubroutinize() cff.desubroutinize()
# Drop hints if not needed # Drop hints if not needed
if not options.hinting: if not options.hinting:
@ -460,36 +381,11 @@ def _delete_empty_subrs(private_dict):
del private_dict.rawDict['Subrs'] del private_dict.rawDict['Subrs']
del private_dict.Subrs del private_dict.Subrs
@deprecateFunction("use 'CFFFontSet.desubroutinize()' instead", category=DeprecationWarning)
@_add_method(ttLib.getTableClass('CFF ')) @_add_method(ttLib.getTableClass('CFF '))
def desubroutinize(self): def desubroutinize(self):
cff = self.cff self.cff.desubroutinize()
for fontname in cff.keys():
font = cff[fontname]
cs = font.CharStrings
for g in font.charset:
c, _ = cs.getItemAndSelector(g)
c.decompile()
subrs = getattr(c.private, "Subrs", [])
decompiler = _DesubroutinizingT2Decompiler(subrs, c.globalSubrs, c.private)
decompiler.execute(c)
c.program = c._desubroutinized
del c._desubroutinized
# Delete all the local subrs
if hasattr(font, 'FDArray'):
for fd in font.FDArray:
pd = fd.Private
if hasattr(pd, 'Subrs'):
del pd.Subrs
if 'Subrs' in pd.rawDict:
del pd.rawDict['Subrs']
else:
pd = font.Private
if hasattr(pd, 'Subrs'):
del pd.Subrs
if 'Subrs' in pd.rawDict:
del pd.rawDict['Subrs']
# as well as the global subrs
cff.GlobalSubrs.clear()
@_add_method(ttLib.getTableClass('CFF ')) @_add_method(ttLib.getTableClass('CFF '))

23445
Tests/merge/data/CFFFont1.ttx Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,14 +3,82 @@ import itertools
from fontTools import ttLib from fontTools import ttLib
from fontTools.ttLib.tables._g_l_y_f import Glyph from fontTools.ttLib.tables._g_l_y_f import Glyph
from fontTools.fontBuilder import FontBuilder from fontTools.fontBuilder import FontBuilder
from fontTools.merge import Merger from fontTools.merge import Merger, main as merge_main
import difflib
import os
import re
import shutil
import sys
import tempfile
import unittest import unittest
import pathlib
import pytest import pytest
class MergeIntegrationTest(unittest.TestCase): class MergeIntegrationTest(unittest.TestCase):
# TODO def setUp(self):
pass self.tempdir = None
self.num_tempfiles = 0
def tearDown(self):
if self.tempdir:
shutil.rmtree(self.tempdir)
@staticmethod
def getpath(testfile):
path, _ = os.path.split(__file__)
return os.path.join(path, "data", testfile)
def temp_path(self, suffix):
if not self.tempdir:
self.tempdir = tempfile.mkdtemp()
self.num_tempfiles += 1
return os.path.join(self.tempdir, "tmp%d%s" % (self.num_tempfiles, suffix))
IGNORED_LINES_RE = re.compile(
"^(<ttFont | <(checkSumAdjustment|created|modified) ).*"
)
def read_ttx(self, path):
lines = []
with open(path, "r", encoding="utf-8") as ttx:
for line in ttx.readlines():
# Elide lines with data that often change.
if self.IGNORED_LINES_RE.match(line):
lines.append("\n")
else:
lines.append(line.rstrip() + "\n")
return lines
def expect_ttx(self, font, expected_ttx, tables=None):
path = self.temp_path(suffix=".ttx")
font.saveXML(path, tables=tables)
actual = self.read_ttx(path)
expected = self.read_ttx(expected_ttx)
if actual != expected:
for line in difflib.unified_diff(
expected, actual, fromfile=expected_ttx, tofile=path):
sys.stdout.write(line)
self.fail("TTX output is different from expected")
def compile_font(self, path, suffix):
savepath = self.temp_path(suffix=suffix)
font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
font.importXML(path)
font.save(savepath, reorderTables=None)
return font, savepath
# -----
# Tests
# -----
def test_merge_cff(self):
_, fontpath1 = self.compile_font(self.getpath("CFFFont1.ttx"), ".otf")
_, fontpath2 = self.compile_font(self.getpath("CFFFont2.ttx"), ".otf")
mergedpath = self.temp_path(".otf")
merge_main([fontpath1, fontpath2, "--output-file=%s" % mergedpath])
mergedfont = ttLib.TTFont(mergedpath)
self.expect_ttx(mergedfont, self.getpath("CFFFont_expected.ttx"))
class gaspMergeUnitTest(unittest.TestCase): class gaspMergeUnitTest(unittest.TestCase):
def setUp(self): def setUp(self):