diff --git a/kitty/core_text.m b/kitty/core_text.m index 162cff4b1..3e4297616 100644 --- a/kitty/core_text.m +++ b/kitty/core_text.m @@ -111,6 +111,45 @@ dealloc(CTFace* self) { Py_TYPE(self)->tp_free((PyObject*)self); } +static const char* +tag_to_string(uint32_t tag, uint8_t bytes[5]) { + bytes[0] = (tag >> 24) & 0xff; + bytes[1] = (tag >> 16) & 0xff; + bytes[2] = (tag >> 8) & 0xff; + bytes[3] = (tag) & 0xff; + bytes[4] = 0; + return (const char*)bytes; +} + +static uint32_t +string_to_tag(const uint8_t *bytes) { + return (((uint32_t)bytes[0]) << 24) | (((uint32_t)bytes[1]) << 16) | (((uint32_t)bytes[2]) << 8) | bytes[3]; +} + + +static void +add_variation_pair(const void *key_, const void *value_, void *ctx) { + PyObject *ans = ctx; + CFNumberRef key = key_, value = value_; + uint32_t tag; double val; + if (!CFNumberGetValue(key, kCFNumberSInt32Type, &tag)) return; + if (!CFNumberGetValue(value, kCFNumberDoubleType, &val)) return; + uint8_t tag_string[5]; + tag_to_string(tag, tag_string); + RAII_PyObject(pyval, PyFloat_FromDouble(val)); + if (pyval) PyDict_SetItemString(ans, (const char*)tag_string, pyval); +} + +static PyObject* +variation_to_python(CFDictionaryRef v) { + if (!v) { Py_RETURN_NONE; } + RAII_PyObject(ans, PyDict_New()); + if (!ans) return NULL; + CFDictionaryApplyFunction(v, add_variation_pair, ans); + if (PyErr_Occurred()) return NULL; + Py_INCREF(ans); return ans; +} + static PyObject* font_descriptor_to_python(CTFontDescriptorRef descriptor) { RAII_PyObject(path, get_path_for_font_descriptor(descriptor)); @@ -127,9 +166,12 @@ font_descriptor_to_python(CTFontDescriptorRef descriptor) { get_number(traits, kCTFontWeightTrait, weight, kCFNumberFloatType); get_number(traits, kCTFontWidthTrait, width, kCFNumberFloatType); get_number(traits, kCTFontSlantTrait, slant, kCFNumberFloatType); - RAII_CoreFoundation(CFDictionaryRef, variation, CTFontDescriptorCopyAttribute(descriptor, kCTFontVariationAttribute)); + RAII_CoreFoundation(CFDictionaryRef, cf_variation, CTFontDescriptorCopyAttribute(descriptor, kCTFontVariationAttribute)); + RAII_PyObject(variation, variation_to_python(cf_variation)); + if (!variation) return NULL; #undef get_number + PyObject *ans = Py_BuildValue("{ss sOsOsOsOsO sOsOsOsOsOsOsO sfsfsfsk}", "descriptor_type", "core_text", @@ -141,7 +183,7 @@ font_descriptor_to_python(CTFontDescriptorRef descriptor) { "expanded", (symbolic_traits & kCTFontExpandedTrait) != 0 ? Py_True : Py_False, "condensed", (symbolic_traits & kCTFontCondensedTrait) != 0 ? Py_True : Py_False, "color_glyphs", (symbolic_traits & kCTFontColorGlyphsTrait) != 0 ? Py_True : Py_False, - "variable", variation ? Py_True : Py_False, + "variation", variation, "weight", weight, "width", width, "slant", slant, "traits", symbolic_traits ); @@ -151,7 +193,7 @@ font_descriptor_to_python(CTFontDescriptorRef descriptor) { static CTFontDescriptorRef font_descriptor_from_python(PyObject *src) { CTFontSymbolicTraits symbolic_traits = 0; - NSMutableDictionary *attrs = [NSMutableDictionary dictionary]; + RAII_CoreFoundation(CFMutableDictionaryRef, ans, CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks)); PyObject *t = PyDict_GetItemString(src, "traits"); if (t == NULL) { symbolic_traits = ( @@ -161,19 +203,33 @@ font_descriptor_from_python(PyObject *src) { } else { symbolic_traits = PyLong_AsUnsignedLong(t); } - NSDictionary *traits = @{(id)kCTFontSymbolicTrait:[NSNumber numberWithUnsignedInt:symbolic_traits]}; - attrs[(id)kCTFontTraitsAttribute] = traits; + RAII_CoreFoundation(CFNumberRef, cf_symbolic_traits, CFNumberCreate(NULL, kCFNumberSInt32Type, &symbolic_traits)); + CFTypeRef keys[] = { kCTFontSymbolicTrait }; + CFTypeRef values[] = { cf_symbolic_traits }; + RAII_CoreFoundation(CFDictionaryRef, traits, CFDictionaryCreate(NULL, keys, values, 1, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks)); + CFDictionaryAddValue(ans, kCTFontTraitsAttribute, traits); -#define SET(x, attr) \ - t = PyDict_GetItemString(src, #x); \ - if (t) attrs[(id)attr] = @(PyUnicode_AsUTF8(t)); +#define SET(x, attr) if ((t = PyDict_GetItemString(src, #x))) { \ + RAII_CoreFoundation(CFStringRef, cs, CFStringCreateWithCString(NULL, PyUnicode_AsUTF8(t), kCFStringEncodingUTF8)); \ + CFDictionaryAddValue(ans, attr, cs); } SET(family, kCTFontFamilyNameAttribute); SET(style, kCTFontStyleNameAttribute); SET(postscript_name, kCTFontNameAttribute); #undef SET - - return CTFontDescriptorCreateWithAttributes((CFDictionaryRef) attrs); + if ((t = PyDict_GetItemString(src, "axis_map"))) { + RAII_CoreFoundation(CFMutableDictionaryRef, axis_map, CFDictionaryCreateMutable(NULL, 0, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks)); + PyObject *key, *value; Py_ssize_t pos = 0; + while (PyDict_Next(t, &pos, &key, &value)) { + double val = PyFloat_AS_DOUBLE(value); + uint32_t tag = string_to_tag((const uint8_t*)PyUnicode_AsUTF8(key)); + RAII_CoreFoundation(CFNumberRef, cf_tag, CFNumberCreate(NULL, kCFNumberSInt32Type, &tag)); + RAII_CoreFoundation(CFNumberRef, cf_val, CFNumberCreate(NULL, kCFNumberDoubleType, &val)); + CFDictionaryAddValue(axis_map, cf_tag, cf_val); + } + CFDictionaryAddValue(ans, kCTFontVariationAttribute, axis_map); + } + return CTFontDescriptorCreateWithAttributes(ans); } static CTFontCollectionRef all_fonts_collection_data = NULL; diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 9d2ebc856..5057bd9f8 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -397,7 +397,7 @@ class FontConfigPattern(TypedDict): # The following two are used by C code to get a face from the pattern named_style: NotRequired[int] - axes: NotRequired[List[float]] + axes: NotRequired[Tuple[float, ...]] def fc_list(spacing: int = -1, allow_bitmapped_fonts: bool = False, only_variable: bool = False) -> Tuple[FontConfigPattern, ...]: @@ -444,15 +444,14 @@ class CoreTextFont(TypedDict): condensed: bool color_glyphs: bool monospace: bool - variable: bool + variation: Optional[Dict[str, float]] weight: float width: float slant: float traits: int - # The following two are used by C code to get a face from the pattern - named_style: NotRequired[int] - axes: NotRequired[List[float]] + # The following is used by C code to get a face from the pattern + axis_map: NotRequired[Dict[str, float]] class CTFace: diff --git a/kitty/fonts/__init__.py b/kitty/fonts/__init__.py index b79d9a629..2eb6948f8 100644 --- a/kitty/fonts/__init__.py +++ b/kitty/fonts/__init__.py @@ -138,6 +138,7 @@ class FontSpec(NamedTuple): full_name: str = '' system: str = '' axes: Tuple[Tuple[str, float], ...] = () + variable_name: str = '' @property def is_system(self) -> bool: diff --git a/kitty/fonts/common.py b/kitty/fonts/common.py index 67cc88974..6a3e0bc90 100644 --- a/kitty/fonts/common.py +++ b/kitty/fonts/common.py @@ -23,14 +23,35 @@ if TYPE_CHECKING: 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: ... + def is_variable(descriptor: Descriptor) -> bool: ... + def set_named_style(name: str, font: Descriptor, vd: VariableData) -> bool: ... + def set_axis_values(tag_map: Dict[str, float], font: Descriptor, vd: VariableData) -> bool: ... else: FontCollectionMapType = FontMap = None if is_macos: from kitty.fast_data_types import CTFace as Face - from kitty.fonts.core_text import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font, is_monospace + from kitty.fonts.core_text import ( + all_fonts_map, + create_scorer, + find_best_match, + find_last_resort_text_font, + is_monospace, + is_variable, + set_axis_values, + set_named_style, + ) else: from kitty.fast_data_types import Face - from kitty.fonts.fontconfig import all_fonts_map, create_scorer, find_best_match, find_last_resort_text_font, is_monospace + from kitty.fonts.fontconfig import ( + all_fonts_map, + create_scorer, + find_best_match, + find_last_resort_text_font, + is_monospace, + is_variable, + set_axis_values, + set_named_style, + ) def face_from_descriptor(descriptor: Descriptor) -> Face: return Face(descriptor=descriptor) @@ -70,9 +91,9 @@ def find_medium_variant(font: Descriptor) -> Descriptor: vd = get_variable_data_for_descriptor(font) for i, ns in enumerate(vd['named_styles']): if ns['name'] == 'Regular': - font['named_style'] = i + set_named_style(ns['psname'], font, vd) return font - font['axes'] = axes = [ax['default'] for ax in vd['axes']] + axis_values = {} for i, ax in enumerate(vd['axes']): tag = ax['tag'] for dax in vd['design_axes']: @@ -80,8 +101,10 @@ def find_medium_variant(font: Descriptor) -> Descriptor: for x in dax['values']: if x['format'] in (1, 2): if x['name'] == 'Regular': - axes[i] = x['value'] + axis_values[tag] = x['value'] break + if axis_values: + set_axis_values(axis_values, font, vd) return font @@ -100,28 +123,51 @@ def get_design_value_for(dax: DesignAxis, default: float, bold: bool, italic: bo 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] + monospaced = is_monospace(medium) + fonts = all_fonts_map(monospaced)['variable_map'][family_name_to_key(medium['family'])] scorer = create_scorer(bold, italic, monospaced) fonts.sort(key=scorer) + vd = get_variable_data_for_descriptor(fonts[0]) 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']] + axis_values = {} 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) + axis_values[tag] = get_design_value_for(dax, ax['default'], bold, italic) break + if axis_values: + set_axis_values(axis_values, ans, vd) return ans +def find_best_variable_face(spec: FontSpec, bold: bool, italic: bool, monospaced: bool, candidates: List[Descriptor]) -> Descriptor: + if spec.variable_name: + q = spec.variable_name.lower() + for font in candidates: + vd = get_variable_data_for_descriptor(font) + if vd['variations_postscript_name_prefix'].lower() == q: + return font + if spec.style: + q = spec.style.lower() + for font in candidates: + vd = get_variable_data_for_descriptor(font) + for x in vd['named_styles']: + if x['psname'].lower() == q: + return font + for x in vd['named_styles']: + if x['name'].lower() == q: + return font + scorer = create_scorer(bold, italic, monospaced) + candidates.sort(key=scorer) + return candidates[0] + + def get_fine_grained_font( spec: FontSpec, bold: bool = False, italic: bool = False, medium_font_spec: FontSpec = FontSpec(), resolved_medium_font: Optional[Descriptor] = None, monospaced: bool = True @@ -142,8 +188,7 @@ def get_fine_grained_font( # First look for a variable font candidates = font_map['variable_map'].get(key, []) if candidates: - candidates.sort(key=scorer) - q = candidates[0] + q = candidates[0] if len(candidates) == 1 else find_best_variable_face(spec, bold, italic, monospaced, candidates) q, applied = apply_variation_to_pattern(q, spec) if applied: return q @@ -162,42 +207,26 @@ def get_fine_grained_font( def apply_variation_to_pattern(pat: Descriptor, spec: FontSpec) -> Tuple[Descriptor, bool]: - if not pat['variable']: - return pat, False - vd = face_from_descriptor(pat).get_variable_data() + pat = pat.copy() if spec.style: - q = spec.style.lower() - for i, ns in enumerate(vd['named_styles']): - if ns['psname'].lower() == q: - pat = pat.copy() - pat['named_style'] = i - return pat, True - else: - for i, ns in enumerate(vd['named_styles']): - if ns['name'].lower() == q: - pat = pat.copy() - pat['named_style'] = i - return pat, True + if set_named_style(spec.style, pat, vd): + return pat, True tag_map, name_map = {}, {} - axes = [ax['default'] for ax in vd['axes']] for i, ax in enumerate(vd['axes']): tag_map[ax['tag']] = i if ax['strid']: - name_map[ax['strid'].lower()] = i - changed = False + name_map[ax['strid'].lower()] = ax['tag'] + axis_values = {} for axspec in spec.axes: qname = axspec[0] - axis = tag_map.get(qname) - if axis is None: - axis = name_map.get(qname.lower()) - if axis is not None: - axes[axis] = axspec[1] - changed = True - if changed: - pat = pat.copy() - pat['axes'] = axes - return pat, changed + if qname in tag_map: + axis_values[qname] = axspec[1] + continue + tag = name_map.get(qname.lower()) + if tag: + axis_values[tag] = axspec[1] + return pat, set_axis_values(axis_values, pat, vd) def get_font_from_spec( @@ -211,7 +240,7 @@ def get_font_from_spec( if bold or italic: assert resolved_medium_font is not None family = resolved_medium_font['family'] - if resolved_medium_font['variable']: + if is_variable(resolved_medium_font): v = find_bold_italic_variant(resolved_medium_font, bold, italic) if v is not None: return v diff --git a/kitty/fonts/core_text.py b/kitty/fonts/core_text.py index 9f926ff2b..38464535f 100644 --- a/kitty/fonts/core_text.py +++ b/kitty/fonts/core_text.py @@ -1,7 +1,10 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2017, Kovid Goyal +import itertools +import operator import re +from collections import defaultdict from functools import lru_cache from typing import Dict, Generator, Iterable, List, Optional, Tuple @@ -11,7 +14,7 @@ from kitty.options.types import Options from kitty.typing import CoreTextFont from kitty.utils import log_error -from . import Descriptor, ListedFont, Score, Scorer +from . import Descriptor, ListedFont, Score, Scorer, VariableData attr_map = {(False, False): 'font_family', (True, False): 'bold_font', @@ -24,6 +27,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': {}, 'variable_map': {}} + vmap: Dict[str, List[CoreTextFont]] = defaultdict(list) for x in all_fonts: f = (x['family'] or '').lower() s = (x['style'] or '').lower() @@ -31,8 +35,19 @@ 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) + if x['variation'] is not None: + vmap[f].append(x) + # CoreText makes a separate descriptor for each named style in each + # variable font file. Keep only the default style descriptor, which has an + # empty variation dictionary. If no default exists, pick the one with the + # smallest variation dictionary size. + keyfunc = operator.itemgetter('path') + for k, v in vmap.items(): + v.sort(key=keyfunc) + uniq_per_path = [] + for _, g in itertools.groupby(v, keyfunc): + uniq_per_path.append(sorted(g, key=lambda x: len(x['variation'] or ()))[0]) + ans['variable_map'][k] = uniq_per_path return ans @@ -45,6 +60,10 @@ def is_monospace(descriptor: CoreTextFont) -> bool: return descriptor['monospace'] +def is_variable(descriptor: CoreTextFont) -> bool: + return descriptor['variation'] is not None + + def list_fonts() -> Generator[ListedFont, None, None]: for fd in coretext_all_fonts(False): f = fd['family'] @@ -53,14 +72,14 @@ def list_fonts() -> Generator[ListedFont, None, None]: if not fn: fn = f'{f} {fd["style"]}'.strip() yield {'family': f, 'full_name': fn, 'postscript_name': fd['postscript_name'] or '', 'is_monospace': fd['monospace'], - 'is_variable': fd['variable'], 'descriptor': fd, 'style': fd['style']} + 'is_variable': is_variable(fd), 'descriptor': fd, 'style': fd['style']} def create_scorer(bold: bool = False, italic: bool = False, monospaced: bool = True, prefer_variable: bool = False) -> Scorer: def score(candidate: Descriptor) -> Score: assert candidate['descriptor_type'] == 'core_text' - variable_score = 0 if prefer_variable and candidate['variable'] else 1 + variable_score = 0 if prefer_variable and candidate['variation'] is not None else 1 style_match = 1 if candidate['bold'] == bold and candidate[ 'italic' ] == italic else 0 @@ -161,3 +180,26 @@ def prune_family_group(g: List[ListedFont]) -> List[ListedFont]: return True return False return [x for x in g if is_ok(descriptor(x))] + + +def set_axis_values(tag_map: Dict[str, float], font: CoreTextFont, vd: VariableData) -> bool: + known_axes = {ax['tag'] for ax in vd['axes']} + previous = font.get('axis_map', {}) + new = previous.copy() + for tag in known_axes: + val = tag_map.get(tag) + if val is not None: + new[tag] = val + font['axis_map'] = new + return new != previous + + +def set_named_style(name: str, font: CoreTextFont, vd: VariableData) -> bool: + q = name.lower() + for i, ns in enumerate(vd['named_styles']): + if ns['psname'].lower() == q: + return set_axis_values(ns['axis_values'], font, vd) + for i, ns in enumerate(vd['named_styles']): + if ns['name'].lower() == q: + return set_axis_values(ns['axis_values'], font, vd) + return False diff --git a/kitty/fonts/fontconfig.py b/kitty/fonts/fontconfig.py index fe8f695de..e35b72639 100644 --- a/kitty/fonts/fontconfig.py +++ b/kitty/fonts/fontconfig.py @@ -17,7 +17,7 @@ from kitty.fast_data_types import ( from kitty.fast_data_types import fc_match as fc_match_impl from kitty.typing import FontConfigPattern -from . import Descriptor, ListedFont, Score, Scorer, family_name_to_key +from . import Descriptor, ListedFont, Score, Scorer, VariableData, family_name_to_key FontCollectionMapType = Literal['family_map', 'ps_map', 'full_map', 'variable_map'] FontMap = Dict[FontCollectionMapType, Dict[str, List[FontConfigPattern]]] @@ -54,6 +54,10 @@ def is_monospace(descriptor: FontConfigPattern) -> bool: return descriptor['spacing'] in ('MONO', 'DUAL') +def is_variable(descriptor: FontConfigPattern) -> bool: + return descriptor['variable'] + + def list_fonts(only_variable: bool = False) -> Generator[ListedFont, None, None]: for fd in fc_list(only_variable=only_variable): f = fd.get('family') @@ -65,7 +69,7 @@ def list_fonts(only_variable: bool = False) -> Generator[ListedFont, None, None] fn = f'{f} {fd.get("style", "")}'.strip() yield { 'family': f, 'full_name': fn, 'postscript_name': str(fd.get('postscript_name', '')), - 'is_monospace': is_monospace(fd), 'descriptor': fd, 'is_variable': fd.get('variable', False), + 'is_monospace': is_monospace(fd), 'descriptor': fd, 'is_variable': is_variable(fd), 'style': fd['style'], } @@ -161,3 +165,29 @@ def prune_family_group(g: List[ListedFont]) -> List[ListedFont]: return d['variable'] or d['path'] not in variable_paths return [x for x in g if is_ok(descriptor(x))] + + +def set_named_style(name: str, font: FontConfigPattern, vd: VariableData) -> bool: + q = name.lower() + for i, ns in enumerate(vd['named_styles']): + if ns['psname'].lower() == q: + font['named_style'] = i + return True + for i, ns in enumerate(vd['named_styles']): + if ns['name'].lower() == q: + font['named_style'] = i + return True + return False + + +def set_axis_values(tag_map: Dict[str, float], font: FontConfigPattern, vd: VariableData) -> bool: + axes = list(font.get('axes', ())) or [ax['default'] for ax in vd['axes']] + changed = False + for i, ax in enumerate(vd['axes']): + val = tag_map.get(ax['tag']) + if val is not None: + changed = True + axes[i] = val + if changed: + font['axes'] = tuple(axes) + return changed diff --git a/kitty/freetype.c b/kitty/freetype.c index 8568bbdf6..0136f5471 100644 --- a/kitty/freetype.c +++ b/kitty/freetype.c @@ -288,15 +288,16 @@ face_from_descriptor(PyObject *descriptor, FONTS_DATA_HANDLE fg) { if ((error = FT_Set_Named_Instance(self->face, index + 1))) return set_load_error(path, error); } PyObject *axes = PyDict_GetItemString(descriptor, "axes"); - if (axes && PyList_GET_SIZE(axes)) { - RAII_ALLOC(FT_Fixed, coords, malloc(sizeof(FT_Fixed) * PyList_GET_SIZE(axes))); - for (Py_ssize_t i = 0; i < PyList_GET_SIZE(axes); i++) { - PyObject *t = PyList_GET_ITEM(axes, i); + Py_ssize_t sz; + if (axes && (sz = PyTuple_GET_SIZE(axes))) { + RAII_ALLOC(FT_Fixed, coords, malloc(sizeof(FT_Fixed) * sz)); + for (Py_ssize_t i = 0; i < sz; i++) { + PyObject *t = PyTuple_GET_ITEM(axes, i); double val = PyFloat_AsDouble(t); if (PyErr_Occurred()) return NULL; coords[i] = (FT_Fixed)(val * 65536.0); } - if ((error = FT_Set_Var_Design_Coordinates(self->face, PyList_GET_SIZE(axes), coords))) return set_load_error(path, error); + if ((error = FT_Set_Var_Design_Coordinates(self->face, sz, coords))) return set_load_error(path, error); } } Py_XINCREF(retval); diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 0f936cf5e..04bc813b5 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -1402,7 +1402,7 @@ def parse_font_spec(spec: str) -> FontSpec: k, sep, v = item.partition('=') if sep != '=': raise ValueError(f'The font specification: {spec} is not valid as {item} does not contain an =') - if k in ('family', 'style', 'full_name', 'postscript_name'): + if k in ('family', 'style', 'full_name', 'postscript_name', 'variable_name'): defined[k] = v else: try: