fonts.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. #!/usr/bin/env python
  2. # License: GPL v3 Copyright: 2017, Kovid Goyal <kovid at kovidgoyal.net>
  3. import os
  4. import tempfile
  5. import unittest
  6. from functools import partial
  7. from kitty.constants import is_macos, read_kitty_resource
  8. from kitty.fast_data_types import (
  9. DECAWM,
  10. ParsedFontFeature,
  11. get_fallback_font,
  12. sprite_map_set_layout,
  13. sprite_map_set_limits,
  14. test_render_line,
  15. test_sprite_position_for,
  16. wcwidth,
  17. )
  18. from kitty.fonts import family_name_to_key
  19. from kitty.fonts.box_drawing import box_chars
  20. from kitty.fonts.common import FontSpec, all_fonts_map, face_from_descriptor, get_font_files, get_named_style, spec_for_face
  21. from kitty.fonts.render import coalesce_symbol_maps, render_string, setup_for_testing, shape_string
  22. from kitty.options.types import Options
  23. from . import BaseTest
  24. def parse_font_spec(spec):
  25. return FontSpec.from_setting(spec)
  26. class Selection(BaseTest):
  27. def test_font_selection(self):
  28. self.set_options({'font_features': {'LiberationMono': (ParsedFontFeature('-dlig'),)}})
  29. opts = Options()
  30. fonts_map = all_fonts_map(True)
  31. names = set(fonts_map['family_map']) | set(fonts_map['variable_map'])
  32. del fonts_map
  33. def s(family: str, *expected: str, alternate=None) -> None:
  34. opts.font_family = parse_font_spec(family)
  35. ff = get_font_files(opts)
  36. actual = tuple(face_from_descriptor(ff[x]).postscript_name() for x in ('medium', 'bold', 'italic', 'bi')) # type: ignore
  37. del ff
  38. for x in actual:
  39. if '/' in x: # Old FreeType failed to generate postscript name for a variable font probably
  40. return
  41. with self.subTest(spec=family):
  42. try:
  43. self.ae(expected, actual)
  44. except AssertionError:
  45. if alternate:
  46. self.ae(alternate, actual)
  47. else:
  48. raise
  49. def both(family: str, *expected: str, alternate=None) -> None:
  50. for family in (family, f'family="{family}"'):
  51. s(family, *expected, alternate=alternate)
  52. def has(family, allow_missing_in_ci=False):
  53. ans = family_name_to_key(family) in names
  54. if self.is_ci and not allow_missing_in_ci and not ans:
  55. raise AssertionError(f'The family: {family} is not available')
  56. return ans
  57. def t(family, psprefix, bold='Bold', italic='Italic', bi='', reg='Regular', allow_missing_in_ci=False, alternate=None):
  58. if has(family, allow_missing_in_ci=allow_missing_in_ci):
  59. bi = bi or bold + italic
  60. if reg:
  61. reg = '-' + reg
  62. both(family, f'{psprefix}{reg}', f'{psprefix}-{bold}', f'{psprefix}-{italic}', f'{psprefix}-{bi}', alternate=alternate)
  63. t('Source Code Pro', 'SourceCodePro', 'Semibold', 'It')
  64. t('sourcecodeVf', 'SourceCodeVF', 'Semibold')
  65. # The Arch ttf-fira-code package excludes the variable fonts for some reason
  66. t('fira code', 'FiraCodeRoman', 'SemiBold', 'Regular', 'SemiBold', alternate=(
  67. 'FiraCode-Regular', 'FiraCode-SemiBold', 'FiraCode-Retina', 'FiraCode-SemiBold'))
  68. t('hack', 'Hack')
  69. # some ubuntu systems (such as the build VM) have only the regular and
  70. # bold faces of DejaVu Sans Mono installed.
  71. # t('DejaVu Sans Mono', 'DejaVuSansMono', reg='', italic='Oblique')
  72. t('ubuntu mono', 'UbuntuMono')
  73. t('liberation mono', 'LiberationMono', reg='')
  74. t('ibm plex mono', 'IBMPlexMono', 'SmBld', reg='')
  75. t('iosevka fixed', 'Iosevka-Fixed', 'Semibold', reg='', bi='Semibold-Italic', allow_missing_in_ci=True)
  76. t('iosevka term', 'Iosevka-Term', 'Semibold', reg='', bi='Semibold-Italic', allow_missing_in_ci=True)
  77. t('fantasque sans mono', 'FantasqueSansMono')
  78. t('jetbrains mono', 'JetBrainsMono', 'SemiBold')
  79. t('consolas', 'Consolas', reg='', allow_missing_in_ci=True)
  80. if has('cascadia code'):
  81. if is_macos:
  82. both('cascadia code', 'CascadiaCode-Regular', 'CascadiaCode-Regular_SemiBold', 'CascadiaCode-Italic', 'CascadiaCode-Italic_SemiBold-Italic')
  83. else:
  84. both('cascadia code', 'CascadiaCodeRoman-Regular', 'CascadiaCodeRoman-SemiBold', 'CascadiaCode-Italic', 'CascadiaCode-SemiBoldItalic')
  85. if has('cascadia mono'):
  86. if is_macos:
  87. both('cascadia mono', 'CascadiaMono-Regular', 'CascadiaMono-Regular_SemiBold', 'CascadiaMono-Italic', 'CascadiaMono-Italic_SemiBold-Italic')
  88. else:
  89. both('cascadia mono', 'CascadiaMonoRoman-Regular', 'CascadiaMonoRoman-SemiBold', 'CascadiaMono-Italic', 'CascadiaMono-SemiBoldItalic')
  90. if has('operator mono', allow_missing_in_ci=True):
  91. both('operator mono', 'OperatorMono-Medium', 'OperatorMono-Bold', 'OperatorMono-MediumItalic', 'OperatorMono-BoldItalic')
  92. # Test variable font selection
  93. if has('SourceCodeVF'):
  94. opts = Options()
  95. opts.font_family = parse_font_spec('family="SourceCodeVF" variable_name="SourceCodeUpright" style="Bold"')
  96. ff = get_font_files(opts)
  97. face = face_from_descriptor(ff['medium'])
  98. self.ae(get_named_style(face)['name'], 'Bold')
  99. face = face_from_descriptor(ff['italic'])
  100. self.ae(get_named_style(face)['name'], 'Bold Italic')
  101. face = face_from_descriptor(ff['bold'])
  102. self.ae(get_named_style(face)['name'], 'Black')
  103. face = face_from_descriptor(ff['bi'])
  104. self.ae(get_named_style(face)['name'], 'Black Italic')
  105. opts.font_family = parse_font_spec('family=SourceCodeVF variable_name=SourceCodeUpright wght=470')
  106. opts.italic_font = parse_font_spec('family=SourceCodeVF variable_name=SourceCodeItalic style=Black')
  107. ff = get_font_files(opts)
  108. self.assertFalse(get_named_style(ff['medium']))
  109. self.ae(get_named_style(ff['italic'])['name'], 'Black Italic')
  110. if has('cascadia code'):
  111. opts = Options()
  112. opts.font_family = parse_font_spec('family="cascadia code"')
  113. opts.italic_font = parse_font_spec('family="cascadia code" variable_name= style="Light Italic"')
  114. ff = get_font_files(opts)
  115. def t(x, **kw):
  116. if 'spec' in kw:
  117. fs = FontSpec.from_setting('family="Cascadia Code" ' + kw['spec'])._replace(created_from_string='')
  118. else:
  119. kw['family'] = 'Cascadia Code'
  120. fs = FontSpec(**kw)
  121. face = face_from_descriptor(ff[x])
  122. self.ae(fs.as_setting, spec_for_face('Cascadia Code', face).as_setting)
  123. t('medium', variable_name='CascadiaCodeRoman', style='Regular')
  124. t('italic', variable_name='', style='Light Italic')
  125. opts = Options()
  126. opts.font_family = parse_font_spec('family="cascadia code" variable_name=CascadiaCodeRoman wght=455')
  127. opts.italic_font = parse_font_spec('family="cascadia code" variable_name= wght=405')
  128. opts.bold_font = parse_font_spec('family="cascadia code" variable_name=CascadiaCodeRoman wght=603')
  129. ff = get_font_files(opts)
  130. t('medium', spec='variable_name=CascadiaCodeRoman wght=455')
  131. t('italic', spec='variable_name= wght=405')
  132. t('bold', spec='variable_name=CascadiaCodeRoman wght=603')
  133. t('bi', spec='variable_name= wght=603')
  134. # Test font features
  135. if has('liberation mono'):
  136. opts = Options()
  137. opts.font_family = parse_font_spec('family="liberation mono"')
  138. ff = get_font_files(opts)
  139. self.ae(face_from_descriptor(ff['medium']).applied_features(), {'dlig': '-dlig'})
  140. self.ae(face_from_descriptor(ff['bold']).applied_features(), {})
  141. opts.font_family = parse_font_spec('family="liberation mono" features="dlig test=3"')
  142. ff = get_font_files(opts)
  143. self.ae(face_from_descriptor(ff['medium']).applied_features(), {'dlig': 'dlig', 'test': 'test=3'})
  144. self.ae(face_from_descriptor(ff['bold']).applied_features(), {'dlig': 'dlig', 'test': 'test=3'})
  145. class Rendering(BaseTest):
  146. def setUp(self):
  147. super().setUp()
  148. self.test_ctx = setup_for_testing()
  149. self.test_ctx.__enter__()
  150. self.sprites, self.cell_width, self.cell_height = self.test_ctx.__enter__()
  151. try:
  152. self.assertEqual([k[0] for k in self.sprites], [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
  153. except Exception:
  154. self.test_ctx.__exit__()
  155. del self.test_ctx
  156. raise
  157. self.tdir = tempfile.mkdtemp()
  158. def tearDown(self):
  159. self.test_ctx.__exit__()
  160. del self.sprites, self.cell_width, self.cell_height, self.test_ctx
  161. self.rmtree_ignoring_errors(self.tdir)
  162. super().tearDown()
  163. def test_sprite_map(self):
  164. sprite_map_set_limits(10, 2)
  165. sprite_map_set_layout(5, 5)
  166. self.ae(test_sprite_position_for(0), (0, 0, 0))
  167. self.ae(test_sprite_position_for(1), (1, 0, 0))
  168. self.ae(test_sprite_position_for(2), (0, 1, 0))
  169. self.ae(test_sprite_position_for(3), (1, 1, 0))
  170. self.ae(test_sprite_position_for(4), (0, 0, 1))
  171. self.ae(test_sprite_position_for(5), (1, 0, 1))
  172. self.ae(test_sprite_position_for(6), (0, 1, 1))
  173. self.ae(test_sprite_position_for(7), (1, 1, 1))
  174. self.ae(test_sprite_position_for(0, 1), (0, 0, 2))
  175. self.ae(test_sprite_position_for(0, 2), (1, 0, 2))
  176. def test_box_drawing(self):
  177. prerendered = len(self.sprites)
  178. s = self.create_screen(cols=len(box_chars) + 1, lines=1, scrollback=0)
  179. s.draw(''.join(box_chars))
  180. line = s.line(0)
  181. test_render_line(line)
  182. self.assertEqual(len(self.sprites) - prerendered, len(box_chars))
  183. def test_font_rendering(self):
  184. render_string('ab\u0347\u0305你好|\U0001F601|\U0001F64f|\U0001F63a|')
  185. text = 'He\u0347\u0305llo\u0341, w\u0302or\u0306l\u0354d!'
  186. # macOS has no fonts capable of rendering combining chars
  187. if is_macos:
  188. text = text.encode('ascii', 'ignore').decode('ascii')
  189. cells = render_string(text)[-1]
  190. self.ae(len(cells), len(text.encode('ascii', 'ignore')))
  191. text = '你好,世界'
  192. sz = sum(map(lambda x: wcwidth(ord(x)), text))
  193. cells = render_string(text)[-1]
  194. self.ae(len(cells), sz)
  195. def test_shaping(self):
  196. font_path_cache = {}
  197. def path_for_font(name):
  198. if name not in font_path_cache:
  199. with open(os.path.join(self.tdir, name), 'wb') as f:
  200. font_path_cache[name] = f.name
  201. data = read_kitty_resource(name, __name__.rpartition('.')[0])
  202. f.write(data)
  203. return font_path_cache[name]
  204. def ss(text, font=None):
  205. path = path_for_font(font) if font else None
  206. return shape_string(text, path=path)
  207. def groups(text, font=None):
  208. return [x[:2] for x in ss(text, font)]
  209. for font in ('FiraCode-Medium.otf', 'CascadiaCode-Regular.otf', 'iosevka-regular.ttf'):
  210. g = partial(groups, font=font)
  211. self.ae(g('abcd'), [(1, 1) for i in range(4)])
  212. self.ae(g('A===B!=C'), [(1, 1), (3, 3), (1, 1), (2, 2), (1, 1)])
  213. self.ae(g('A=>>B!=C'), [(1, 1), (3, 3), (1, 1), (2, 2), (1, 1)])
  214. if 'iosevka' in font:
  215. self.ae(g('--->'), [(4, 4)])
  216. self.ae(g('-' * 12 + '>'), [(13, 13)])
  217. self.ae(g('<~~~'), [(4, 4)])
  218. self.ae(g('a<~~~b'), [(1, 1), (4, 4), (1, 1)])
  219. else:
  220. self.ae(g('----'), [(4, 4)])
  221. self.ae(g('F--a--'), [(1, 1), (2, 2), (1, 1), (2, 2)])
  222. self.ae(g('===--<>=='), [(3, 3), (2, 2), (2, 2), (2, 2)])
  223. self.ae(g('==!=<>==<><><>'), [(4, 4), (2, 2), (2, 2), (2, 2), (2, 2), (2, 2)])
  224. self.ae(g('-' * 18), [(18, 18)])
  225. self.ae(g('a>\u2060<b'), [(1, 1), (1, 2), (1, 1), (1, 1)])
  226. colon_glyph = ss('9:30', font='FiraCode-Medium.otf')[1][2]
  227. self.assertNotEqual(colon_glyph, ss(':', font='FiraCode-Medium.otf')[0][2])
  228. self.ae(colon_glyph, 1031)
  229. self.ae(groups('9:30', font='FiraCode-Medium.otf'), [(1, 1), (1, 1), (1, 1), (1, 1)])
  230. self.ae(groups('|\U0001F601|\U0001F64f|\U0001F63a|'), [(1, 1), (2, 1), (1, 1), (2, 1), (1, 1), (2, 1), (1, 1)])
  231. self.ae(groups('He\u0347\u0305llo\u0337,', font='LiberationMono-Regular.ttf'),
  232. [(1, 1), (1, 3), (1, 1), (1, 1), (1, 2), (1, 1)])
  233. self.ae(groups('i\u0332\u0308', font='LiberationMono-Regular.ttf'), [(1, 2)])
  234. self.ae(groups('u\u0332 u\u0332\u0301', font='LiberationMono-Regular.ttf'), [(1, 2), (1, 1), (1, 2)])
  235. def test_emoji_presentation(self):
  236. s = self.create_screen()
  237. s.draw('\u2716\u2716\ufe0f')
  238. self.ae((s.cursor.x, s.cursor.y), (3, 0))
  239. s.draw('\u2716\u2716')
  240. self.ae((s.cursor.x, s.cursor.y), (5, 0))
  241. s.draw('\ufe0f')
  242. self.ae((s.cursor.x, s.cursor.y), (2, 1))
  243. self.ae(str(s.line(0)), '\u2716\u2716\ufe0f\u2716')
  244. self.ae(str(s.line(1)), '\u2716\ufe0f')
  245. s.draw('\u2716' * 3)
  246. self.ae((s.cursor.x, s.cursor.y), (5, 1))
  247. self.ae(str(s.line(1)), '\u2716\ufe0f\u2716\u2716\u2716')
  248. self.ae((s.cursor.x, s.cursor.y), (5, 1))
  249. s.reset_mode(DECAWM)
  250. s.draw('\ufe0f')
  251. s.set_mode(DECAWM)
  252. self.ae((s.cursor.x, s.cursor.y), (5, 1))
  253. self.ae(str(s.line(1)), '\u2716\ufe0f\u2716\u2716\ufe0f')
  254. s.cursor.y = s.lines - 1
  255. s.draw('\u2716' * s.columns)
  256. self.ae((s.cursor.x, s.cursor.y), (5, 4))
  257. s.draw('\ufe0f')
  258. self.ae((s.cursor.x, s.cursor.y), (2, 4))
  259. self.ae(str(s.line(s.cursor.y)), '\u2716\ufe0f')
  260. @unittest.skipUnless(is_macos, 'Only macOS has a Last Resort font')
  261. def test_fallback_font_not_last_resort(self):
  262. # Ensure that the LastResort font is not reported as a fallback font on
  263. # macOS. See https://github.com/kovidgoyal/kitty/issues/799
  264. with self.assertRaises(ValueError, msg='No fallback font found'):
  265. get_fallback_font('\U0010FFFF', False, False)
  266. def test_coalesce_symbol_maps(self):
  267. q = {(2, 3): 'a', (4, 6): 'b', (5, 5): 'b', (7, 7): 'b', (9, 9): 'b', (1, 1): 'a'}
  268. self.ae(coalesce_symbol_maps(q), {(1, 3): 'a', (4, 7): 'b', (9, 9): 'b'})
  269. q = {(1, 4): 'a', (2, 3): 'b'}
  270. self.ae(coalesce_symbol_maps(q), {(1, 1): 'a', (2, 3): 'b', (4, 4): 'a'})
  271. q = {(2, 3): 'b', (1, 4): 'a'}
  272. self.ae(coalesce_symbol_maps(q), {(1, 4): 'a'})
  273. q = {(1, 4): 'a', (2, 5): 'b'}
  274. self.ae(coalesce_symbol_maps(q), {(1, 1): 'a', (2, 5): 'b'})
  275. q = {(2, 5): 'b', (1, 4): 'a'}
  276. self.ae(coalesce_symbol_maps(q), {(1, 4): 'a', (5, 5): 'b'})
  277. q = {(1, 4): 'a', (2, 5): 'a'}
  278. self.ae(coalesce_symbol_maps(q), {(1, 5): 'a'})
  279. q = {(1, 4): 'a', (4, 5): 'b'}
  280. self.ae(coalesce_symbol_maps(q), {(1, 3): 'a', (4, 5): 'b'})
  281. q = {(4, 5): 'b', (1, 4): 'a'}
  282. self.ae(coalesce_symbol_maps(q), {(1, 4): 'a', (5, 5): 'b'})
  283. q = {(0, 30): 'a', (10, 10): 'b', (11, 11): 'b', (2, 2): 'c', (1, 1): 'c'}
  284. self.ae(coalesce_symbol_maps(q), {
  285. (0, 0): 'a', (1, 2): 'c', (3, 9): 'a', (10, 11): 'b', (12, 30): 'a'})