# ---------------------------------------------------------------------------- # pyglet # Copyright (c) 2006-2008 Alex Holkner # Copyright (c) 2008-2022 pyglet contributors # All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions # are met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above copyright # notice, this list of conditions and the following disclaimer in # the documentation and/or other materials provided with the # distribution. # * Neither the name of pyglet nor the names of its # contributors may be used to endorse or promote products # derived from this software without specific prior written # permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. # ---------------------------------------------------------------------------- """ Implementation of the Truetype file format. Typical applications will not need to use this module directly; look at `pyglet.font` instead. References: * http://developer.apple.com/fonts/TTRefMan/RM06 * http://www.microsoft.com/typography/otspec """ import os import mmap import struct import codecs class TruetypeInfo: """Information about a single Truetype face. The class memory-maps the font file to read the tables, so it is vital that you call the `close` method to avoid large memory leaks. Once closed, you cannot call any of the ``get_*`` methods. Not all tables have been implemented yet (or likely ever will). Currently only the name and metric tables are read; in particular there is no glyph or hinting information. """ _name_id_lookup = { 'copyright': 0, 'family': 1, 'subfamily': 2, 'identifier': 3, 'name': 4, 'version': 5, 'postscript': 6, 'trademark': 7, 'manufacturer': 8, 'designer': 9, 'description': 10, 'vendor-url': 11, 'designer-url': 12, 'license': 13, 'license-url': 14, 'preferred-family': 16, 'preferred-subfamily': 17, 'compatible-name': 18, 'sample': 19, } _platform_id_lookup = { 'unicode': 0, 'macintosh': 1, 'iso': 2, 'microsoft': 3, 'custom': 4 } _microsoft_encoding_lookup = { 1: 'utf_16_be', 2: 'shift_jis', 4: 'big5', 6: 'johab', 10: 'utf_16_be' } _macintosh_encoding_lookup = { 0: 'mac_roman' } def __init__(self, filename): """Read the given TrueType file. :Parameters: `filename` The name of any Windows, OS2 or Macintosh Truetype file. The object must be closed (see `close`) after use. An exception will be raised if the file does not exist or cannot be read. """ assert filename, "must provide a font file name" length = os.stat(filename).st_size self._fileno = os.open(filename, os.O_RDONLY) if hasattr(mmap, 'MAP_SHARED'): self._data = mmap.mmap(self._fileno, length, mmap.MAP_SHARED, mmap.PROT_READ) else: self._data = mmap.mmap(self._fileno, length, None, mmap.ACCESS_READ) self._closed = False offsets = _read_offset_table(self._data, 0) self._tables = {} for table in _read_table_directory_entry.array(self._data, offsets.size, offsets.num_tables): self._tables[table.tag] = table self._names = None self._horizontal_metrics = None self._character_advances = None self._character_kernings = None self._glyph_kernings = None self._character_map = None self._glyph_map = None self._font_selection_flags = None self.header = _read_head_table(self._data, self._tables['head'].offset) self.horizontal_header = _read_horizontal_header(self._data, self._tables['hhea'].offset) def get_font_selection_flags(self): """Return the font selection flags, as defined in OS/2 table""" if not self._font_selection_flags: OS2_table = _read_OS2_table(self._data, self._tables['OS/2'].offset) self._font_selection_flags = OS2_table.fs_selection return self._font_selection_flags def is_bold(self): """Returns True iff the font describes itself as bold.""" return bool(self.get_font_selection_flags() & 0x20) def is_italic(self): """Returns True iff the font describes itself as italic.""" return bool(self.get_font_selection_flags() & 0x1) def get_names(self): """Returns a dictionary of names defined in the file. The key of each item is a tuple of ``platform_id``, ``name_id``, where each ID is the number as described in the Truetype format. The value of each item is a tuple of ``encoding_id``, ``language_id``, ``value``, where ``value`` is an encoded string. """ if self._names: return self._names naming_table = _read_naming_table(self._data, self._tables['name'].offset) name_records = _read_name_record.array( self._data, self._tables['name'].offset + naming_table.size, naming_table.count) storage = naming_table.string_offset + self._tables['name'].offset self._names = {} for record in name_records: value = self._data[record.offset + storage: record.offset + storage + record.length] key = record.platform_id, record.name_id value = (record.encoding_id, record.language_id, value) if key not in self._names: self._names[key] = [] self._names[key].append(value) return self._names def get_name(self, name, platform=None, languages=None): """Returns the value of the given name in this font. :Parameters: `name` Either an integer, representing the name_id desired (see font format); or a string describing it, see below for valid names. `platform` Platform for the requested name. Can be the integer ID, or a string describing it. By default, the Microsoft platform is searched first, then Macintosh. `languages` A list of language IDs to search. The first language which defines the requested name will be used. By default, all English dialects are searched. If the name is not found, ``None`` is returned. If the name is found, the value will be decoded and returned as a unicode string. Currently only some common encodings are supported. Valid names to request are (supply as a string):: 'copyright' 'family' 'subfamily' 'identifier' 'name' 'version' 'postscript' 'trademark' 'manufacturer' 'designer' 'description' 'vendor-url' 'designer-url' 'license' 'license-url' 'preferred-family' 'preferred-subfamily' 'compatible-name' 'sample' Valid platforms to request are (supply as a string):: 'unicode' 'macintosh' 'iso' 'microsoft' 'custom' """ names = self.get_names() if type(name) == str: name = self._name_id_lookup[name] if not platform: for platform in ('microsoft', 'macintosh'): value = self.get_name(name, platform, languages) if value: return value if type(platform) == str: platform = self._platform_id_lookup[platform] if not (platform, name) in names: return None if platform == 3: # setup for microsoft encodings = self._microsoft_encoding_lookup if not languages: # Default to english languages for microsoft languages = (0x409, 0x809, 0xc09, 0x1009, 0x1409, 0x1809) elif platform == 1: # setup for macintosh encodings = self.__macintosh_encoding_lookup if not languages: # Default to english for macintosh languages = (0,) for record in names[(platform, name)]: if record[1] in languages and record[0] in encodings: decoder = codecs.getdecoder(encodings[record[0]]) return decoder(record[2])[0] return None def get_horizontal_metrics(self): """Return all horizontal metric entries in table format.""" if not self._horizontal_metrics: ar = _read_long_hor_metric.array(self._data, self._tables['hmtx'].offset, self.horizontal_header.number_of_h_metrics) self._horizontal_metrics = ar return self._horizontal_metrics def get_character_advances(self): """Return a dictionary of character->advance. They key of the dictionary is a unit-length unicode string, and the value is a float giving the horizontal advance in em. """ if self._character_advances: return self._character_advances ga = self.get_glyph_advances() gmap = self.get_glyph_map() self._character_advances = {} for i in range(len(ga)): if i in gmap and not gmap[i] in self._character_advances: self._character_advances[gmap[i]] = ga[i] return self._character_advances def get_glyph_advances(self): """Return a dictionary of glyph->advance. They key of the dictionary is the glyph index and the value is a float giving the horizontal advance in em. """ hm = self.get_horizontal_metrics() return [float(m.advance_width) / self.header.units_per_em for m in hm] def get_character_kernings(self): """Return a dictionary of (left,right)->kerning The key of the dictionary is a tuple of ``(left, right)`` where each element is a unit-length unicode string. The value of the dictionary is the horizontal pairwise kerning in em. """ if not self._character_kernings: gmap = self.get_glyph_map() kerns = self.get_glyph_kernings() self._character_kernings = {} for pair, value in kerns.items(): lglyph, rglyph = pair lchar = lglyph in gmap and gmap[lglyph] or None rchar = rglyph in gmap and gmap[rglyph] or None if lchar and rchar: self._character_kernings[(lchar, rchar)] = value return self._character_kernings def get_glyph_kernings(self): """Return a dictionary of (left,right)->kerning The key of the dictionary is a tuple of ``(left, right)`` where each element is a glyph index. The value of the dictionary is the horizontal pairwise kerning in em. """ if self._glyph_kernings: return self._glyph_kernings header = \ _read_kern_header_table(self._data, self._tables['kern'].offset) offset = self._tables['kern'].offset + header.size kernings = {} for i in range(header.n_tables): header = _read_kern_subtable_header(self._data, offset) if header.coverage & header.horizontal_mask \ and not header.coverage & header.minimum_mask \ and not header.coverage & header.perpendicular_mask: if header.coverage & header.format_mask == 0: self._add_kernings_format0(kernings, offset + header.size) offset += header.length self._glyph_kernings = kernings return kernings def _add_kernings_format0(self, kernings, offset): header = _read_kern_subtable_format0(self._data, offset) kerning_pairs = _read_kern_subtable_format0Pair.array(self._data, offset + header.size, header.n_pairs) for pair in kerning_pairs: if (pair.left, pair.right) in kernings: kernings[(pair.left, pair.right)] += pair.value \ / float(self.header.units_per_em) else: kernings[(pair.left, pair.right)] = pair.value \ / float(self.header.units_per_em) def get_glyph_map(self): """Calculate and return a reverse character map. Returns a dictionary where the key is a glyph index and the value is a unit-length unicode string. """ if self._glyph_map: return self._glyph_map cmap = self.get_character_map() self._glyph_map = {} for ch, glyph in cmap.items(): if not glyph in self._glyph_map: self._glyph_map[glyph] = ch return self._glyph_map def get_character_map(self): """Return the character map. Returns a dictionary where the key is a unit-length unicode string and the value is a glyph index. Currently only format 4 character maps are read. """ if self._character_map: return self._character_map cmap = _read_cmap_header(self._data, self._tables['cmap'].offset) records = _read_cmap_encoding_record.array(self._data, self._tables['cmap'].offset + cmap.size, cmap.num_tables) self._character_map = {} for record in records: if record.platform_id == 3 and record.encoding_id == 1: # Look at Windows Unicode charmaps only offset = self._tables['cmap'].offset + record.offset format_header = _read_cmap_format_header(self._data, offset) if format_header.format == 4: self._character_map = \ self._get_character_map_format4(offset) break return self._character_map def _get_character_map_format4(self, offset): # This is absolutely, without question, the *worst* file # format ever. Whoever the fuckwit is that thought this up is # a fuckwit. header = _read_cmap_format4Header(self._data, offset) seg_count = header.seg_count_x2 // 2 array_size = struct.calcsize('>%dH' % seg_count) end_count = self._read_array('>%dH' % seg_count, offset + header.size) start_count = self._read_array('>%dH' % seg_count, offset + header.size + array_size + 2) id_delta = self._read_array('>%dh' % seg_count, offset + header.size + array_size + 2 + array_size) id_range_offset_address = \ offset + header.size + array_size + 2 + array_size + array_size id_range_offset = self._read_array('>%dH' % seg_count, id_range_offset_address) character_map = {} for i in range(0, seg_count): if id_range_offset[i] != 0: if id_range_offset[i] == 65535: continue # Hack around a dodgy font (babelfish.ttf) for c in range(start_count[i], end_count[i] + 1): addr = id_range_offset[i] + 2 * (c - start_count[i]) + \ id_range_offset_address + 2 * i g = struct.unpack('>H', self._data[addr:addr + 2])[0] if g != 0: character_map[chr(c)] = (g + id_delta[i]) % 65536 else: for c in range(start_count[i], end_count[i] + 1): g = (c + id_delta[i]) % 65536 if g != 0: character_map[chr(c)] = g return character_map def _read_array(self, format, offset): size = struct.calcsize(format) return struct.unpack(format, self._data[offset:offset + size]) def close(self): """Close the font file. This is a good idea, since the entire file is memory mapped in until this method is called. After closing cannot rely on the ``get_*`` methods. """ self._data.close() os.close(self._fileno) self._closed = True def __del__(self): if not self._closed: self.close() def _read_table(*entries): """ Generic table constructor used for table formats listed at end of file.""" fmt = '>' names = [] for entry in entries: name, entry_type = entry.split(':') names.append(name) fmt += entry_type class TableClass: size = struct.calcsize(fmt) def __init__(self, data, offset): items = struct.unpack(fmt, data[offset:offset + self.size]) self.pairs = list(zip(names, items)) for pname, pvalue in self.pairs: if isinstance(pvalue, bytes): pvalue = pvalue.decode('utf-8') setattr(self, pname, pvalue) def __repr__(self): return '{'+', '.join(['%s = %s' % (pname, pvalue) for pname, pvalue in self.pairs])+'}' @staticmethod def array(data, offset, count): tables = [] for i in range(count): tables.append(TableClass(data, offset)) offset += TableClass.size return tables return TableClass # Table formats (see references) _read_offset_table = _read_table('scalertype:I', 'num_tables:H', 'search_range:H', 'entry_selector:H', 'range_shift:H') _read_table_directory_entry = _read_table('tag:4s', 'check_sum:I', 'offset:I', 'length:I') _read_head_table = _read_table('version:i', 'font_revision:i', 'check_sum_adjustment:L', 'magic_number:L', 'flags:H', 'units_per_em:H', 'created:Q', 'modified:Q', 'x_min:h', 'y_min:h', 'x_max:h', 'y_max:h', 'mac_style:H', 'lowest_rec_p_pEM:H', 'font_direction_hint:h', 'index_to_loc_format:h', 'glyph_data_format:h') _read_OS2_table = _read_table('version:H', 'x_avg_char_width:h', 'us_weight_class:H', 'us_width_class:H', 'fs_type:H', 'y_subscript_x_size:h', 'y_subscript_y_size:h', 'y_subscript_x_offset:h', 'y_subscript_y_offset:h', 'y_superscript_x_size:h', 'y_superscript_y_size:h', 'y_superscript_x_offset:h', 'y_superscript_y_offset:h', 'y_strikeout_size:h', 'y_strikeout_position:h', 's_family_class:h', 'panose1:B', 'panose2:B', 'panose3:B', 'panose4:B', 'panose5:B', 'panose6:B', 'panose7:B', 'panose8:B', 'panose9:B', 'panose10:B', 'ul_unicode_range1:L', 'ul_unicode_range2:L', 'ul_unicode_range3:L', 'ul_unicode_range4:L', 'ach_vend_id:I', 'fs_selection:H', 'us_first_char_index:H', 'us_last_char_index:H', 's_typo_ascender:h', 's_typo_descender:h', 's_typo_line_gap:h', 'us_win_ascent:H', 'us_win_descent:H', 'ul_code_page_range1:L', 'ul_code_page_range2:L', 'sx_height:h', 's_cap_height:h', 'us_default_char:H', 'us_break_char:H', 'us_max_context:H') _read_kern_header_table = _read_table('version_num:H', 'n_tables:H') _read_kern_subtable_header = _read_table('version:H', 'length:H', 'coverage:H') _read_kern_subtable_header.horizontal_mask = 0x1 _read_kern_subtable_header.minimum_mask = 0x2 _read_kern_subtable_header.perpendicular_mask = 0x4 _read_kern_subtable_header.override_mask = 0x5 _read_kern_subtable_header.format_mask = 0xf0 _read_kern_subtable_format0 = _read_table('n_pairs:H', 'search_range:H', 'entry_selector:H', 'range_shift:H') _read_kern_subtable_format0Pair = _read_table('left:H', 'right:H', 'value:h') _read_cmap_header = _read_table('version:H', 'num_tables:H') _read_cmap_encoding_record = _read_table('platform_id:H', 'encoding_id:H', 'offset:L') _read_cmap_format_header = _read_table('format:H', 'length:H') _read_cmap_format4Header = _read_table('format:H', 'length:H', 'language:H', 'seg_count_x2:H', 'search_range:H', 'entry_selector:H', 'range_shift:H') _read_horizontal_header = _read_table('version:i', 'Advance:h', 'Descender:h', 'LineGap:h', 'advance_width_max:H', 'min_left_side_bearing:h', 'min_right_side_bearing:h', 'x_max_extent:h', 'caret_slope_rise:h', 'caret_slope_run:h', 'caret_offset:h', 'reserved1:h', 'reserved2:h', 'reserved3:h', 'reserved4:h', 'metric_data_format:h', 'number_of_h_metrics:H') _read_long_hor_metric = _read_table('advance_width:H', 'lsb:h') _read_naming_table = _read_table('format:H', 'count:H', 'string_offset:H') _read_name_record = _read_table('platform_id:H', 'encoding_id:H', 'language_id:H', 'name_id:H', 'length:H', 'offset:H')