Get variable font selection working on coretext

This commit is contained in:
Kovid Goyal
2024-05-10 21:50:24 +05:30
parent 69de414ba1
commit b44ffb9815
8 changed files with 228 additions and 70 deletions

View File

@@ -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;

View File

@@ -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:

View File

@@ -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:

View File

@@ -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

View File

@@ -1,7 +1,10 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
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

View File

@@ -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

View File

@@ -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);

View File

@@ -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: