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