From c1828fa2a4b7980ab019677e8b55d5d32ff599fc Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Fri, 10 May 2024 10:34:41 +0530 Subject: [PATCH] Implement spec based selection for variable fonts --- kitty/fast_data_types.pyi | 1 + kitty/fontconfig.c | 5 +- kitty/fonts/common.py | 149 ++++++++++++++++++++++++++++++-------- kitty/fonts/core_text.py | 13 +++- kitty/fonts/fontconfig.py | 17 +++-- 5 files changed, 145 insertions(+), 40 deletions(-) diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index eca98d420..9d2ebc856 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -393,6 +393,7 @@ class FontConfigPattern(TypedDict): outline: bool color: bool variable: bool + named_instance: bool # The following two are used by C code to get a face from the pattern named_style: NotRequired[int] diff --git a/kitty/fontconfig.c b/kitty/fontconfig.c index 18bc6da92..fa0c38645 100644 --- a/kitty/fontconfig.c +++ b/kitty/fontconfig.c @@ -178,7 +178,7 @@ pattern_as_dict(FcPattern *pat) { #define S(which, key) G(FcChar8*, FcPatternGetString, which, PS, key, PyUnicode_FromString("")) #define LS(which, key) L(FcChar8*, FcPatternGetString, which, PS, key) #define I(which, key) G(int, FcPatternGetInteger, which, PyLong_FromLong, key, PyLong_FromUnsignedLong(0)) -#define B(which, key) G(int, FcPatternGetBool, which, pybool, key, increment_and_return(Py_False)) +#define B(which, key) G(FcBool, FcPatternGetBool, which, pybool, key, increment_and_return(Py_False)) #define E(which, key, conv) G(int, FcPatternGetInteger, which, conv, key, PyLong_FromUnsignedLong(0)) S(FC_FILE, path); S(FC_FAMILY, family); @@ -187,6 +187,7 @@ pattern_as_dict(FcPattern *pat) { S(FC_POSTSCRIPT_NAME, postscript_name); LS(FC_FONT_FEATURES, fontfeatures); B(FC_VARIABLE, variable); + B(FC_NAMED_INSTANCE, named_instance); I(FC_WEIGHT, weight); I(FC_WIDTH, width) I(FC_SLANT, slant); @@ -244,7 +245,7 @@ fc_list(PyObject UNUSED *self, PyObject *args, PyObject *kw) { } if (spacing > -1) AP(FcPatternAddInteger, FC_SPACING, spacing, "spacing"); if (only_variable) AP(FcPatternAddBool, FC_VARIABLE, FcTrue, "variable"); - os = FcObjectSetBuild(FC_FILE, FC_POSTSCRIPT_NAME, FC_FAMILY, FC_STYLE, FC_FULLNAME, FC_WEIGHT, FC_WIDTH, FC_SLANT, FC_HINT_STYLE, FC_INDEX, FC_HINTING, FC_SCALABLE, FC_OUTLINE, FC_COLOR, FC_SPACING, FC_VARIABLE, NULL); + os = FcObjectSetBuild(FC_FILE, FC_POSTSCRIPT_NAME, FC_FAMILY, FC_STYLE, FC_FULLNAME, FC_WEIGHT, FC_WIDTH, FC_SLANT, FC_HINT_STYLE, FC_INDEX, FC_HINTING, FC_SCALABLE, FC_OUTLINE, FC_COLOR, FC_SPACING, FC_VARIABLE, FC_NAMED_INSTANCE, NULL); if (!os) { PyErr_SetString(PyExc_ValueError, "Failed to create fontconfig object set"); goto end; } fs = FcFontList(NULL, pat, os); if (!fs) { PyErr_SetString(PyExc_ValueError, "Failed to create fontconfig font set"); goto end; } diff --git a/kitty/fonts/common.py b/kitty/fonts/common.py index af1c2657f..36e03894c 100644 --- a/kitty/fonts/common.py +++ b/kitty/fonts/common.py @@ -1,37 +1,36 @@ #!/usr/bin/env python # License: GPLv3 Copyright: 2024, Kovid Goyal -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Sequence, TypedDict, Union +from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Sequence, Tuple, TypedDict, Union from kitty.constants import is_macos +from kitty.fonts import Descriptor, DesignAxis, FontSpec, Scorer, VariableData, family_name_to_key from kitty.options.types import Options -from . import Descriptor, FontSpec, Scorer, VariableData, family_name_to_key - if TYPE_CHECKING: from kitty.fast_data_types import CTFace from kitty.fast_data_types import Face as FT_Face - FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map'] + FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map', 'variable_map'] FontMap = Dict[FontCollectionMapType, Dict[str, List[Descriptor]]] Face = Union[FT_Face, CTFace] def all_fonts_map(monospaced: bool) -> FontMap: ... def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: ... def find_best_match( - family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[Descriptor] = None + family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[Descriptor] = None, + prefer_variable: bool = False, ) -> Descriptor: ... def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospaced: bool = True) -> Descriptor: ... def face_from_descriptor(descriptor: Descriptor) -> Face: ... + def is_monospace(descriptor: Descriptor) -> bool: ... else: FontCollectionMapType = FontMap = None if is_macos: from kitty.fast_data_types import CTFace as Face - - from .core_text import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font + from kitty.fonts.core_text import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font, is_monospace else: from kitty.fast_data_types import Face - - from .fontconfig import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font + from kitty.fonts.fontconfig import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font, is_monospace def face_from_descriptor(descriptor: Descriptor) -> Face: return Face(descriptor=descriptor) @@ -61,6 +60,67 @@ def find_best_match_in_candidates( return x return None +def pprint(*a: Any) -> None: + from pprint import pprint + pprint(*a) + + +def find_medium_variant(font: Descriptor) -> Descriptor: + font = font.copy() + vd = get_variable_data_for_descriptor(font) + for i, ns in enumerate(vd['named_styles']): + if ns['name'] == 'Regular': + font['named_style'] = i + return font + font['axes'] = axes = [ax['default'] for ax in vd['axes']] + for i, ax in enumerate(vd['axes']): + tag = ax['tag'] + for dax in vd['design_axes']: + if dax['tag'] == tag: + for x in dax['values']: + if x['format'] in (1, 2): + if x['name'] == 'Regular': + axes[i] = x['value'] + break + return font + + +def get_design_value_for(dax: DesignAxis, default: float, bold: bool, italic: bool) -> float: + if dax['tag'] == 'wght': + keys = ('semibold', 'bold', 'heavy', 'black') if bold else ('regular', 'medium') + elif dax['tag'] in ('ital', 'slnt'): + keys = ('italic', 'oblique', 'slanted', 'slant') if italic else ('regular', 'normal', 'medium', 'upright') + else: + return default + for x in dax['values']: + if x['format'] in (1, 2): + if x['name'].lower() in keys: + return x['value'] + return default + + +def find_bold_italic_variant(medium: Descriptor, bold: bool, italic: bool) -> Descriptor: + key = family_name_to_key(medium['family']) + monospaced = is_monospace(medium) + # we first pick the best font file for bold/italic if there are more than + # one. For example SourceCodeVF has Italic and Upright faces with variable + # weights in each, so we rely on the OS font matcher to give us the best + # font file. + fonts = all_fonts_map(monospaced)['variable_map'][key] + scorer = create_scorer(bold, italic, monospaced) + fonts.sort(key=scorer) + ans = fonts[0].copy() + # now we need to specialise all axes in ans + vd = get_variable_data_for_descriptor(ans) + ans['axes'] = axes = [ax['default'] for ax in vd['axes']] + for i, ax in enumerate(vd['axes']): + tag = ax['tag'] + for dax in vd['design_axes']: + if dax['tag'] == tag: + axes[i] = get_design_value_for(dax, axes[i], bold, italic) + break + return ans + def get_fine_grained_font( spec: FontSpec, bold: bool = False, italic: bool = False, medium_font_spec: FontSpec = FontSpec(), @@ -68,8 +128,7 @@ def get_fine_grained_font( ) -> Descriptor: font_map = all_fonts_map(monospaced) is_medium_face = resolved_medium_font is None - prefer_variable = bool(spec.axes) or bool(spec.style) - scorer = create_scorer(bold, italic, monospaced, prefer_variable=prefer_variable) + scorer = create_scorer(bold, italic, monospaced) if spec.postscript_name: q = find_best_match_in_candidates(font_map['ps_map'].get(family_name_to_key(spec.postscript_name), []), scorer, is_medium_face) if q: @@ -79,32 +138,47 @@ def get_fine_grained_font( if q: return q if spec.family: - candidates = font_map['family_map'].get(family_name_to_key(spec.family), []) - if spec.style: - qs = spec.style.lower() - candidates = [x for x in candidates if x['style'].lower() == qs] - q = find_best_match_in_candidates(candidates, scorer, is_medium_face) - if q: - return q + key = family_name_to_key(spec.family) + # First look for a variable font + candidates = font_map['variable_map'].get(key, []) + if candidates: + candidates.sort(key=scorer) + q = candidates[0] + q, applied = apply_variation_to_pattern(q, spec) + if applied: + return q + return find_medium_variant(q) if resolved_medium_font is None else find_bold_italic_variant(resolved_medium_font, bold, italic) + # Now look for any font + candidates = font_map['family_map'].get(key, []) + if candidates: + if spec.style: + qs = spec.style.lower() + candidates = [x for x in candidates if x['style'].lower() == qs] + q = find_best_match_in_candidates(candidates, scorer, is_medium_face) + if q: + return q + return find_last_resort_text_font(bold, italic, monospaced) -def apply_variation_to_pattern(pat: Descriptor, spec: FontSpec) -> Descriptor: +def apply_variation_to_pattern(pat: Descriptor, spec: FontSpec) -> Tuple[Descriptor, bool]: if not pat['variable']: - return pat + return pat, False vd = face_from_descriptor(pat).get_variable_data() if spec.style: q = spec.style.lower() for i, ns in enumerate(vd['named_styles']): - if ns.get('psname') and ns['psname'].lower() == q: + if ns['psname'].lower() == q: + pat = pat.copy() pat['named_style'] = i - break + return pat, True else: for i, ns in enumerate(vd['named_styles']): if ns['name'].lower() == q: + pat = pat.copy() pat['named_style'] = i - break + return pat, True tag_map, name_map = {}, {} axes = [ax['default'] for ax in vd['axes']] for i, ax in enumerate(vd['axes']): @@ -121,12 +195,9 @@ def apply_variation_to_pattern(pat: Descriptor, spec: FontSpec) -> Descriptor: axes[axis] = axspec[1] changed = True if changed: + pat = pat.copy() pat['axes'] = axes - return pat - - -def find_bold_italic_variant(medium: Descriptor, bold: bool, italic: bool) -> Optional[Descriptor]: - raise NotImplementedError('TODO: Implement me') + return pat, changed def get_font_from_spec( @@ -134,7 +205,7 @@ def get_font_from_spec( resolved_medium_font: Optional[Descriptor] = None ) -> Descriptor: if not spec.is_system: - return apply_variation_to_pattern(get_fine_grained_font(spec, bold, italic, medium_font_spec, resolved_medium_font), spec) + return get_fine_grained_font(spec, bold, italic, medium_font_spec, resolved_medium_font) family = spec.system if family == 'auto': if bold or italic: @@ -168,3 +239,23 @@ def get_font_files(opts: Options) -> FontFiles: key = kd[(bold, italic)] ans[key] = font return {'medium': ans['medium'], 'bold': ans['bold'], 'italic': ans['italic'], 'bi': ans['bi']} + + +def develop(family: str = '') -> None: + import sys + family = family or sys.argv[-1] + from kitty.options.utils import parse_font_spec + opts = Options() + opts.font_family = parse_font_spec(family) + ff = get_font_files(opts) + def s(d: Descriptor) -> str: + return str(face_from_descriptor(d)) + + print('Medium :', s(ff['medium'])) + print('Bold :', s(ff['bold'])) + print('Italic :', s(ff['italic'])) + print('Bold-Italic:', s(ff['bi'])) + + +if __name__ == '__main__': + develop() diff --git a/kitty/fonts/core_text.py b/kitty/fonts/core_text.py index 7777566a4..9f926ff2b 100644 --- a/kitty/fonts/core_text.py +++ b/kitty/fonts/core_text.py @@ -23,7 +23,7 @@ FontMap = Dict[str, Dict[str, List[CoreTextFont]]] def create_font_map(all_fonts: Iterable[CoreTextFont]) -> FontMap: - ans: FontMap = {'family_map': {}, 'ps_map': {}, 'full_map': {}} + ans: FontMap = {'family_map': {}, 'ps_map': {}, 'full_map': {}, 'variable_map': {}} for x in all_fonts: f = (x['family'] or '').lower() s = (x['style'] or '').lower() @@ -31,6 +31,8 @@ def create_font_map(all_fonts: Iterable[CoreTextFont]) -> FontMap: ans['family_map'].setdefault(f, []).append(x) ans['ps_map'].setdefault(ps, []).append(x) ans['full_map'].setdefault(f'{f} {s}', []).append(x) + if x['variable']: + ans['variable_map'].setdefault(f, []).append(x) return ans @@ -39,6 +41,10 @@ def all_fonts_map(monospaced: bool = True) -> FontMap: return create_font_map(coretext_all_fonts(monospaced)) +def is_monospace(descriptor: CoreTextFont) -> bool: + return descriptor['monospace'] + + def list_fonts() -> Generator[ListedFont, None, None]: for fd in coretext_all_fonts(False): f = fd['family'] @@ -76,11 +82,12 @@ def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospa def find_best_match( - family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[CoreTextFont] = None + family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, ignore_face: Optional[CoreTextFont] = None, + prefer_variable: bool = False ) -> CoreTextFont: q = re.sub(r'\s+', ' ', family.lower()) font_map = all_fonts_map(monospaced) - scorer = create_scorer(bold, italic, monospaced) + scorer = create_scorer(bold, italic, monospaced, prefer_variable=prefer_variable) # First look for an exact match for selector in ('ps_map', 'full_map'): diff --git a/kitty/fonts/fontconfig.py b/kitty/fonts/fontconfig.py index 04d1563fe..fe8f695de 100644 --- a/kitty/fonts/fontconfig.py +++ b/kitty/fonts/fontconfig.py @@ -19,12 +19,12 @@ from kitty.typing import FontConfigPattern from . import Descriptor, ListedFont, Score, Scorer, family_name_to_key -FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map'] +FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map', 'variable_map'] FontMap = Dict[FontCollectionMapType, Dict[str, List[FontConfigPattern]]] def create_font_map(all_fonts: Tuple[FontConfigPattern, ...]) -> FontMap: - ans: FontMap = {'family_map': {}, 'ps_map': {}, 'full_map': {}} + ans: FontMap = {'family_map': {}, 'ps_map': {}, 'full_map': {}, 'variable_map': {}} for x in all_fonts: if not x.get('path'): continue @@ -34,6 +34,8 @@ def create_font_map(all_fonts: Tuple[FontConfigPattern, ...]) -> FontMap: ans['family_map'].setdefault(f, []).append(x) ans['ps_map'].setdefault(ps, []).append(x) ans['full_map'].setdefault(full, []).append(x) + if x['variable']: + ans['variable_map'].setdefault(f, []).append(x) return ans @@ -48,6 +50,10 @@ def all_fonts_map(monospaced: bool = True) -> FontMap: return create_font_map(ans) +def is_monospace(descriptor: FontConfigPattern) -> bool: + return descriptor['spacing'] in ('MONO', 'DUAL') + + def list_fonts(only_variable: bool = False) -> Generator[ListedFont, None, None]: for fd in fc_list(only_variable=only_variable): f = fd.get('family') @@ -57,10 +63,9 @@ def list_fonts(only_variable: bool = False) -> Generator[ListedFont, None, None] fn = str(fn_) else: fn = f'{f} {fd.get("style", "")}'.strip() - is_mono = fd.get('spacing') in ('MONO', 'DUAL') yield { 'family': f, 'full_name': fn, 'postscript_name': str(fd.get('postscript_name', '')), - 'is_monospace': is_mono, 'descriptor': fd, 'is_variable': fd.get('variable', False), + 'is_monospace': is_monospace(fd), 'descriptor': fd, 'is_variable': fd.get('variable', False), 'style': fd['style'], } @@ -94,12 +99,12 @@ def find_last_resort_text_font(bold: bool = False, italic: bool = False, monospa def find_best_match( family: str, bold: bool = False, italic: bool = False, monospaced: bool = True, - ignore_face: Optional[FontConfigPattern] = None + ignore_face: Optional[FontConfigPattern] = None, prefer_variable: bool = False, ) -> FontConfigPattern: from .common import find_best_match_in_candidates q = family_name_to_key(family) font_map = all_fonts_map(monospaced) - scorer = create_scorer(bold, italic, monospaced) + scorer = create_scorer(bold, italic, monospaced, prefer_variable=prefer_variable) is_medium_face = not bold and not italic # First look for an exact match exact_match = (