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