1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> 4 5import re 6from functools import lru_cache 7from typing import Dict, Generator, List, Optional, Tuple, cast 8 9from kitty.fast_data_types import ( 10 FC_DUAL, FC_MONO, FC_SLANT_ITALIC, FC_SLANT_ROMAN, FC_WEIGHT_BOLD, 11 FC_WEIGHT_REGULAR, FC_WIDTH_NORMAL, fc_list, fc_match as fc_match_impl, 12 fc_match_postscript_name, parse_font_feature 13) 14from kitty.options.types import Options 15from kitty.typing import FontConfigPattern 16from kitty.utils import log_error 17 18from . import ListedFont, FontFeature 19 20attr_map = {(False, False): 'font_family', 21 (True, False): 'bold_font', 22 (False, True): 'italic_font', 23 (True, True): 'bold_italic_font'} 24 25 26FontMap = Dict[str, Dict[str, List[FontConfigPattern]]] 27 28 29def create_font_map(all_fonts: Tuple[FontConfigPattern, ...]) -> FontMap: 30 ans: FontMap = {'family_map': {}, 'ps_map': {}, 'full_map': {}} 31 for x in all_fonts: 32 if 'path' not in x: 33 continue 34 f = (x.get('family') or '').lower() 35 full = (x.get('full_name') or '').lower() 36 ps = (x.get('postscript_name') or '').lower() 37 ans['family_map'].setdefault(f, []).append(x) 38 ans['ps_map'].setdefault(ps, []).append(x) 39 ans['full_map'].setdefault(full, []).append(x) 40 return ans 41 42 43@lru_cache() 44def all_fonts_map(monospaced: bool = True) -> FontMap: 45 if monospaced: 46 ans = fc_list(FC_DUAL) + fc_list(FC_MONO) 47 else: 48 # allow non-monospaced and bitmapped fonts as these are used for 49 # symbol_map 50 ans = fc_list(-1, True) 51 return create_font_map(ans) 52 53 54def list_fonts() -> Generator[ListedFont, None, None]: 55 for fd in fc_list(): 56 f = fd.get('family') 57 if f and isinstance(f, str): 58 fn_ = fd.get('full_name') 59 if fn_: 60 fn = str(fn_) 61 else: 62 fn = (f + ' ' + str(fd.get('style', ''))).strip() 63 is_mono = fd.get('spacing') in ('MONO', 'DUAL') 64 yield {'family': f, 'full_name': fn, 'postscript_name': str(fd.get('postscript_name', '')), 'is_monospace': is_mono} 65 66 67def family_name_to_key(family: str) -> str: 68 return re.sub(r'\s+', ' ', family.lower()) 69 70 71@lru_cache() 72def fc_match(family: str, bold: bool, italic: bool, spacing: int = FC_MONO) -> FontConfigPattern: 73 return fc_match_impl(family, bold, italic, spacing) 74 75 76def find_font_features(postscript_name: str) -> Tuple[FontFeature, ...]: 77 pat = fc_match_postscript_name(postscript_name) 78 79 if pat.get('postscript_name') != postscript_name or 'fontfeatures' not in pat: 80 return () 81 82 features = [] 83 for feat in pat['fontfeatures']: 84 try: 85 parsed = parse_font_feature(feat) 86 except ValueError: 87 log_error('Ignoring invalid font feature: {}'.format(feat)) 88 else: 89 features.append(FontFeature(feat, parsed)) 90 91 return tuple(features) 92 93 94def find_best_match(family: str, bold: bool = False, italic: bool = False, monospaced: bool = True) -> FontConfigPattern: 95 q = family_name_to_key(family) 96 font_map = all_fonts_map(monospaced) 97 98 def score(candidate: FontConfigPattern) -> Tuple[int, int, int]: 99 bold_score = abs((FC_WEIGHT_BOLD if bold else FC_WEIGHT_REGULAR) - candidate.get('weight', 0)) 100 italic_score = abs((FC_SLANT_ITALIC if italic else FC_SLANT_ROMAN) - candidate.get('slant', 0)) 101 monospace_match = 0 if candidate.get('spacing') == 'MONO' else 1 102 width_score = abs(candidate.get('width', FC_WIDTH_NORMAL) - FC_WIDTH_NORMAL) 103 104 return bold_score + italic_score, monospace_match, width_score 105 106 # First look for an exact match 107 for selector in ('ps_map', 'full_map', 'family_map'): 108 candidates = font_map[selector].get(q) 109 if not candidates: 110 continue 111 if len(candidates) == 1 and (bold or italic) and candidates[0].get('family') == candidates[0].get('full_name'): 112 # IBM Plex Mono does this, where the full name of the regular font 113 # face is the same as its family name 114 continue 115 candidates.sort(key=score) 116 return candidates[0] 117 118 # Use fc-match to see if we can find a monospaced font that matches family 119 for spacing in (FC_MONO, FC_DUAL): 120 possibility = fc_match(family, False, False, spacing) 121 for key, map_key in (('postscript_name', 'ps_map'), ('full_name', 'full_map'), ('family', 'family_map')): 122 val: Optional[str] = cast(Optional[str], possibility.get(key)) 123 if val: 124 candidates = font_map[map_key].get(family_name_to_key(val)) 125 if candidates: 126 if len(candidates) == 1: 127 # happens if the family name is an alias, so we search with 128 # the actual family name to see if we can find all the 129 # fonts in the family. 130 family_name_candidates = font_map['family_map'].get(family_name_to_key(candidates[0]['family'])) 131 if family_name_candidates and len(family_name_candidates) > 1: 132 candidates = family_name_candidates 133 return sorted(candidates, key=score)[0] 134 135 # Use fc-match with a generic family 136 family = 'monospace' if monospaced else 'sans-serif' 137 return fc_match(family, bold, italic) 138 139 140def resolve_family(f: str, main_family: str, bold: bool, italic: bool) -> str: 141 if (bold or italic) and f == 'auto': 142 f = main_family 143 return f 144 145 146def get_font_files(opts: Options) -> Dict[str, FontConfigPattern]: 147 ans: Dict[str, FontConfigPattern] = {} 148 for (bold, italic), attr in attr_map.items(): 149 rf = resolve_family(getattr(opts, attr), opts.font_family, bold, italic) 150 font = find_best_match(rf, bold, italic) 151 key = {(False, False): 'medium', 152 (True, False): 'bold', 153 (False, True): 'italic', 154 (True, True): 'bi'}[(bold, italic)] 155 ans[key] = font 156 return ans 157 158 159def font_for_family(family: str) -> Tuple[FontConfigPattern, bool, bool]: 160 ans = find_best_match(family, monospaced=False) 161 return ans, ans.get('weight', 0) >= FC_WEIGHT_BOLD, ans.get('slant', FC_SLANT_ROMAN) != FC_SLANT_ROMAN 162