1import io
2import itertools
3from fontTools import ttLib
4from fontTools.ttLib.tables._g_l_y_f import Glyph
5from fontTools.fontBuilder import FontBuilder
6from fontTools.merge import Merger, main as merge_main
7import difflib
8import os
9import re
10import shutil
11import sys
12import tempfile
13import unittest
14import pathlib
15import pytest
16
17
18class MergeIntegrationTest(unittest.TestCase):
19	def setUp(self):
20		self.tempdir = None
21		self.num_tempfiles = 0
22
23	def tearDown(self):
24		if self.tempdir:
25			shutil.rmtree(self.tempdir)
26
27	@staticmethod
28	def getpath(testfile):
29		path, _ = os.path.split(__file__)
30		return os.path.join(path, "data", testfile)
31
32	def temp_path(self, suffix):
33		if not self.tempdir:
34			self.tempdir = tempfile.mkdtemp()
35		self.num_tempfiles += 1
36		return os.path.join(self.tempdir, "tmp%d%s" % (self.num_tempfiles, suffix))
37
38	IGNORED_LINES_RE = re.compile(
39		"^(<ttFont |    <(checkSumAdjustment|created|modified) ).*"
40	)
41	def read_ttx(self, path):
42		lines = []
43		with open(path, "r", encoding="utf-8") as ttx:
44			for line in ttx.readlines():
45				# Elide lines with data that often change.
46				if self.IGNORED_LINES_RE.match(line):
47					lines.append("\n")
48				else:
49					lines.append(line.rstrip() + "\n")
50		return lines
51
52	def expect_ttx(self, font, expected_ttx, tables=None):
53		path = self.temp_path(suffix=".ttx")
54		font.saveXML(path, tables=tables)
55		actual = self.read_ttx(path)
56		expected = self.read_ttx(expected_ttx)
57		if actual != expected:
58			for line in difflib.unified_diff(
59					expected, actual, fromfile=expected_ttx, tofile=path):
60				sys.stdout.write(line)
61			self.fail("TTX output is different from expected")
62
63	def compile_font(self, path, suffix):
64		savepath = self.temp_path(suffix=suffix)
65		font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
66		font.importXML(path)
67		font.save(savepath, reorderTables=None)
68		return font, savepath
69
70# -----
71# Tests
72# -----
73
74	def test_merge_cff(self):
75		_, fontpath1 = self.compile_font(self.getpath("CFFFont1.ttx"), ".otf")
76		_, fontpath2 = self.compile_font(self.getpath("CFFFont2.ttx"), ".otf")
77		mergedpath = self.temp_path(".otf")
78		merge_main([fontpath1, fontpath2, "--output-file=%s" % mergedpath])
79		mergedfont = ttLib.TTFont(mergedpath)
80		self.expect_ttx(mergedfont, self.getpath("CFFFont_expected.ttx"))
81
82
83class gaspMergeUnitTest(unittest.TestCase):
84	def setUp(self):
85		self.merger = Merger()
86
87		self.table1 = ttLib.newTable('gasp')
88		self.table1.version = 1
89		self.table1.gaspRange = {
90			0x8: 0xA ,
91			0x10: 0x5,
92		}
93
94		self.table2 = ttLib.newTable('gasp')
95		self.table2.version = 1
96		self.table2.gaspRange = {
97			0x6: 0xB ,
98			0xFF: 0x4,
99		}
100
101		self.result = ttLib.newTable('gasp')
102
103	def test_gasp_merge_basic(self):
104		result = self.result.merge(self.merger, [self.table1, self.table2])
105		self.assertEqual(result, self.table1)
106
107		result = self.result.merge(self.merger, [self.table2, self.table1])
108		self.assertEqual(result, self.table2)
109
110	def test_gasp_merge_notImplemented(self):
111		result = self.result.merge(self.merger, [NotImplemented, self.table1])
112		self.assertEqual(result, NotImplemented)
113
114		result = self.result.merge(self.merger, [self.table1, NotImplemented])
115		self.assertEqual(result, self.table1)
116
117
118class CmapMergeUnitTest(unittest.TestCase):
119	def setUp(self):
120		self.merger = Merger()
121		self.table1 = ttLib.newTable('cmap')
122		self.table2 = ttLib.newTable('cmap')
123		self.mergedTable = ttLib.newTable('cmap')
124		pass
125
126	def tearDown(self):
127		pass
128
129
130	def makeSubtable(self, format, platformID, platEncID, cmap):
131		module = ttLib.getTableModule('cmap')
132		subtable = module.cmap_classes[format](format)
133		(subtable.platformID,
134			subtable.platEncID,
135			subtable.language,
136			subtable.cmap) = (platformID, platEncID, 0, cmap)
137		return subtable
138
139	# 4-3-1 table merged with 12-3-10 table with no dupes with codepoints outside BMP
140	def test_cmap_merge_no_dupes(self):
141		table1 = self.table1
142		table2 = self.table2
143		mergedTable = self.mergedTable
144
145		cmap1 = {0x2603: 'SNOWMAN'}
146		table1.tables = [self.makeSubtable(4,3,1, cmap1)]
147
148		cmap2 = {0x26C4: 'SNOWMAN WITHOUT SNOW'}
149		cmap2Extended = {0x1F93C: 'WRESTLERS'}
150		cmap2Extended.update(cmap2)
151		table2.tables = [self.makeSubtable(4,3,1, cmap2), self.makeSubtable(12,3,10, cmap2Extended)]
152
153		self.merger.alternateGlyphsPerFont = [{},{}]
154		mergedTable.merge(self.merger, [table1, table2])
155
156		expectedCmap = cmap2.copy()
157		expectedCmap.update(cmap1)
158		expectedCmapExtended = cmap2Extended.copy()
159		expectedCmapExtended.update(cmap1)
160		self.assertEqual(mergedTable.numSubTables, 2)
161		self.assertEqual([(table.format, table.platformID, table.platEncID, table.language) for table in mergedTable.tables],
162			[(4,3,1,0),(12,3,10,0)])
163		self.assertEqual(mergedTable.tables[0].cmap, expectedCmap)
164		self.assertEqual(mergedTable.tables[1].cmap, expectedCmapExtended)
165
166	# Tests Issue #322
167	def test_cmap_merge_three_dupes(self):
168		table1 = self.table1
169		table2 = self.table2
170		mergedTable = self.mergedTable
171
172		cmap1 = {0x20: 'space#0', 0xA0: 'space#0'}
173		table1.tables = [self.makeSubtable(4,3,1,cmap1)]
174		cmap2 = {0x20: 'space#1', 0xA0: 'uni00A0#1'}
175		table2.tables = [self.makeSubtable(4,3,1,cmap2)]
176
177		self.merger.duplicateGlyphsPerFont = [{},{}]
178		mergedTable.merge(self.merger, [table1, table2])
179
180		expectedCmap = cmap1.copy()
181		self.assertEqual(mergedTable.numSubTables, 1)
182		table = mergedTable.tables[0]
183		self.assertEqual((table.format, table.platformID, table.platEncID, table.language), (4,3,1,0))
184		self.assertEqual(table.cmap, expectedCmap)
185		self.assertEqual(self.merger.duplicateGlyphsPerFont, [{}, {'space#0': 'space#1'}])
186
187
188def _compile(ttFont):
189	buf = io.BytesIO()
190	ttFont.save(buf)
191	buf.seek(0)
192	return buf
193
194
195def _make_fontfile_with_OS2(*, version, **kwargs):
196	upem = 1000
197	glyphOrder = [".notdef", "a"]
198	cmap = {0x61: "a"}
199	glyphs = {gn: Glyph() for gn in glyphOrder}
200	hmtx = {gn: (500, 0) for gn in glyphOrder}
201	names = {"familyName": "TestOS2", "styleName": "Regular"}
202
203	fb = FontBuilder(unitsPerEm=upem)
204	fb.setupGlyphOrder(glyphOrder)
205	fb.setupCharacterMap(cmap)
206	fb.setupGlyf(glyphs)
207	fb.setupHorizontalMetrics(hmtx)
208	fb.setupHorizontalHeader()
209	fb.setupNameTable(names)
210	fb.setupOS2(version=version, **kwargs)
211
212	return _compile(fb.font)
213
214
215def _merge_and_recompile(fontfiles, options=None):
216	merger = Merger(options)
217	merged = merger.merge(fontfiles)
218	buf = _compile(merged)
219	return ttLib.TTFont(buf)
220
221
222@pytest.mark.parametrize(
223	"v1, v2", list(itertools.permutations(range(5+1), 2))
224)
225def test_merge_OS2_mixed_versions(v1, v2):
226	# https://github.com/fonttools/fonttools/issues/1865
227	fontfiles = [
228		_make_fontfile_with_OS2(version=v1),
229		_make_fontfile_with_OS2(version=v2),
230	]
231	merged = _merge_and_recompile(fontfiles)
232	assert merged["OS/2"].version == max(v1, v2)
233
234
235if __name__ == "__main__":
236	import sys
237	sys.exit(unittest.main())
238