mirror of
https://github.com/gryf/ebook-converter.git
synced 2026-01-02 08:32:26 +01:00
408 lines
13 KiB
Python
408 lines
13 KiB
Python
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, filter
|
|
|
|
|
|
__license__ = 'GPL v3'
|
|
__copyright__ = '2012, Kovid Goyal <kovid at kovidgoyal.net>'
|
|
__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()
|