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