1"""Module for reading and writing AFM (Adobe Font Metrics) files. 2 3Note that this has been designed to read in AFM files generated by Fontographer 4and has not been tested on many other files. In particular, it does not 5implement the whole Adobe AFM specification [#f1]_ but, it should read most 6"common" AFM files. 7 8Here is an example of using `afmLib` to read, modify and write an AFM file: 9 10 >>> from fontTools.afmLib import AFM 11 >>> f = AFM("Tests/afmLib/data/TestAFM.afm") 12 >>> 13 >>> # Accessing a pair gets you the kern value 14 >>> f[("V","A")] 15 -60 16 >>> 17 >>> # Accessing a glyph name gets you metrics 18 >>> f["A"] 19 (65, 668, (8, -25, 660, 666)) 20 >>> # (charnum, width, bounding box) 21 >>> 22 >>> # Accessing an attribute gets you metadata 23 >>> f.FontName 24 'TestFont-Regular' 25 >>> f.FamilyName 26 'TestFont' 27 >>> f.Weight 28 'Regular' 29 >>> f.XHeight 30 500 31 >>> f.Ascender 32 750 33 >>> 34 >>> # Attributes and items can also be set 35 >>> f[("A","V")] = -150 # Tighten kerning 36 >>> f.FontName = "TestFont Squished" 37 >>> 38 >>> # And the font written out again (remove the # in front) 39 >>> #f.write("testfont-squished.afm") 40 41.. rubric:: Footnotes 42 43.. [#f1] `Adobe Technote 5004 <https://www.adobe.com/content/dam/acom/en/devnet/font/pdfs/5004.AFM_Spec.pdf>`_, 44 Adobe Font Metrics File Format Specification. 45 46""" 47 48 49import re 50 51# every single line starts with a "word" 52identifierRE = re.compile(r"^([A-Za-z]+).*") 53 54# regular expression to parse char lines 55charRE = re.compile( 56 r"(-?\d+)" # charnum 57 r"\s*;\s*WX\s+" # ; WX 58 r"(-?\d+)" # width 59 r"\s*;\s*N\s+" # ; N 60 r"([.A-Za-z0-9_]+)" # charname 61 r"\s*;\s*B\s+" # ; B 62 r"(-?\d+)" # left 63 r"\s+" 64 r"(-?\d+)" # bottom 65 r"\s+" 66 r"(-?\d+)" # right 67 r"\s+" 68 r"(-?\d+)" # top 69 r"\s*;\s*" # ; 70 ) 71 72# regular expression to parse kerning lines 73kernRE = re.compile( 74 r"([.A-Za-z0-9_]+)" # leftchar 75 r"\s+" 76 r"([.A-Za-z0-9_]+)" # rightchar 77 r"\s+" 78 r"(-?\d+)" # value 79 r"\s*" 80 ) 81 82# regular expressions to parse composite info lines of the form: 83# Aacute 2 ; PCC A 0 0 ; PCC acute 182 211 ; 84compositeRE = re.compile( 85 r"([.A-Za-z0-9_]+)" # char name 86 r"\s+" 87 r"(\d+)" # number of parts 88 r"\s*;\s*" 89 ) 90componentRE = re.compile( 91 r"PCC\s+" # PPC 92 r"([.A-Za-z0-9_]+)" # base char name 93 r"\s+" 94 r"(-?\d+)" # x offset 95 r"\s+" 96 r"(-?\d+)" # y offset 97 r"\s*;\s*" 98 ) 99 100preferredAttributeOrder = [ 101 "FontName", 102 "FullName", 103 "FamilyName", 104 "Weight", 105 "ItalicAngle", 106 "IsFixedPitch", 107 "FontBBox", 108 "UnderlinePosition", 109 "UnderlineThickness", 110 "Version", 111 "Notice", 112 "EncodingScheme", 113 "CapHeight", 114 "XHeight", 115 "Ascender", 116 "Descender", 117] 118 119 120class error(Exception): 121 pass 122 123 124class AFM(object): 125 126 _attrs = None 127 128 _keywords = ['StartFontMetrics', 129 'EndFontMetrics', 130 'StartCharMetrics', 131 'EndCharMetrics', 132 'StartKernData', 133 'StartKernPairs', 134 'EndKernPairs', 135 'EndKernData', 136 'StartComposites', 137 'EndComposites', 138 ] 139 140 def __init__(self, path=None): 141 """AFM file reader. 142 143 Instantiating an object with a path name will cause the file to be opened, 144 read, and parsed. Alternatively the path can be left unspecified, and a 145 file can be parsed later with the :meth:`read` method.""" 146 self._attrs = {} 147 self._chars = {} 148 self._kerning = {} 149 self._index = {} 150 self._comments = [] 151 self._composites = {} 152 if path is not None: 153 self.read(path) 154 155 def read(self, path): 156 """Opens, reads and parses a file.""" 157 lines = readlines(path) 158 for line in lines: 159 if not line.strip(): 160 continue 161 m = identifierRE.match(line) 162 if m is None: 163 raise error("syntax error in AFM file: " + repr(line)) 164 165 pos = m.regs[1][1] 166 word = line[:pos] 167 rest = line[pos:].strip() 168 if word in self._keywords: 169 continue 170 if word == "C": 171 self.parsechar(rest) 172 elif word == "KPX": 173 self.parsekernpair(rest) 174 elif word == "CC": 175 self.parsecomposite(rest) 176 else: 177 self.parseattr(word, rest) 178 179 def parsechar(self, rest): 180 m = charRE.match(rest) 181 if m is None: 182 raise error("syntax error in AFM file: " + repr(rest)) 183 things = [] 184 for fr, to in m.regs[1:]: 185 things.append(rest[fr:to]) 186 charname = things[2] 187 del things[2] 188 charnum, width, l, b, r, t = (int(thing) for thing in things) 189 self._chars[charname] = charnum, width, (l, b, r, t) 190 191 def parsekernpair(self, rest): 192 m = kernRE.match(rest) 193 if m is None: 194 raise error("syntax error in AFM file: " + repr(rest)) 195 things = [] 196 for fr, to in m.regs[1:]: 197 things.append(rest[fr:to]) 198 leftchar, rightchar, value = things 199 value = int(value) 200 self._kerning[(leftchar, rightchar)] = value 201 202 def parseattr(self, word, rest): 203 if word == "FontBBox": 204 l, b, r, t = [int(thing) for thing in rest.split()] 205 self._attrs[word] = l, b, r, t 206 elif word == "Comment": 207 self._comments.append(rest) 208 else: 209 try: 210 value = int(rest) 211 except (ValueError, OverflowError): 212 self._attrs[word] = rest 213 else: 214 self._attrs[word] = value 215 216 def parsecomposite(self, rest): 217 m = compositeRE.match(rest) 218 if m is None: 219 raise error("syntax error in AFM file: " + repr(rest)) 220 charname = m.group(1) 221 ncomponents = int(m.group(2)) 222 rest = rest[m.regs[0][1]:] 223 components = [] 224 while True: 225 m = componentRE.match(rest) 226 if m is None: 227 raise error("syntax error in AFM file: " + repr(rest)) 228 basechar = m.group(1) 229 xoffset = int(m.group(2)) 230 yoffset = int(m.group(3)) 231 components.append((basechar, xoffset, yoffset)) 232 rest = rest[m.regs[0][1]:] 233 if not rest: 234 break 235 assert len(components) == ncomponents 236 self._composites[charname] = components 237 238 def write(self, path, sep='\r'): 239 """Writes out an AFM font to the given path.""" 240 import time 241 lines = [ "StartFontMetrics 2.0", 242 "Comment Generated by afmLib; at %s" % ( 243 time.strftime("%m/%d/%Y %H:%M:%S", 244 time.localtime(time.time())))] 245 246 # write comments, assuming (possibly wrongly!) they should 247 # all appear at the top 248 for comment in self._comments: 249 lines.append("Comment " + comment) 250 251 # write attributes, first the ones we know about, in 252 # a preferred order 253 attrs = self._attrs 254 for attr in preferredAttributeOrder: 255 if attr in attrs: 256 value = attrs[attr] 257 if attr == "FontBBox": 258 value = "%s %s %s %s" % value 259 lines.append(attr + " " + str(value)) 260 # then write the attributes we don't know about, 261 # in alphabetical order 262 items = sorted(attrs.items()) 263 for attr, value in items: 264 if attr in preferredAttributeOrder: 265 continue 266 lines.append(attr + " " + str(value)) 267 268 # write char metrics 269 lines.append("StartCharMetrics " + repr(len(self._chars))) 270 items = [(charnum, (charname, width, box)) for charname, (charnum, width, box) in self._chars.items()] 271 272 def myKey(a): 273 """Custom key function to make sure unencoded chars (-1) 274 end up at the end of the list after sorting.""" 275 if a[0] == -1: 276 a = (0xffff,) + a[1:] # 0xffff is an arbitrary large number 277 return a 278 items.sort(key=myKey) 279 280 for charnum, (charname, width, (l, b, r, t)) in items: 281 lines.append("C %d ; WX %d ; N %s ; B %d %d %d %d ;" % 282 (charnum, width, charname, l, b, r, t)) 283 lines.append("EndCharMetrics") 284 285 # write kerning info 286 lines.append("StartKernData") 287 lines.append("StartKernPairs " + repr(len(self._kerning))) 288 items = sorted(self._kerning.items()) 289 for (leftchar, rightchar), value in items: 290 lines.append("KPX %s %s %d" % (leftchar, rightchar, value)) 291 lines.append("EndKernPairs") 292 lines.append("EndKernData") 293 294 if self._composites: 295 composites = sorted(self._composites.items()) 296 lines.append("StartComposites %s" % len(self._composites)) 297 for charname, components in composites: 298 line = "CC %s %s ;" % (charname, len(components)) 299 for basechar, xoffset, yoffset in components: 300 line = line + " PCC %s %s %s ;" % (basechar, xoffset, yoffset) 301 lines.append(line) 302 lines.append("EndComposites") 303 304 lines.append("EndFontMetrics") 305 306 writelines(path, lines, sep) 307 308 def has_kernpair(self, pair): 309 """Returns `True` if the given glyph pair (specified as a tuple) exists 310 in the kerning dictionary.""" 311 return pair in self._kerning 312 313 def kernpairs(self): 314 """Returns a list of all kern pairs in the kerning dictionary.""" 315 return list(self._kerning.keys()) 316 317 def has_char(self, char): 318 """Returns `True` if the given glyph exists in the font.""" 319 return char in self._chars 320 321 def chars(self): 322 """Returns a list of all glyph names in the font.""" 323 return list(self._chars.keys()) 324 325 def comments(self): 326 """Returns all comments from the file.""" 327 return self._comments 328 329 def addComment(self, comment): 330 """Adds a new comment to the file.""" 331 self._comments.append(comment) 332 333 def addComposite(self, glyphName, components): 334 """Specifies that the glyph `glyphName` is made up of the given components. 335 The components list should be of the following form:: 336 337 [ 338 (glyphname, xOffset, yOffset), 339 ... 340 ] 341 342 """ 343 self._composites[glyphName] = components 344 345 def __getattr__(self, attr): 346 if attr in self._attrs: 347 return self._attrs[attr] 348 else: 349 raise AttributeError(attr) 350 351 def __setattr__(self, attr, value): 352 # all attrs *not* starting with "_" are consider to be AFM keywords 353 if attr[:1] == "_": 354 self.__dict__[attr] = value 355 else: 356 self._attrs[attr] = value 357 358 def __delattr__(self, attr): 359 # all attrs *not* starting with "_" are consider to be AFM keywords 360 if attr[:1] == "_": 361 try: 362 del self.__dict__[attr] 363 except KeyError: 364 raise AttributeError(attr) 365 else: 366 try: 367 del self._attrs[attr] 368 except KeyError: 369 raise AttributeError(attr) 370 371 def __getitem__(self, key): 372 if isinstance(key, tuple): 373 # key is a tuple, return the kernpair 374 return self._kerning[key] 375 else: 376 # return the metrics instead 377 return self._chars[key] 378 379 def __setitem__(self, key, value): 380 if isinstance(key, tuple): 381 # key is a tuple, set kernpair 382 self._kerning[key] = value 383 else: 384 # set char metrics 385 self._chars[key] = value 386 387 def __delitem__(self, key): 388 if isinstance(key, tuple): 389 # key is a tuple, del kernpair 390 del self._kerning[key] 391 else: 392 # del char metrics 393 del self._chars[key] 394 395 def __repr__(self): 396 if hasattr(self, "FullName"): 397 return '<AFM object for %s>' % self.FullName 398 else: 399 return '<AFM object at %x>' % id(self) 400 401 402def readlines(path): 403 with open(path, "r", encoding="ascii") as f: 404 data = f.read() 405 return data.splitlines() 406 407def writelines(path, lines, sep='\r'): 408 with open(path, "w", encoding="ascii", newline=sep) as f: 409 f.write("\n".join(lines) + "\n") 410 411 412if __name__ == "__main__": 413 import EasyDialogs 414 path = EasyDialogs.AskFileForOpen() 415 if path: 416 afm = AFM(path) 417 char = 'A' 418 if afm.has_char(char): 419 print(afm[char]) # print charnum, width and boundingbox 420 pair = ('A', 'V') 421 if afm.has_kernpair(pair): 422 print(afm[pair]) # print kerning value for pair 423 print(afm.Version) # various other afm entries have become attributes 424 print(afm.Weight) 425 # afm.comments() returns a list of all Comment lines found in the AFM 426 print(afm.comments()) 427 #print afm.chars() 428 #print afm.kernpairs() 429 print(afm) 430 afm.write(path + ".muck") 431