import os from collections import defaultdict from threading import Thread from ebook_converter import walk, prints, as_unicode from ebook_converter.constants import (config_dir, iswindows, isosx, plugins, DEBUG, isworker, filesystem_encoding) from ebook_converter.utils.fonts.metadata import FontMetadata, UnsupportedFont from ebook_converter.polyglot.builtins import itervalues, unicode_type __license__ = 'GPL v3' __copyright__ = '2012, Kovid Goyal ' __docformat__ = 'restructuredtext en' class NoFonts(ValueError): pass # Font dirs {{{ def default_font_dirs(): return [ '/opt/share/fonts', '/usr/share/fonts', '/usr/local/share/fonts', os.path.expanduser('~/.local/share/fonts'), os.path.expanduser('~/.fonts') ] def fc_list(): import ctypes from ctypes.util import find_library lib = find_library('fontconfig') if lib is None: return default_font_dirs() try: lib = ctypes.CDLL(lib) except: return default_font_dirs() prototype = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p) try: get_font_dirs = prototype(('FcConfigGetFontDirs', lib)) except (AttributeError): return default_font_dirs() prototype = ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_void_p) try: next_dir = prototype(('FcStrListNext', lib)) except (AttributeError): return default_font_dirs() prototype = ctypes.CFUNCTYPE(None, ctypes.c_void_p) try: end = prototype(('FcStrListDone', lib)) except (AttributeError): return default_font_dirs() str_list = get_font_dirs(ctypes.c_void_p()) if not str_list: return default_font_dirs() ans = [] while True: d = next_dir(str_list) if not d: break if d: try: ans.append(d.decode(filesystem_encoding)) except ValueError: prints('Ignoring undecodeable font path: %r' % d) continue end(str_list) if len(ans) < 3: return default_font_dirs() parents, visited = [], set() for f in ans: path = os.path.normpath(os.path.abspath(os.path.realpath(f))) if path == '/': continue head, tail = os.path.split(path) while head and tail: if head in visited: break head, tail = os.path.split(head) else: parents.append(path) visited.add(path) return parents def font_dirs(): if iswindows: winutil, err = plugins['winutil'] if err: raise RuntimeError('Failed to load winutil: %s'%err) try: return [winutil.special_folder_path(winutil.CSIDL_FONTS)] except ValueError: return [r'C:\Windows\Fonts'] if isosx: return [ '/Library/Fonts', '/System/Library/Fonts', '/usr/share/fonts', '/var/root/Library/Fonts', os.path.expanduser('~/.fonts'), os.path.expanduser('~/Library/Fonts'), ] return fc_list() # }}} # Build font family maps {{{ def font_priority(font): ''' Try to ensure that the "Regular" face is the first font for a given family. ''' style_normal = font['font-style'] == 'normal' width_normal = font['font-stretch'] == 'normal' weight_normal = font['font-weight'] == 'normal' num_normal = sum(filter(None, (style_normal, width_normal, weight_normal))) subfamily_name = (font['wws_subfamily_name'] or font['preferred_subfamily_name'] or font['subfamily_name']) if num_normal == 3 and subfamily_name == 'Regular': return 0 if num_normal == 3: return 1 if subfamily_name == 'Regular': return 2 return 3 + (3 - num_normal) def path_significance(path, folders): path = os.path.normcase(os.path.abspath(path)) for i, q in enumerate(folders): if path.startswith(q): return i return -1 def build_families(cached_fonts, folders, family_attr='font-family'): families = defaultdict(list) for font in itervalues(cached_fonts): if not font: continue lf = (font.get(family_attr) or '').lower() if lf: families[lf].append(font) for fonts in itervalues(families): # Look for duplicate font files and choose the copy that is from a # more significant font directory (prefer user directories over # system directories). fmap = {} remove = [] for font in fonts: fingerprint = (font['font-family'].lower(), font['font-weight'], font['font-stretch'], font['font-style']) if fingerprint in fmap: opath = fmap[fingerprint]['path'] npath = font['path'] if path_significance(npath, folders) >= path_significance(opath, folders): remove.append(fmap[fingerprint]) fmap[fingerprint] = font else: remove.append(font) else: fmap[fingerprint] = font for fnt in remove: fonts.remove(fnt) fonts.sort(key=font_priority) font_family_map = dict.copy(families) font_families = tuple(sorted((font[0]['font-family'] for font in itervalues(font_family_map)))) return font_family_map, font_families # }}} class FontScanner(Thread): CACHE_VERSION = 2 def __init__(self, folders=[], allowed_extensions={'ttf', 'otf'}): Thread.__init__(self) self.folders = folders + font_dirs() self.folders = [os.path.normcase(os.path.abspath(font)) for font in self.folders] self.font_families = () self.allowed_extensions = allowed_extensions # API {{{ def find_font_families(self): self.join() return self.font_families def fonts_for_family(self, family): ''' Return a list of the faces belonging to the specified family. The first face is the "Regular" face of family. Each face is a dictionary with many keys, the most important of which are: path, font-family, font-weight, font-style, font-stretch. The font-* properties follow the CSS 3 Fonts specification. ''' self.join() try: return self.font_family_map[family.lower()] except KeyError: raise NoFonts('No fonts found for the family: %r'%family) def legacy_fonts_for_family(self, family): ''' Return a simple set of regular, bold, italic and bold-italic faces for the specified family. Returns a dictionary with each element being a 2-tuple of (path to font, full font name) and the keys being: normal, bold, italic, bi. ''' ans = {} try: faces = self.fonts_for_family(family) except NoFonts: return ans for i, face in enumerate(faces): if i == 0: key = 'normal' elif face['font-style'] in {'italic', 'oblique'}: key = 'bi' if face['font-weight'] == 'bold' else 'italic' elif face['font-weight'] == 'bold': key = 'bold' else: continue ans[key] = (face['path'], face['full_name']) return ans def get_font_data(self, font_or_path): path = font_or_path if isinstance(font_or_path, dict): path = font_or_path['path'] with lopen(path, 'rb') as f: return f.read() def find_font_for_text(self, text, allowed_families={'serif', 'sans-serif'}, preferred_families=('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy')): ''' Find a font on the system capable of rendering the given text. Returns a font family (as given by fonts_for_family()) that has a "normal" font and that can render the supplied text. If no such font exists, returns None. :return: (family name, faces) or None, None ''' from ebook_converter.utils.fonts.utils import (supports_text, panose_to_css_generic_family, get_printable_characters) if not isinstance(text, unicode_type): raise TypeError(u'%r is not unicode'%text) text = get_printable_characters(text) found = {} def filter_faces(font): try: raw = self.get_font_data(font) return supports_text(raw, text) except: pass return False for family in self.find_font_families(): faces = list(filter(filter_faces, self.fonts_for_family(family))) if not faces: continue generic_family = panose_to_css_generic_family(faces[0]['panose']) if generic_family in allowed_families or generic_family == preferred_families[0]: return (family, faces) elif generic_family not in found: found[generic_family] = (family, faces) for f in preferred_families: if f in found: return found[f] return None, None # }}} def reload_cache(self): if not hasattr(self, 'cache'): from ebook_converter.utils.config import JSONConfig self.cache = JSONConfig('fonts/scanner_cache') else: self.cache.refresh() if self.cache.get('version', None) != self.CACHE_VERSION: self.cache.clear() self.cached_fonts = self.cache.get('fonts', {}) def run(self): self.do_scan() def do_scan(self): self.reload_cache() if isworker: # Dont scan font files in worker processes, use whatever is # cached. Font files typically dont change frequently enough to # justify a rescan in a worker process. self.build_families() return cached_fonts = self.cached_fonts.copy() self.cached_fonts.clear() for folder in self.folders: if not os.path.isdir(folder): continue try: files = tuple(walk(folder)) except EnvironmentError as e: if DEBUG: prints('Failed to walk font folder:', folder, as_unicode(e)) continue for candidate in files: if (candidate.rpartition('.')[-1].lower() not in self.allowed_extensions or not os.path.isfile(candidate)): continue candidate = os.path.normcase(os.path.abspath(candidate)) try: s = os.stat(candidate) except EnvironmentError: continue fileid = '{0}||{1}:{2}'.format(candidate, s.st_size, s.st_mtime) if fileid in cached_fonts: # Use previously cached metadata, since the file size and # last modified timestamp have not changed. self.cached_fonts[fileid] = cached_fonts[fileid] continue try: self.read_font_metadata(candidate, fileid) except Exception as e: if DEBUG: prints('Failed to read metadata from font file:', candidate, as_unicode(e)) continue if frozenset(cached_fonts) != frozenset(self.cached_fonts): # Write out the cache only if some font files have changed self.write_cache() self.build_families() def build_families(self): self.font_family_map, self.font_families = build_families(self.cached_fonts, self.folders) def write_cache(self): with self.cache: self.cache['version'] = self.CACHE_VERSION self.cache['fonts'] = self.cached_fonts def force_rescan(self): self.cached_fonts = {} self.write_cache() def read_font_metadata(self, path, fileid): with lopen(path, 'rb') as f: try: fm = FontMetadata(f) except UnsupportedFont: self.cached_fonts[fileid] = {} else: data = fm.to_dict() data['path'] = path self.cached_fonts[fileid] = data def dump_fonts(self): self.join() for family in self.font_families: prints(family) for font in self.fonts_for_family(family): prints('\t%s: %s'%(font['full_name'], font['path'])) prints(end='\t') for key in ('font-stretch', 'font-weight', 'font-style'): prints('%s: %s'%(key, font[key]), end=' ') prints() prints('\tSub-family:', font['wws_subfamily_name'] or font['preferred_subfamily_name'] or font['subfamily_name']) prints() prints() font_scanner = FontScanner() font_scanner.start() def force_rescan(): font_scanner.join() font_scanner.force_rescan() font_scanner.run() if __name__ == '__main__': font_scanner.dump_fonts()