afmLib.py revision 32c10eecffb4923e0721c395e4b80fb732543f18
1"""Module for reading and writing AFM files."""
2
3# XXX reads AFM's generated by Fog, not tested with much else.
4# It does not implement the full spec (Adobe Technote 5004, Adobe Font Metrics
5# File Format Specification). Still, it should read most "common" AFM files.
6
7from __future__ import print_function, division
8from fontTools.misc.py23 import *
9import re
10
11__version__ = "$Id: afmLib.py,v 1.6 2003-05-24 12:50:47 jvr Exp $"
12
13
14# every single line starts with a "word"
15identifierRE = re.compile("^([A-Za-z]+).*")
16
17# regular expression to parse char lines
18charRE = re.compile(
19		"(-?\d+)"			# charnum
20		"\s*;\s*WX\s+"		# ; WX
21		"(-?\d+)"			# width
22		"\s*;\s*N\s+"		# ; N
23		"([.A-Za-z0-9_]+)"	# charname
24		"\s*;\s*B\s+"		# ; B
25		"(-?\d+)"			# left
26		"\s+"				#
27		"(-?\d+)"			# bottom
28		"\s+"				#
29		"(-?\d+)"			# right
30		"\s+"				#
31		"(-?\d+)"			# top
32		"\s*;\s*"			# ;
33		)
34
35# regular expression to parse kerning lines
36kernRE = re.compile(
37		"([.A-Za-z0-9_]+)"	# leftchar
38		"\s+"				#
39		"([.A-Za-z0-9_]+)"	# rightchar
40		"\s+"				#
41		"(-?\d+)"			# value
42		"\s*"				#
43		)
44
45# regular expressions to parse composite info lines of the form:
46# Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ;
47compositeRE = re.compile(
48		"([.A-Za-z0-9_]+)"	# char name
49		"\s+"				#
50		"(\d+)"				# number of parts
51		"\s*;\s*"			#
52		)
53componentRE = re.compile(
54		"PCC\s+"			# PPC
55		"([.A-Za-z0-9_]+)"	# base char name
56		"\s+"				#
57		"(-?\d+)"			# x offset
58		"\s+"				#
59		"(-?\d+)"			# y offset
60		"\s*;\s*"			#
61		)
62
63preferredAttributeOrder = [
64		"FontName",
65		"FullName",
66		"FamilyName",
67		"Weight",
68		"ItalicAngle",
69		"IsFixedPitch",
70		"FontBBox",
71		"UnderlinePosition",
72		"UnderlineThickness",
73		"Version",
74		"Notice",
75		"EncodingScheme",
76		"CapHeight",
77		"XHeight",
78		"Ascender",
79		"Descender",
80]
81
82
83class error(Exception): pass
84
85
86class AFM:
87
88	_attrs = None
89
90	_keywords = ['StartFontMetrics',
91			'EndFontMetrics',
92			'StartCharMetrics',
93			'EndCharMetrics',
94			'StartKernData',
95			'StartKernPairs',
96			'EndKernPairs',
97			'EndKernData',
98			'StartComposites',
99			'EndComposites',
100			]
101
102	def __init__(self, path=None):
103		self._attrs = {}
104		self._chars = {}
105		self._kerning = {}
106		self._index = {}
107		self._comments = []
108		self._composites = {}
109		if path is not None:
110			self.read(path)
111
112	def read(self, path):
113		lines = readlines(path)
114		for line in lines:
115			if not line.strip():
116				continue
117			m = identifierRE.match(line)
118			if m is None:
119				raise error("syntax error in AFM file: " + repr(line))
120
121			pos = m.regs[1][1]
122			word = line[:pos]
123			rest = line[pos:].strip()
124			if word in self._keywords:
125				continue
126			if word == "C":
127				self.parsechar(rest)
128			elif word == "KPX":
129				self.parsekernpair(rest)
130			elif word == "CC":
131				self.parsecomposite(rest)
132			else:
133				self.parseattr(word, rest)
134
135	def parsechar(self, rest):
136		m = charRE.match(rest)
137		if m is None:
138			raise error("syntax error in AFM file: " + repr(rest))
139		things = []
140		for fr, to in m.regs[1:]:
141			things.append(rest[fr:to])
142		charname = things[2]
143		del things[2]
144		charnum, width, l, b, r, t = (int(thing) for thing in things)
145		self._chars[charname] = charnum, width, (l, b, r, t)
146
147	def parsekernpair(self, rest):
148		m = kernRE.match(rest)
149		if m is None:
150			raise error("syntax error in AFM file: " + repr(rest))
151		things = []
152		for fr, to in m.regs[1:]:
153			things.append(rest[fr:to])
154		leftchar, rightchar, value = things
155		value = int(value)
156		self._kerning[(leftchar, rightchar)] = value
157
158	def parseattr(self, word, rest):
159		if word == "FontBBox":
160			l, b, r, t = [int(thing) for thing in rest.split()]
161			self._attrs[word] = l, b, r, t
162		elif word == "Comment":
163			self._comments.append(rest)
164		else:
165			try:
166				value = int(rest)
167			except (ValueError, OverflowError):
168				self._attrs[word] = rest
169			else:
170				self._attrs[word] = value
171
172	def parsecomposite(self, rest):
173		m = compositeRE.match(rest)
174		if m is None:
175			raise error("syntax error in AFM file: " + repr(rest))
176		charname = m.group(1)
177		ncomponents = int(m.group(2))
178		rest = rest[m.regs[0][1]:]
179		components = []
180		while True:
181			m = componentRE.match(rest)
182			if m is None:
183				raise error("syntax error in AFM file: " + repr(rest))
184			basechar = m.group(1)
185			xoffset = int(m.group(2))
186			yoffset = int(m.group(3))
187			components.append((basechar, xoffset, yoffset))
188			rest = rest[m.regs[0][1]:]
189			if not rest:
190				break
191		assert len(components) == ncomponents
192		self._composites[charname] = components
193
194	def write(self, path, sep='\r'):
195		import time
196		lines = [	"StartFontMetrics 2.0",
197				"Comment Generated by afmLib, version %s; at %s" %
198						(__version__.split()[2],
199						time.strftime("%m/%d/%Y %H:%M:%S",
200						time.localtime(time.time())))]
201
202		# write comments, assuming (possibly wrongly!) they should
203		# all appear at the top
204		for comment in self._comments:
205			lines.append("Comment " + comment)
206
207		# write attributes, first the ones we know about, in
208		# a preferred order
209		attrs = self._attrs
210		for attr in preferredAttributeOrder:
211			if attr in attrs:
212				value = attrs[attr]
213				if attr == "FontBBox":
214					value = "%s %s %s %s" % value
215				lines.append(attr + " " + str(value))
216		# then write the attributes we don't know about,
217		# in alphabetical order
218		items = sorted(attrs.items())
219		for attr, value in items:
220			if attr in preferredAttributeOrder:
221				continue
222			lines.append(attr + " " + str(value))
223
224		# write char metrics
225		lines.append("StartCharMetrics " + repr(len(self._chars)))
226		items = [(charnum, (charname, width, box)) for charname, (charnum, width, box) in self._chars.items()]
227
228		def myCmp(a, b):
229			"""Custom compare function to make sure unencoded chars (-1)
230			end up at the end of the list after sorting."""
231			if a[0] == -1:
232				a = (0xffff,) + a[1:]  # 0xffff is an arbitrary large number
233			if b[0] == -1:
234				b = (0xffff,) + b[1:]
235			return cmp(a, b)
236		items.sort(myCmp)
237
238		for charnum, (charname, width, (l, b, r, t)) in items:
239			lines.append("C %d ; WX %d ; N %s ; B %d %d %d %d ;" %
240					(charnum, width, charname, l, b, r, t))
241		lines.append("EndCharMetrics")
242
243		# write kerning info
244		lines.append("StartKernData")
245		lines.append("StartKernPairs " + repr(len(self._kerning)))
246		items = sorted(self._kerning.items())
247		for (leftchar, rightchar), value in items:
248			lines.append("KPX %s %s %d" % (leftchar, rightchar, value))
249		lines.append("EndKernPairs")
250		lines.append("EndKernData")
251
252		if self._composites:
253			composites = sorted(self._composites.items())
254			lines.append("StartComposites %s" % len(self._composites))
255			for charname, components in composites:
256				line = "CC %s %s ;" % (charname, len(components))
257				for basechar, xoffset, yoffset in components:
258					line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset)
259				lines.append(line)
260			lines.append("EndComposites")
261
262		lines.append("EndFontMetrics")
263
264		writelines(path, lines, sep)
265
266	def has_kernpair(self, pair):
267		return pair in self._kerning
268
269	def kernpairs(self):
270		return list(self._kerning.keys())
271
272	def has_char(self, char):
273		return char in self._chars
274
275	def chars(self):
276		return list(self._chars.keys())
277
278	def comments(self):
279		return self._comments
280
281	def addComment(self, comment):
282		self._comments.append(comment)
283
284	def addComposite(self, glyphName, components):
285		self._composites[glyphName] = components
286
287	def __getattr__(self, attr):
288		if attr in self._attrs:
289			return self._attrs[attr]
290		else:
291			raise AttributeError(attr)
292
293	def __setattr__(self, attr, value):
294		# all attrs *not* starting with "_" are consider to be AFM keywords
295		if attr[:1] == "_":
296			self.__dict__[attr] = value
297		else:
298			self._attrs[attr] = value
299
300	def __delattr__(self, attr):
301		# all attrs *not* starting with "_" are consider to be AFM keywords
302		if attr[:1] == "_":
303			try:
304				del self.__dict__[attr]
305			except KeyError:
306				raise AttributeError(attr)
307		else:
308			try:
309				del self._attrs[attr]
310			except KeyError:
311				raise AttributeError(attr)
312
313	def __getitem__(self, key):
314		if isinstance(key, tuple):
315			# key is a tuple, return the kernpair
316			return self._kerning[key]
317		else:
318			# return the metrics instead
319			return self._chars[key]
320
321	def __setitem__(self, key, value):
322		if isinstance(key, tuple):
323			# key is a tuple, set kernpair
324			self._kerning[key] = value
325		else:
326			# set char metrics
327			self._chars[key] = value
328
329	def __delitem__(self, key):
330		if isinstance(key, tuple):
331			# key is a tuple, del kernpair
332			del self._kerning[key]
333		else:
334			# del char metrics
335			del self._chars[key]
336
337	def __repr__(self):
338		if hasattr(self, "FullName"):
339			return '<AFM object for %s>' % self.FullName
340		else:
341			return '<AFM object at %x>' % id(self)
342
343
344def readlines(path):
345	f = open(path, 'rb')
346	data = f.read()
347	f.close()
348	# read any text file, regardless whether it's formatted for Mac, Unix or Dos
349	sep = ""
350	if '\r' in data:
351		sep = sep + '\r'	# mac or dos
352	if '\n' in data:
353		sep = sep + '\n'	# unix or dos
354	return data.split(sep)
355
356def writelines(path, lines, sep='\r'):
357	f = open(path, 'wb')
358	for line in lines:
359		f.write(line + sep)
360	f.close()
361
362
363
364if __name__ == "__main__":
365	import EasyDialogs
366	path = EasyDialogs.AskFileForOpen()
367	if path:
368		afm = AFM(path)
369		char = 'A'
370		if afm.has_char(char):
371			print(afm[char])	# print charnum, width and boundingbox
372		pair = ('A', 'V')
373		if afm.has_kernpair(pair):
374			print(afm[pair])	# print kerning value for pair
375		print(afm.Version)	# various other afm entries have become attributes
376		print(afm.Weight)
377		# afm.comments() returns a list of all Comment lines found in the AFM
378		print(afm.comments())
379		#print afm.chars()
380		#print afm.kernpairs()
381		print(afm)
382		afm.write(path + ".muck")
383
384