afmLib.py revision 6175debd674004b3e88a4fea1338a090fc4b9da8
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
8import string
9import types
10
11__version__ = "$Id: afmLib.py,v 1.3 2001-06-24 15:11:31 Just 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	_keywords = ['StartFontMetrics',
89			'EndFontMetrics',
90			'StartCharMetrics',
91			'EndCharMetrics',
92			'StartKernData',
93			'StartKernPairs',
94			'EndKernPairs',
95			'EndKernData',
96			'StartComposites',
97			'EndComposites',
98			]
99
100	def __init__(self, path=None):
101		self._attrs = {}
102		self._chars = {}
103		self._kerning = {}
104		self._index = {}
105		self._comments = []
106		self._composites = {}
107		if path is not None:
108			self.read(path)
109
110	def read(self, path):
111		lines = readlines(path)
112		for line in lines:
113			if not string.strip(line):
114				continue
115			m = identifierRE.match(line)
116			if m is None:
117				raise error, "syntax error in AFM file: " + `line`
118
119			pos = m.regs[1][1]
120			word = line[:pos]
121			rest = string.strip(line[pos:])
122			if word in self._keywords:
123				continue
124			if word == "C":
125				self.parsechar(rest)
126			elif word == "KPX":
127				self.parsekernpair(rest)
128			elif word == "CC":
129				self.parsecomposite(rest)
130			else:
131				self.parseattr(word, rest)
132
133	def parsechar(self, rest):
134		m = charRE.match(rest)
135		if m is None:
136			raise error, "syntax error in AFM file: " + `rest`
137		things = []
138		for fr, to in m.regs[1:]:
139			things.append(rest[fr:to])
140		charname = things[2]
141		del things[2]
142		charnum, width, l, b, r, t = map(string.atoi, things)
143		self._chars[charname] = charnum, width, (l, b, r, t)
144
145	def parsekernpair(self, rest):
146		m = kernRE.match(rest)
147		if m is None:
148			raise error, "syntax error in AFM file: " + `rest`
149		things = []
150		for fr, to in m.regs[1:]:
151			things.append(rest[fr:to])
152		leftchar, rightchar, value = things
153		value = string.atoi(value)
154		self._kerning[(leftchar, rightchar)] = value
155
156	def parseattr(self, word, rest):
157		if word == "FontBBox":
158			l, b, r, t = map(string.atoi, string.split(rest))
159			self._attrs[word] = l, b, r, t
160		elif word == "Comment":
161			self._comments.append(rest)
162		else:
163			try:
164				value = string.atoi(rest)
165			except (ValueError, OverflowError):
166				self._attrs[word] = rest
167			else:
168				self._attrs[word] = value
169
170	def parsecomposite(self, rest):
171		m = compositeRE.match(rest)
172		if m is None:
173			raise error, "syntax error in AFM file: " + `rest`
174		charname = m.group(1)
175		ncomponents = int(m.group(2))
176		rest = rest[m.regs[0][1]:]
177		components = []
178		while 1:
179			m = componentRE.match(rest)
180			if m is None:
181				raise error, "syntax error in AFM file: " + `rest`
182			basechar = m.group(1)
183			xoffset = int(m.group(2))
184			yoffset = int(m.group(3))
185			components.append((basechar, xoffset, yoffset))
186			rest = rest[m.regs[0][1]:]
187			if not rest:
188				break
189		assert len(components) == ncomponents
190		self._composites[charname] = components
191
192	def write(self, path, sep='\r'):
193		import time
194		lines = [	"StartFontMetrics 2.0",
195				"Comment Generated by afmLib, version %s; at %s" %
196						(string.split(__version__)[2],
197						time.strftime("%m/%d/%Y %H:%M:%S",
198						time.localtime(time.time())))]
199
200		# write comments, assuming (possibly wrongly!) they should
201		# all appear at the top
202		for comment in self._comments:
203			lines.append("Comment " + comment)
204
205		# write attributes, first the ones we know about, in
206		# a preferred order
207		attrs = self._attrs
208		for attr in preferredAttributeOrder:
209			if attrs.has_key(attr):
210				value = attrs[attr]
211				if attr == "FontBBox":
212					value = "%s %s %s %s" % value
213				lines.append(attr + " " + str(value))
214		# then write the attributes we don't know about,
215		# in alphabetical order
216		items = attrs.items()
217		items.sort()
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 " + `len(self._chars)`)
225		items = map(lambda (charname, (charnum, width, box)):
226			(charnum, (charname, width, box)),
227			self._chars.items())
228
229		def myCmp(a, b):
230			"""Custom compare function to make sure unencoded chars (-1)
231			end up at the end of the list after sorting."""
232			if a[0] == -1:
233				a = (0xffff,) + a[1:]  # 0xffff is an arbitrary large number
234			if b[0] == -1:
235				b = (0xffff,) + b[1:]
236			return cmp(a, b)
237		items.sort(myCmp)
238
239		for charnum, (charname, width, (l, b, r, t)) in items:
240			lines.append("C %d ; WX %d ; N %s ; B %d %d %d %d ;" %
241					(charnum, width, charname, l, b, r, t))
242		lines.append("EndCharMetrics")
243
244		# write kerning info
245		lines.append("StartKernData")
246		lines.append("StartKernPairs " + `len(self._kerning)`)
247		items = self._kerning.items()
248		items.sort()		# XXX is order important?
249		for (leftchar, rightchar), value in items:
250			lines.append("KPX %s %s %d" % (leftchar, rightchar, value))
251		lines.append("EndKernPairs")
252		lines.append("EndKernData")
253
254		if self._composites:
255			composites = self._composites.items()
256			composites.sort()
257			lines.append("StartComposites %s" % len(self._composites))
258			for charname, components in composites:
259				line = "CC %s %s ;" % (charname, len(components))
260				for basechar, xoffset, yoffset in components:
261					line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset)
262				lines.append(line)
263			lines.append("EndComposites")
264
265		lines.append("EndFontMetrics")
266
267		writelines(path, lines, sep)
268
269	def has_kernpair(self, pair):
270		return self._kerning.has_key(pair)
271
272	def kernpairs(self):
273		return self._kerning.keys()
274
275	def has_char(self, char):
276		return self._chars.has_key(char)
277
278	def chars(self):
279		return self._chars.keys()
280
281	def comments(self):
282		return self._comments
283
284	def addComment(self, comment):
285		self._comments.append(comment)
286
287	def addComposite(self, glyphName, components):
288		self._composites[glyphName] = components
289
290	def __getattr__(self, attr):
291		if self._attrs.has_key(attr):
292			return self._attrs[attr]
293		else:
294			raise AttributeError, attr
295
296	def __setattr__(self, attr, value):
297		# all attrs *not* starting with "_" are consider to be AFM keywords
298		if attr[:1] == "_":
299			self.__dict__[attr] = value
300		else:
301			self._attrs[attr] = value
302
303	def __delattr__(self, attr):
304		# all attrs *not* starting with "_" are consider to be AFM keywords
305		if attr[:1] == "_":
306			try:
307				del self.__dict__[attr]
308			except KeyError:
309				raise AttributeError, attr
310		else:
311			try:
312				del self._attrs[attr]
313			except KeyError:
314				raise AttributeError, attr
315
316	def __getitem__(self, key):
317		if type(key) == types.TupleType:
318			# key is a tuple, return the kernpair
319			return self._kerning[key]
320		else:
321			# return the metrics instead
322			return self._chars[key]
323
324	def __setitem__(self, key, value):
325		if type(key) == types.TupleType:
326			# key is a tuple, set kernpair
327			self._kerning[key] = value
328		else:
329			# set char metrics
330			self._chars[key] = value
331
332	def __delitem__(self, key):
333		if type(key) == types.TupleType:
334			# key is a tuple, del kernpair
335			del self._kerning[key]
336		else:
337			# del char metrics
338			del self._chars[key]
339
340	def __repr__(self):
341		if hasattr(self, "FullName"):
342			return '<AFM object for %s>' % self.FullName
343		else:
344			return '<AFM object at %x>' % id(self)
345
346
347def readlines(path):
348	f = open(path, 'rb')
349	data = f.read()
350	f.close()
351	# read any text file, regardless whether it's formatted for Mac, Unix or Dos
352	sep = ""
353	if '\r' in data:
354		sep = sep + '\r'	# mac or dos
355	if '\n' in data:
356		sep = sep + '\n'	# unix or dos
357	return string.split(data, sep)
358
359def writelines(path, lines, sep='\r'):
360	f = open(path, 'wb')
361	for line in lines:
362		f.write(line + sep)
363	f.close()
364
365
366
367if __name__ == "__main__":
368	import macfs
369	fss, ok = macfs.StandardGetFile('TEXT')
370	if ok:
371		path = fss.as_pathname()
372		afm = AFM(path)
373		char = 'A'
374		if afm.has_char(char):
375			print afm[char]	# print charnum, width and boundingbox
376		pair = ('A', 'V')
377		if afm.has_kernpair(pair):
378			print afm[pair]	# print kerning value for pair
379		print afm.Version	# various other afm entries have become attributes
380		print afm.Weight
381		# afm.comments() returns a list of all Comment lines found in the AFM
382		print afm.comments()
383		#print afm.chars()
384		#print afm.kernpairs()
385		print afm
386		afm.write(path + ".muck")
387
388