Implement spec based selection for variable fonts

This commit is contained in:
Kovid Goyal
2024-05-10 10:34:41 +05:30
parent 6d751b94f6
commit c1828fa2a4
5 changed files with 145 additions and 40 deletions

View File

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

View File

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

View File

@@ -1,37 +1,36 @@
#!/usr/bin/env python
# License: GPLv3 Copyright: 2024, Kovid Goyal <kovid at kovidgoyal.net>
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()

View File

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

View File

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