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