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