1#!/usr/local/bin/python3.8 2# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:fdm=marker:ai 3 4 5__license__ = 'GPL v3' 6__copyright__ = '2012, Kovid Goyal <kovid at kovidgoyal.net>' 7__docformat__ = 'restructuredtext en' 8 9import os 10from collections import defaultdict 11from threading import Thread 12 13from calibre import walk, prints, as_unicode 14from calibre.constants import (config_dir, iswindows, ismacos, DEBUG, 15 isworker, filesystem_encoding) 16from calibre.utils.fonts.metadata import FontMetadata, UnsupportedFont 17from calibre.utils.icu import sort_key 18from polyglot.builtins import itervalues 19 20 21class NoFonts(ValueError): 22 pass 23 24# Font dirs {{{ 25 26 27def default_font_dirs(): 28 return [ 29 '/opt/share/fonts', 30 '/usr/share/fonts', 31 '/usr/local/share/fonts', 32 os.path.expanduser('~/.local/share/fonts'), 33 os.path.expanduser('~/.fonts') 34 ] 35 36 37def fc_list(): 38 import ctypes 39 from ctypes.util import find_library 40 41 lib = find_library('fontconfig') 42 if lib is None: 43 return default_font_dirs() 44 try: 45 lib = ctypes.CDLL(lib) 46 except: 47 return default_font_dirs() 48 49 prototype = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p) 50 try: 51 get_font_dirs = prototype(('FcConfigGetFontDirs', lib)) 52 except (AttributeError): 53 return default_font_dirs() 54 prototype = ctypes.CFUNCTYPE(ctypes.c_char_p, ctypes.c_void_p) 55 try: 56 next_dir = prototype(('FcStrListNext', lib)) 57 except (AttributeError): 58 return default_font_dirs() 59 60 prototype = ctypes.CFUNCTYPE(None, ctypes.c_void_p) 61 try: 62 end = prototype(('FcStrListDone', lib)) 63 except (AttributeError): 64 return default_font_dirs() 65 66 str_list = get_font_dirs(ctypes.c_void_p()) 67 if not str_list: 68 return default_font_dirs() 69 70 ans = [] 71 while True: 72 d = next_dir(str_list) 73 if not d: 74 break 75 if d: 76 try: 77 ans.append(d.decode(filesystem_encoding)) 78 except ValueError: 79 prints('Ignoring undecodeable font path: %r' % d) 80 continue 81 end(str_list) 82 if len(ans) < 3: 83 return default_font_dirs() 84 85 parents, visited = [], set() 86 for f in ans: 87 path = os.path.normpath(os.path.abspath(os.path.realpath(f))) 88 if path == '/': 89 continue 90 head, tail = os.path.split(path) 91 while head and tail: 92 if head in visited: 93 break 94 head, tail = os.path.split(head) 95 else: 96 parents.append(path) 97 visited.add(path) 98 return parents 99 100 101def font_dirs(): 102 if iswindows: 103 from calibre_extensions import winutil 104 paths = {os.path.normcase(r'C:\Windows\Fonts')} 105 for which in (winutil.CSIDL_FONTS, winutil.CSIDL_LOCAL_APPDATA, winutil.CSIDL_APPDATA): 106 try: 107 path = winutil.special_folder_path(which) 108 except ValueError: 109 continue 110 if which != winutil.CSIDL_FONTS: 111 path = os.path.join(path, r'Microsoft\Windows\Fonts') 112 paths.add(os.path.normcase(path)) 113 return list(paths) 114 if ismacos: 115 return [ 116 '/Library/Fonts', 117 '/System/Library/Fonts', 118 '/usr/share/fonts', 119 '/var/root/Library/Fonts', 120 os.path.expanduser('~/.fonts'), 121 os.path.expanduser('~/Library/Fonts'), 122 ] 123 return fc_list() 124# }}} 125 126# Build font family maps {{{ 127 128 129def font_priority(font): 130 ''' 131 Try to ensure that the "Regular" face is the first font for a given 132 family. 133 ''' 134 style_normal = font['font-style'] == 'normal' 135 width_normal = font['font-stretch'] == 'normal' 136 weight_normal = font['font-weight'] == 'normal' 137 num_normal = sum(filter(None, (style_normal, width_normal, 138 weight_normal))) 139 subfamily_name = (font['wws_subfamily_name'] or 140 font['preferred_subfamily_name'] or font['subfamily_name']) 141 if num_normal == 3 and subfamily_name == 'Regular': 142 return 0 143 if num_normal == 3: 144 return 1 145 if subfamily_name == 'Regular': 146 return 2 147 return 3 + (3 - num_normal) 148 149 150def path_significance(path, folders): 151 path = os.path.normcase(os.path.abspath(path)) 152 for i, q in enumerate(folders): 153 if path.startswith(q): 154 return i 155 return -1 156 157 158def build_families(cached_fonts, folders, family_attr='font-family'): 159 families = defaultdict(list) 160 for f in itervalues(cached_fonts): 161 if not f: 162 continue 163 lf = icu_lower(f.get(family_attr) or '') 164 if lf: 165 families[lf].append(f) 166 167 for fonts in itervalues(families): 168 # Look for duplicate font files and choose the copy that is from a 169 # more significant font directory (prefer user directories over 170 # system directories). 171 fmap = {} 172 remove = [] 173 for f in fonts: 174 fingerprint = (icu_lower(f['font-family']), f['font-weight'], 175 f['font-stretch'], f['font-style']) 176 if fingerprint in fmap: 177 opath = fmap[fingerprint]['path'] 178 npath = f['path'] 179 if path_significance(npath, folders) >= path_significance(opath, folders): 180 remove.append(fmap[fingerprint]) 181 fmap[fingerprint] = f 182 else: 183 remove.append(f) 184 else: 185 fmap[fingerprint] = f 186 for font in remove: 187 fonts.remove(font) 188 fonts.sort(key=font_priority) 189 190 font_family_map = dict.copy(families) 191 font_families = tuple(sorted((f[0]['font-family'] for f in 192 itervalues(font_family_map)), key=sort_key)) 193 return font_family_map, font_families 194# }}} 195 196 197class FontScanner(Thread): 198 199 CACHE_VERSION = 2 200 201 def __init__(self, folders=[], allowed_extensions={'ttf', 'otf'}): 202 Thread.__init__(self) 203 self.folders = folders + font_dirs() + [os.path.join(config_dir, 'fonts'), 204 P('fonts/liberation')] 205 self.folders = [os.path.normcase(os.path.abspath(f)) for f in 206 self.folders] 207 self.font_families = () 208 self.allowed_extensions = allowed_extensions 209 210 # API {{{ 211 def find_font_families(self): 212 self.join() 213 return self.font_families 214 215 def fonts_for_family(self, family): 216 ''' 217 Return a list of the faces belonging to the specified family. The first 218 face is the "Regular" face of family. Each face is a dictionary with 219 many keys, the most important of which are: path, font-family, 220 font-weight, font-style, font-stretch. The font-* properties follow the 221 CSS 3 Fonts specification. 222 ''' 223 self.join() 224 try: 225 return self.font_family_map[icu_lower(family)] 226 except KeyError: 227 raise NoFonts('No fonts found for the family: %r'%family) 228 229 def legacy_fonts_for_family(self, family): 230 ''' 231 Return a simple set of regular, bold, italic and bold-italic faces for 232 the specified family. Returns a dictionary with each element being a 233 2-tuple of (path to font, full font name) and the keys being: normal, 234 bold, italic, bi. 235 ''' 236 ans = {} 237 try: 238 faces = self.fonts_for_family(family) 239 except NoFonts: 240 return ans 241 for i, face in enumerate(faces): 242 if i == 0: 243 key = 'normal' 244 elif face['font-style'] in {'italic', 'oblique'}: 245 key = 'bi' if face['font-weight'] == 'bold' else 'italic' 246 elif face['font-weight'] == 'bold': 247 key = 'bold' 248 else: 249 continue 250 ans[key] = (face['path'], face['full_name']) 251 return ans 252 253 def get_font_data(self, font_or_path): 254 path = font_or_path 255 if isinstance(font_or_path, dict): 256 path = font_or_path['path'] 257 with lopen(path, 'rb') as f: 258 return f.read() 259 260 def find_font_for_text(self, text, allowed_families={'serif', 'sans-serif'}, 261 preferred_families=('serif', 'sans-serif', 'monospace', 'cursive', 'fantasy')): 262 ''' 263 Find a font on the system capable of rendering the given text. 264 265 Returns a font family (as given by fonts_for_family()) that has a 266 "normal" font and that can render the supplied text. If no such font 267 exists, returns None. 268 269 :return: (family name, faces) or None, None 270 ''' 271 from calibre.utils.fonts.utils import (supports_text, 272 panose_to_css_generic_family, get_printable_characters) 273 if not isinstance(text, str): 274 raise TypeError('%r is not unicode'%text) 275 text = get_printable_characters(text) 276 found = {} 277 278 def filter_faces(font): 279 try: 280 raw = self.get_font_data(font) 281 return supports_text(raw, text) 282 except: 283 pass 284 return False 285 286 for family in self.find_font_families(): 287 faces = list(filter(filter_faces, self.fonts_for_family(family))) 288 if not faces: 289 continue 290 generic_family = panose_to_css_generic_family(faces[0]['panose']) 291 if generic_family in allowed_families or generic_family == preferred_families[0]: 292 return (family, faces) 293 elif generic_family not in found: 294 found[generic_family] = (family, faces) 295 296 for f in preferred_families: 297 if f in found: 298 return found[f] 299 return None, None 300 # }}} 301 302 def reload_cache(self): 303 if not hasattr(self, 'cache'): 304 from calibre.utils.config import JSONConfig 305 self.cache = JSONConfig('fonts/scanner_cache') 306 else: 307 self.cache.refresh() 308 if self.cache.get('version', None) != self.CACHE_VERSION: 309 self.cache.clear() 310 self.cached_fonts = self.cache.get('fonts', {}) 311 312 def run(self): 313 self.do_scan() 314 315 def do_scan(self): 316 self.reload_cache() 317 318 if isworker: 319 # Dont scan font files in worker processes, use whatever is 320 # cached. Font files typically dont change frequently enough to 321 # justify a rescan in a worker process. 322 self.build_families() 323 return 324 325 cached_fonts = self.cached_fonts.copy() 326 self.cached_fonts.clear() 327 for folder in self.folders: 328 if not os.path.isdir(folder): 329 continue 330 try: 331 files = tuple(walk(folder)) 332 except OSError as e: 333 if DEBUG: 334 prints('Failed to walk font folder:', folder, 335 as_unicode(e)) 336 continue 337 for candidate in files: 338 if (candidate.rpartition('.')[-1].lower() not in self.allowed_extensions or not os.path.isfile(candidate)): 339 continue 340 candidate = os.path.normcase(os.path.abspath(candidate)) 341 try: 342 s = os.stat(candidate) 343 except OSError: 344 continue 345 fileid = '{}||{}:{}'.format(candidate, s.st_size, s.st_mtime) 346 if fileid in cached_fonts: 347 # Use previously cached metadata, since the file size and 348 # last modified timestamp have not changed. 349 self.cached_fonts[fileid] = cached_fonts[fileid] 350 continue 351 try: 352 self.read_font_metadata(candidate, fileid) 353 except Exception as e: 354 if DEBUG: 355 prints('Failed to read metadata from font file:', 356 candidate, as_unicode(e)) 357 continue 358 359 if frozenset(cached_fonts) != frozenset(self.cached_fonts): 360 # Write out the cache only if some font files have changed 361 self.write_cache() 362 363 self.build_families() 364 365 def build_families(self): 366 self.font_family_map, self.font_families = build_families(self.cached_fonts, self.folders) 367 368 def write_cache(self): 369 with self.cache: 370 self.cache['version'] = self.CACHE_VERSION 371 self.cache['fonts'] = self.cached_fonts 372 373 def force_rescan(self): 374 self.cached_fonts = {} 375 self.write_cache() 376 377 def read_font_metadata(self, path, fileid): 378 with lopen(path, 'rb') as f: 379 try: 380 fm = FontMetadata(f) 381 except UnsupportedFont: 382 self.cached_fonts[fileid] = {} 383 else: 384 data = fm.to_dict() 385 data['path'] = path 386 self.cached_fonts[fileid] = data 387 388 def dump_fonts(self): 389 self.join() 390 for family in self.font_families: 391 prints(family) 392 for font in self.fonts_for_family(family): 393 prints('\t%s: %s'%(font['full_name'], font['path'])) 394 prints(end='\t') 395 for key in ('font-stretch', 'font-weight', 'font-style'): 396 prints('%s: %s'%(key, font[key]), end=' ') 397 prints() 398 prints('\tSub-family:', font['wws_subfamily_name'] or 399 font['preferred_subfamily_name'] or 400 font['subfamily_name']) 401 prints() 402 prints() 403 404 405font_scanner = FontScanner() 406font_scanner.start() 407 408 409def force_rescan(): 410 font_scanner.join() 411 font_scanner.force_rescan() 412 font_scanner.run() 413 414 415if __name__ == '__main__': 416 font_scanner.dump_fonts() 417