Coverage for /usr/lib/python3/dist-packages/matplotlib/_type1font.py: 19%
401 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1"""
2A class representing a Type 1 font.
4This version reads pfa and pfb files and splits them for embedding in
5pdf files. It also supports SlantFont and ExtendFont transformations,
6similarly to pdfTeX and friends. There is no support yet for subsetting.
8Usage::
10 font = Type1Font(filename)
11 clear_part, encrypted_part, finale = font.parts
12 slanted_font = font.transform({'slant': 0.167})
13 extended_font = font.transform({'extend': 1.2})
15Sources:
17* Adobe Technical Note #5040, Supporting Downloadable PostScript
18 Language Fonts.
20* Adobe Type 1 Font Format, Adobe Systems Incorporated, third printing,
21 v1.1, 1993. ISBN 0-201-57044-0.
22"""
24import binascii
25import functools
26import logging
27import re
28import string
29import struct
31import numpy as np
33from matplotlib.cbook import _format_approx
34from . import _api
36_log = logging.getLogger(__name__)
39class _Token:
40 """
41 A token in a PostScript stream.
43 Attributes
44 ----------
45 pos : int
46 Position, i.e. offset from the beginning of the data.
47 raw : str
48 Raw text of the token.
49 kind : str
50 Description of the token (for debugging or testing).
51 """
52 __slots__ = ('pos', 'raw')
53 kind = '?'
55 def __init__(self, pos, raw):
56 _log.debug('type1font._Token %s at %d: %r', self.kind, pos, raw)
57 self.pos = pos
58 self.raw = raw
60 def __str__(self):
61 return f"<{self.kind} {self.raw} @{self.pos}>"
63 def endpos(self):
64 """Position one past the end of the token"""
65 return self.pos + len(self.raw)
67 def is_keyword(self, *names):
68 """Is this a name token with one of the names?"""
69 return False
71 def is_slash_name(self):
72 """Is this a name token that starts with a slash?"""
73 return False
75 def is_delim(self):
76 """Is this a delimiter token?"""
77 return False
79 def is_number(self):
80 """Is this a number token?"""
81 return False
83 def value(self):
84 return self.raw
87class _NameToken(_Token):
88 kind = 'name'
90 def is_slash_name(self):
91 return self.raw.startswith('/')
93 def value(self):
94 return self.raw[1:]
97class _BooleanToken(_Token):
98 kind = 'boolean'
100 def value(self):
101 return self.raw == 'true'
104class _KeywordToken(_Token):
105 kind = 'keyword'
107 def is_keyword(self, *names):
108 return self.raw in names
111class _DelimiterToken(_Token):
112 kind = 'delimiter'
114 def is_delim(self):
115 return True
117 def opposite(self):
118 return {'[': ']', ']': '[',
119 '{': '}', '}': '{',
120 '<<': '>>', '>>': '<<'
121 }[self.raw]
124class _WhitespaceToken(_Token):
125 kind = 'whitespace'
128class _StringToken(_Token):
129 kind = 'string'
130 _escapes_re = re.compile(r'\\([\\()nrtbf]|[0-7]{1,3})')
131 _replacements = {'\\': '\\', '(': '(', ')': ')', 'n': '\n',
132 'r': '\r', 't': '\t', 'b': '\b', 'f': '\f'}
133 _ws_re = re.compile('[\0\t\r\f\n ]')
135 @classmethod
136 def _escape(cls, match):
137 group = match.group(1)
138 try:
139 return cls._replacements[group]
140 except KeyError:
141 return chr(int(group, 8))
143 @functools.lru_cache()
144 def value(self):
145 if self.raw[0] == '(':
146 return self._escapes_re.sub(self._escape, self.raw[1:-1])
147 else:
148 data = self._ws_re.sub('', self.raw[1:-1])
149 if len(data) % 2 == 1:
150 data += '0'
151 return binascii.unhexlify(data)
154class _BinaryToken(_Token):
155 kind = 'binary'
157 def value(self):
158 return self.raw[1:]
161class _NumberToken(_Token):
162 kind = 'number'
164 def is_number(self):
165 return True
167 def value(self):
168 if '.' not in self.raw:
169 return int(self.raw)
170 else:
171 return float(self.raw)
174def _tokenize(data: bytes, skip_ws: bool):
175 """
176 A generator that produces _Token instances from Type-1 font code.
178 The consumer of the generator may send an integer to the tokenizer to
179 indicate that the next token should be _BinaryToken of the given length.
181 Parameters
182 ----------
183 data : bytes
184 The data of the font to tokenize.
186 skip_ws : bool
187 If true, the generator will drop any _WhitespaceTokens from the output.
188 """
190 text = data.decode('ascii', 'replace')
191 whitespace_or_comment_re = re.compile(r'[\0\t\r\f\n ]+|%[^\r\n]*')
192 token_re = re.compile(r'/{0,2}[^]\0\t\r\f\n ()<>{}/%[]+')
193 instring_re = re.compile(r'[()\\]')
194 hex_re = re.compile(r'^<[0-9a-fA-F\0\t\r\f\n ]*>$')
195 oct_re = re.compile(r'[0-7]{1,3}')
196 pos = 0
197 next_binary = None
199 while pos < len(text):
200 if next_binary is not None:
201 n = next_binary
202 next_binary = (yield _BinaryToken(pos, data[pos:pos+n]))
203 pos += n
204 continue
205 match = whitespace_or_comment_re.match(text, pos)
206 if match:
207 if not skip_ws:
208 next_binary = (yield _WhitespaceToken(pos, match.group()))
209 pos = match.end()
210 elif text[pos] == '(':
211 # PostScript string rules:
212 # - parentheses must be balanced
213 # - backslashes escape backslashes and parens
214 # - also codes \n\r\t\b\f and octal escapes are recognized
215 # - other backslashes do not escape anything
216 start = pos
217 pos += 1
218 depth = 1
219 while depth:
220 match = instring_re.search(text, pos)
221 if match is None:
222 raise ValueError(
223 f'Unterminated string starting at {start}')
224 pos = match.end()
225 if match.group() == '(':
226 depth += 1
227 elif match.group() == ')':
228 depth -= 1
229 else: # a backslash
230 char = text[pos]
231 if char in r'\()nrtbf':
232 pos += 1
233 else:
234 octal = oct_re.match(text, pos)
235 if octal:
236 pos = octal.end()
237 else:
238 pass # non-escaping backslash
239 next_binary = (yield _StringToken(start, text[start:pos]))
240 elif text[pos:pos + 2] in ('<<', '>>'):
241 next_binary = (yield _DelimiterToken(pos, text[pos:pos + 2]))
242 pos += 2
243 elif text[pos] == '<':
244 start = pos
245 try:
246 pos = text.index('>', pos) + 1
247 except ValueError as e:
248 raise ValueError(f'Unterminated hex string starting at {start}'
249 ) from e
250 if not hex_re.match(text[start:pos]):
251 raise ValueError(f'Malformed hex string starting at {start}')
252 next_binary = (yield _StringToken(pos, text[start:pos]))
253 else:
254 match = token_re.match(text, pos)
255 if match:
256 raw = match.group()
257 if raw.startswith('/'):
258 next_binary = (yield _NameToken(pos, raw))
259 elif match.group() in ('true', 'false'):
260 next_binary = (yield _BooleanToken(pos, raw))
261 else:
262 try:
263 float(raw)
264 next_binary = (yield _NumberToken(pos, raw))
265 except ValueError:
266 next_binary = (yield _KeywordToken(pos, raw))
267 pos = match.end()
268 else:
269 next_binary = (yield _DelimiterToken(pos, text[pos]))
270 pos += 1
273class _BalancedExpression(_Token):
274 pass
277def _expression(initial, tokens, data):
278 """
279 Consume some number of tokens and return a balanced PostScript expression.
281 Parameters
282 ----------
283 initial : _Token
284 The token that triggered parsing a balanced expression.
285 tokens : iterator of _Token
286 Following tokens.
287 data : bytes
288 Underlying data that the token positions point to.
290 Returns
291 -------
292 _BalancedExpression
293 """
294 delim_stack = []
295 token = initial
296 while True:
297 if token.is_delim():
298 if token.raw in ('[', '{'):
299 delim_stack.append(token)
300 elif token.raw in (']', '}'):
301 if not delim_stack:
302 raise RuntimeError(f"unmatched closing token {token}")
303 match = delim_stack.pop()
304 if match.raw != token.opposite():
305 raise RuntimeError(
306 f"opening token {match} closed by {token}"
307 )
308 if not delim_stack:
309 break
310 else:
311 raise RuntimeError(f'unknown delimiter {token}')
312 elif not delim_stack:
313 break
314 token = next(tokens)
315 return _BalancedExpression(
316 initial.pos,
317 data[initial.pos:token.endpos()].decode('ascii', 'replace')
318 )
321class Type1Font:
322 """
323 A class representing a Type-1 font, for use by backends.
325 Attributes
326 ----------
327 parts : tuple
328 A 3-tuple of the cleartext part, the encrypted part, and the finale of
329 zeros.
331 decrypted : bytes
332 The decrypted form of ``parts[1]``.
334 prop : dict[str, Any]
335 A dictionary of font properties. Noteworthy keys include:
337 - FontName: PostScript name of the font
338 - Encoding: dict from numeric codes to glyph names
339 - FontMatrix: bytes object encoding a matrix
340 - UniqueID: optional font identifier, dropped when modifying the font
341 - CharStrings: dict from glyph names to byte code
342 - Subrs: array of byte code subroutines
343 - OtherSubrs: bytes object encoding some PostScript code
344 """
345 __slots__ = ('parts', 'decrypted', 'prop', '_pos', '_abbr')
346 # the _pos dict contains (begin, end) indices to parts[0] + decrypted
347 # so that they can be replaced when transforming the font;
348 # but since sometimes a definition appears in both parts[0] and decrypted,
349 # _pos[name] is an array of such pairs
350 #
351 # _abbr maps three standard abbreviations to their particular names in
352 # this font (e.g. 'RD' is named '-|' in some fonts)
354 def __init__(self, input):
355 """
356 Initialize a Type-1 font.
358 Parameters
359 ----------
360 input : str or 3-tuple
361 Either a pfb file name, or a 3-tuple of already-decoded Type-1
362 font `~.Type1Font.parts`.
363 """
364 if isinstance(input, tuple) and len(input) == 3:
365 self.parts = input
366 else:
367 with open(input, 'rb') as file:
368 data = self._read(file)
369 self.parts = self._split(data)
371 self.decrypted = self._decrypt(self.parts[1], 'eexec')
372 self._abbr = {'RD': 'RD', 'ND': 'ND', 'NP': 'NP'}
373 self._parse()
375 def _read(self, file):
376 """Read the font from a file, decoding into usable parts."""
377 rawdata = file.read()
378 if not rawdata.startswith(b'\x80'):
379 return rawdata
381 data = b''
382 while rawdata:
383 if not rawdata.startswith(b'\x80'):
384 raise RuntimeError('Broken pfb file (expected byte 128, '
385 'got %d)' % rawdata[0])
386 type = rawdata[1]
387 if type in (1, 2):
388 length, = struct.unpack('<i', rawdata[2:6])
389 segment = rawdata[6:6 + length]
390 rawdata = rawdata[6 + length:]
392 if type == 1: # ASCII text: include verbatim
393 data += segment
394 elif type == 2: # binary data: encode in hexadecimal
395 data += binascii.hexlify(segment)
396 elif type == 3: # end of file
397 break
398 else:
399 raise RuntimeError('Unknown segment type %d in pfb file' %
400 type)
402 return data
404 def _split(self, data):
405 """
406 Split the Type 1 font into its three main parts.
408 The three parts are: (1) the cleartext part, which ends in a
409 eexec operator; (2) the encrypted part; (3) the fixed part,
410 which contains 512 ASCII zeros possibly divided on various
411 lines, a cleartomark operator, and possibly something else.
412 """
414 # Cleartext part: just find the eexec and skip whitespace
415 idx = data.index(b'eexec')
416 idx += len(b'eexec')
417 while data[idx] in b' \t\r\n':
418 idx += 1
419 len1 = idx
421 # Encrypted part: find the cleartomark operator and count
422 # zeros backward
423 idx = data.rindex(b'cleartomark') - 1
424 zeros = 512
425 while zeros and data[idx] in b'0' or data[idx] in b'\r\n':
426 if data[idx] in b'0':
427 zeros -= 1
428 idx -= 1
429 if zeros:
430 # this may have been a problem on old implementations that
431 # used the zeros as necessary padding
432 _log.info('Insufficiently many zeros in Type 1 font')
434 # Convert encrypted part to binary (if we read a pfb file, we may end
435 # up converting binary to hexadecimal to binary again; but if we read
436 # a pfa file, this part is already in hex, and I am not quite sure if
437 # even the pfb format guarantees that it will be in binary).
438 idx1 = len1 + ((idx - len1 + 2) & ~1) # ensure an even number of bytes
439 binary = binascii.unhexlify(data[len1:idx1])
441 return data[:len1], binary, data[idx+1:]
443 @staticmethod
444 def _decrypt(ciphertext, key, ndiscard=4):
445 """
446 Decrypt ciphertext using the Type-1 font algorithm.
448 The algorithm is described in Adobe's "Adobe Type 1 Font Format".
449 The key argument can be an integer, or one of the strings
450 'eexec' and 'charstring', which map to the key specified for the
451 corresponding part of Type-1 fonts.
453 The ndiscard argument should be an integer, usually 4.
454 That number of bytes is discarded from the beginning of plaintext.
455 """
457 key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key)
458 plaintext = []
459 for byte in ciphertext:
460 plaintext.append(byte ^ (key >> 8))
461 key = ((key+byte) * 52845 + 22719) & 0xffff
463 return bytes(plaintext[ndiscard:])
465 @staticmethod
466 def _encrypt(plaintext, key, ndiscard=4):
467 """
468 Encrypt plaintext using the Type-1 font algorithm.
470 The algorithm is described in Adobe's "Adobe Type 1 Font Format".
471 The key argument can be an integer, or one of the strings
472 'eexec' and 'charstring', which map to the key specified for the
473 corresponding part of Type-1 fonts.
475 The ndiscard argument should be an integer, usually 4. That
476 number of bytes is prepended to the plaintext before encryption.
477 This function prepends NUL bytes for reproducibility, even though
478 the original algorithm uses random bytes, presumably to avoid
479 cryptanalysis.
480 """
482 key = _api.check_getitem({'eexec': 55665, 'charstring': 4330}, key=key)
483 ciphertext = []
484 for byte in b'\0' * ndiscard + plaintext:
485 c = byte ^ (key >> 8)
486 ciphertext.append(c)
487 key = ((key + c) * 52845 + 22719) & 0xffff
489 return bytes(ciphertext)
491 def _parse(self):
492 """
493 Find the values of various font properties. This limited kind
494 of parsing is described in Chapter 10 "Adobe Type Manager
495 Compatibility" of the Type-1 spec.
496 """
497 # Start with reasonable defaults
498 prop = {'Weight': 'Regular', 'ItalicAngle': 0.0, 'isFixedPitch': False,
499 'UnderlinePosition': -100, 'UnderlineThickness': 50}
500 pos = {}
501 data = self.parts[0] + self.decrypted
503 source = _tokenize(data, True)
504 while True:
505 # See if there is a key to be assigned a value
506 # e.g. /FontName in /FontName /Helvetica def
507 try:
508 token = next(source)
509 except StopIteration:
510 break
511 if token.is_delim():
512 # skip over this - we want top-level keys only
513 _expression(token, source, data)
514 if token.is_slash_name():
515 key = token.value()
516 keypos = token.pos
517 else:
518 continue
520 # Some values need special parsing
521 if key in ('Subrs', 'CharStrings', 'Encoding', 'OtherSubrs'):
522 prop[key], endpos = {
523 'Subrs': self._parse_subrs,
524 'CharStrings': self._parse_charstrings,
525 'Encoding': self._parse_encoding,
526 'OtherSubrs': self._parse_othersubrs
527 }[key](source, data)
528 pos.setdefault(key, []).append((keypos, endpos))
529 continue
531 try:
532 token = next(source)
533 except StopIteration:
534 break
536 if isinstance(token, _KeywordToken):
537 # constructs like
538 # FontDirectory /Helvetica known {...} {...} ifelse
539 # mean the key was not really a key
540 continue
542 if token.is_delim():
543 value = _expression(token, source, data).raw
544 else:
545 value = token.value()
547 # look for a 'def' possibly preceded by access modifiers
548 try:
549 kw = next(
550 kw for kw in source
551 if not kw.is_keyword('readonly', 'noaccess', 'executeonly')
552 )
553 except StopIteration:
554 break
556 # sometimes noaccess def and readonly def are abbreviated
557 if kw.is_keyword('def', self._abbr['ND'], self._abbr['NP']):
558 prop[key] = value
559 pos.setdefault(key, []).append((keypos, kw.endpos()))
561 # detect the standard abbreviations
562 if value == '{noaccess def}':
563 self._abbr['ND'] = key
564 elif value == '{noaccess put}':
565 self._abbr['NP'] = key
566 elif value == '{string currentfile exch readstring pop}':
567 self._abbr['RD'] = key
569 # Fill in the various *Name properties
570 if 'FontName' not in prop:
571 prop['FontName'] = (prop.get('FullName') or
572 prop.get('FamilyName') or
573 'Unknown')
574 if 'FullName' not in prop:
575 prop['FullName'] = prop['FontName']
576 if 'FamilyName' not in prop:
577 extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|'
578 '(ultra)?light|extra|condensed))+$')
579 prop['FamilyName'] = re.sub(extras, '', prop['FullName'])
580 # Decrypt the encrypted parts
581 ndiscard = prop.get('lenIV', 4)
582 cs = prop['CharStrings']
583 for key, value in cs.items():
584 cs[key] = self._decrypt(value, 'charstring', ndiscard)
585 if 'Subrs' in prop:
586 prop['Subrs'] = [
587 self._decrypt(value, 'charstring', ndiscard)
588 for value in prop['Subrs']
589 ]
591 self.prop = prop
592 self._pos = pos
594 def _parse_subrs(self, tokens, _data):
595 count_token = next(tokens)
596 if not count_token.is_number():
597 raise RuntimeError(
598 f"Token following /Subrs must be a number, was {count_token}"
599 )
600 count = count_token.value()
601 array = [None] * count
602 next(t for t in tokens if t.is_keyword('array'))
603 for _ in range(count):
604 next(t for t in tokens if t.is_keyword('dup'))
605 index_token = next(tokens)
606 if not index_token.is_number():
607 raise RuntimeError(
608 "Token following dup in Subrs definition must be a "
609 f"number, was {index_token}"
610 )
611 nbytes_token = next(tokens)
612 if not nbytes_token.is_number():
613 raise RuntimeError(
614 "Second token following dup in Subrs definition must "
615 f"be a number, was {nbytes_token}"
616 )
617 token = next(tokens)
618 if not token.is_keyword(self._abbr['RD']):
619 raise RuntimeError(
620 f"Token preceding subr must be {self._abbr['RD']}, "
621 f"was {token}"
622 )
623 binary_token = tokens.send(1+nbytes_token.value())
624 array[index_token.value()] = binary_token.value()
626 return array, next(tokens).endpos()
628 @staticmethod
629 def _parse_charstrings(tokens, _data):
630 count_token = next(tokens)
631 if not count_token.is_number():
632 raise RuntimeError(
633 "Token following /CharStrings must be a number, "
634 f"was {count_token}"
635 )
636 count = count_token.value()
637 charstrings = {}
638 next(t for t in tokens if t.is_keyword('begin'))
639 while True:
640 token = next(t for t in tokens
641 if t.is_keyword('end') or t.is_slash_name())
642 if token.raw == 'end':
643 return charstrings, token.endpos()
644 glyphname = token.value()
645 nbytes_token = next(tokens)
646 if not nbytes_token.is_number():
647 raise RuntimeError(
648 f"Token following /{glyphname} in CharStrings definition "
649 f"must be a number, was {nbytes_token}"
650 )
651 next(tokens) # usually RD or |-
652 binary_token = tokens.send(1+nbytes_token.value())
653 charstrings[glyphname] = binary_token.value()
655 @staticmethod
656 def _parse_encoding(tokens, _data):
657 # this only works for encodings that follow the Adobe manual
658 # but some old fonts include non-compliant data - we log a warning
659 # and return a possibly incomplete encoding
660 encoding = {}
661 while True:
662 token = next(t for t in tokens
663 if t.is_keyword('StandardEncoding', 'dup', 'def'))
664 if token.is_keyword('StandardEncoding'):
665 return _StandardEncoding, token.endpos()
666 if token.is_keyword('def'):
667 return encoding, token.endpos()
668 index_token = next(tokens)
669 if not index_token.is_number():
670 _log.warning(
671 f"Parsing encoding: expected number, got {index_token}"
672 )
673 continue
674 name_token = next(tokens)
675 if not name_token.is_slash_name():
676 _log.warning(
677 f"Parsing encoding: expected slash-name, got {name_token}"
678 )
679 continue
680 encoding[index_token.value()] = name_token.value()
682 @staticmethod
683 def _parse_othersubrs(tokens, data):
684 init_pos = None
685 while True:
686 token = next(tokens)
687 if init_pos is None:
688 init_pos = token.pos
689 if token.is_delim():
690 _expression(token, tokens, data)
691 elif token.is_keyword('def', 'ND', '|-'):
692 return data[init_pos:token.endpos()], token.endpos()
694 def transform(self, effects):
695 """
696 Return a new font that is slanted and/or extended.
698 Parameters
699 ----------
700 effects : dict
701 A dict with optional entries:
703 - 'slant' : float, default: 0
704 Tangent of the angle that the font is to be slanted to the
705 right. Negative values slant to the left.
706 - 'extend' : float, default: 1
707 Scaling factor for the font width. Values less than 1 condense
708 the glyphs.
710 Returns
711 -------
712 `Type1Font`
713 """
714 fontname = self.prop['FontName']
715 italicangle = self.prop['ItalicAngle']
717 array = [
718 float(x) for x in (self.prop['FontMatrix']
719 .lstrip('[').rstrip(']').split())
720 ]
721 oldmatrix = np.eye(3, 3)
722 oldmatrix[0:3, 0] = array[::2]
723 oldmatrix[0:3, 1] = array[1::2]
724 modifier = np.eye(3, 3)
726 if 'slant' in effects:
727 slant = effects['slant']
728 fontname += '_Slant_%d' % int(1000 * slant)
729 italicangle = round(
730 float(italicangle) - np.arctan(slant) / np.pi * 180,
731 5
732 )
733 modifier[1, 0] = slant
735 if 'extend' in effects:
736 extend = effects['extend']
737 fontname += '_Extend_%d' % int(1000 * extend)
738 modifier[0, 0] = extend
740 newmatrix = np.dot(modifier, oldmatrix)
741 array[::2] = newmatrix[0:3, 0]
742 array[1::2] = newmatrix[0:3, 1]
743 fontmatrix = (
744 '[%s]' % ' '.join(_format_approx(x, 6) for x in array)
745 )
746 replacements = (
747 [(x, '/FontName/%s def' % fontname)
748 for x in self._pos['FontName']]
749 + [(x, '/ItalicAngle %a def' % italicangle)
750 for x in self._pos['ItalicAngle']]
751 + [(x, '/FontMatrix %s readonly def' % fontmatrix)
752 for x in self._pos['FontMatrix']]
753 + [(x, '') for x in self._pos.get('UniqueID', [])]
754 )
756 data = bytearray(self.parts[0])
757 data.extend(self.decrypted)
758 len0 = len(self.parts[0])
759 for (pos0, pos1), value in sorted(replacements, reverse=True):
760 data[pos0:pos1] = value.encode('ascii', 'replace')
761 if pos0 < len(self.parts[0]):
762 if pos1 >= len(self.parts[0]):
763 raise RuntimeError(
764 f"text to be replaced with {value} spans "
765 "the eexec boundary"
766 )
767 len0 += len(value) - pos1 + pos0
769 data = bytes(data)
770 return Type1Font((
771 data[:len0],
772 self._encrypt(data[len0:], 'eexec'),
773 self.parts[2]
774 ))
777_StandardEncoding = {
778 **{ord(letter): letter for letter in string.ascii_letters},
779 0: '.notdef',
780 32: 'space',
781 33: 'exclam',
782 34: 'quotedbl',
783 35: 'numbersign',
784 36: 'dollar',
785 37: 'percent',
786 38: 'ampersand',
787 39: 'quoteright',
788 40: 'parenleft',
789 41: 'parenright',
790 42: 'asterisk',
791 43: 'plus',
792 44: 'comma',
793 45: 'hyphen',
794 46: 'period',
795 47: 'slash',
796 48: 'zero',
797 49: 'one',
798 50: 'two',
799 51: 'three',
800 52: 'four',
801 53: 'five',
802 54: 'six',
803 55: 'seven',
804 56: 'eight',
805 57: 'nine',
806 58: 'colon',
807 59: 'semicolon',
808 60: 'less',
809 61: 'equal',
810 62: 'greater',
811 63: 'question',
812 64: 'at',
813 91: 'bracketleft',
814 92: 'backslash',
815 93: 'bracketright',
816 94: 'asciicircum',
817 95: 'underscore',
818 96: 'quoteleft',
819 123: 'braceleft',
820 124: 'bar',
821 125: 'braceright',
822 126: 'asciitilde',
823 161: 'exclamdown',
824 162: 'cent',
825 163: 'sterling',
826 164: 'fraction',
827 165: 'yen',
828 166: 'florin',
829 167: 'section',
830 168: 'currency',
831 169: 'quotesingle',
832 170: 'quotedblleft',
833 171: 'guillemotleft',
834 172: 'guilsinglleft',
835 173: 'guilsinglright',
836 174: 'fi',
837 175: 'fl',
838 177: 'endash',
839 178: 'dagger',
840 179: 'daggerdbl',
841 180: 'periodcentered',
842 182: 'paragraph',
843 183: 'bullet',
844 184: 'quotesinglbase',
845 185: 'quotedblbase',
846 186: 'quotedblright',
847 187: 'guillemotright',
848 188: 'ellipsis',
849 189: 'perthousand',
850 191: 'questiondown',
851 193: 'grave',
852 194: 'acute',
853 195: 'circumflex',
854 196: 'tilde',
855 197: 'macron',
856 198: 'breve',
857 199: 'dotaccent',
858 200: 'dieresis',
859 202: 'ring',
860 203: 'cedilla',
861 205: 'hungarumlaut',
862 206: 'ogonek',
863 207: 'caron',
864 208: 'emdash',
865 225: 'AE',
866 227: 'ordfeminine',
867 232: 'Lslash',
868 233: 'Oslash',
869 234: 'OE',
870 235: 'ordmasculine',
871 241: 'ae',
872 245: 'dotlessi',
873 248: 'lslash',
874 249: 'oslash',
875 250: 'oe',
876 251: 'germandbls',
877}