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