1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net> 4 5import ctypes 6import sys 7from functools import partial 8from math import ceil, cos, floor, pi 9from typing import ( 10 Any, Callable, Dict, Generator, List, Optional, Tuple, Union, cast 11) 12 13from kitty.constants import is_macos 14from kitty.fast_data_types import ( 15 Screen, create_test_font_group, get_fallback_font, set_font_data, 16 set_options, set_send_sprite_to_gpu, sprite_map_set_limits, 17 test_render_line, test_shape 18) 19from kitty.fonts.box_drawing import ( 20 BufType, render_box_char, render_missing_glyph 21) 22from kitty.options.types import Options, defaults 23from kitty.typing import CoreTextFont, FontConfigPattern 24from kitty.utils import log_error 25 26if is_macos: 27 from .core_text import get_font_files as get_font_files_coretext, font_for_family as font_for_family_macos, find_font_features 28else: 29 from .fontconfig import get_font_files as get_font_files_fontconfig, font_for_family as font_for_family_fontconfig, find_font_features 30 31FontObject = Union[CoreTextFont, FontConfigPattern] 32current_faces: List[Tuple[FontObject, bool, bool]] = [] 33 34 35def get_font_files(opts: Options) -> Dict[str, Any]: 36 if is_macos: 37 return get_font_files_coretext(opts) 38 return get_font_files_fontconfig(opts) 39 40 41def font_for_family(family: str) -> Tuple[FontObject, bool, bool]: 42 if is_macos: 43 return font_for_family_macos(family) 44 return font_for_family_fontconfig(family) 45 46 47Range = Tuple[Tuple[int, int], str] 48 49 50def merge_ranges(a: Range, b: Range, priority_map: Dict[Tuple[int, int], int]) -> Generator[Range, None, None]: 51 a_start, a_end = a[0] 52 b_start, b_end = b[0] 53 a_val, b_val = a[1], b[1] 54 a_prio, b_prio = priority_map[a[0]], priority_map[b[0]] 55 if b_start > a_end: 56 if b_start == a_end + 1 and a_val == b_val: 57 # ranges can be coalesced 58 r = ((a_start, b_end), a_val) 59 priority_map[r[0]] = max(a_prio, b_prio) 60 yield r 61 return 62 # disjoint ranges 63 yield a 64 yield b 65 return 66 if a_val == b_val: 67 # mergeable ranges 68 r = ((a_start, max(a_end, b_end)), a_val) 69 priority_map[r[0]] = max(a_prio, b_prio) 70 yield r 71 return 72 before_range = mid_range = after_range = None 73 before_range_prio = mid_range_prio = after_range_prio = 0 74 if b_start > a_start: 75 before_range = ((a_start, b_start - 1), a_val) 76 before_range_prio = a_prio 77 mid_end = min(a_end, b_end) 78 if mid_end >= b_start: 79 # overlap range 80 mid_range = ((b_start, mid_end), a_val if priority_map[a[0]] >= priority_map[b[0]] else b_val) 81 mid_range_prio = max(a_prio, b_prio) 82 # after range 83 if mid_end is a_end: 84 if b_end > a_end: 85 after_range = ((a_end + 1, b_end), b_val) 86 after_range_prio = b_prio 87 else: 88 if a_end > b_end: 89 after_range = ((b_end + 1, a_end), a_val) 90 after_range_prio = a_prio 91 # check if the before, mid and after ranges can be coalesced 92 ranges: List[Range] = [] 93 priorities: List[int] = [] 94 for rq, prio in ((before_range, before_range_prio), (mid_range, mid_range_prio), (after_range, after_range_prio)): 95 if rq is None: 96 continue 97 r = rq 98 if ranges: 99 x = ranges[-1] 100 if x[0][1] + 1 == r[0][0] and x[1] == r[1]: 101 ranges[-1] = ((x[0][0], r[0][1]), x[1]) 102 priorities[-1] = max(priorities[-1], prio) 103 else: 104 ranges.append(r) 105 priorities.append(prio) 106 else: 107 ranges.append(r) 108 priorities.append(prio) 109 for r, p in zip(ranges, priorities): 110 priority_map[r[0]] = p 111 yield from ranges 112 113 114def coalesce_symbol_maps(maps: Dict[Tuple[int, int], str]) -> Dict[Tuple[int, int], str]: 115 if not maps: 116 return maps 117 priority_map = {r: i for i, r in enumerate(maps.keys())} 118 ranges = tuple((r, maps[r]) for r in sorted(maps)) 119 ans = [ranges[0]] 120 121 for i in range(1, len(ranges)): 122 r = ranges[i] 123 new_ranges = merge_ranges(ans[-1], r, priority_map) 124 if ans: 125 del ans[-1] 126 if not ans: 127 ans = list(new_ranges) 128 else: 129 for r in new_ranges: 130 prev = ans[-1] 131 if prev[0][1] + 1 == r[0][0] and prev[1] == r[1]: 132 ans[-1] = (prev[0][0], r[0][1]), prev[1] 133 else: 134 ans.append(r) 135 return dict(ans) 136 137 138def create_symbol_map(opts: Options) -> Tuple[Tuple[int, int, int], ...]: 139 val = coalesce_symbol_maps(opts.symbol_map) 140 family_map: Dict[str, int] = {} 141 count = 0 142 for family in val.values(): 143 if family not in family_map: 144 font, bold, italic = font_for_family(family) 145 family_map[family] = count 146 count += 1 147 current_faces.append((font, bold, italic)) 148 sm = tuple((a, b, family_map[f]) for (a, b), f in val.items()) 149 return sm 150 151 152def descriptor_for_idx(idx: int) -> Tuple[FontObject, bool, bool]: 153 return current_faces[idx] 154 155 156def dump_faces(ftypes: List[str], indices: Dict[str, int]) -> None: 157 def face_str(f: Tuple[FontObject, bool, bool]) -> str: 158 fo = f[0] 159 if 'index' in fo: 160 return '{}:{}'.format(fo['path'], cast('FontConfigPattern', fo)['index']) 161 fo = cast('CoreTextFont', fo) 162 return fo['path'] 163 164 log_error('Preloaded font faces:') 165 log_error('normal face:', face_str(current_faces[0])) 166 for ftype in ftypes: 167 if indices[ftype]: 168 log_error(ftype, 'face:', face_str(current_faces[indices[ftype]])) 169 si_faces = current_faces[max(indices.values())+1:] 170 if si_faces: 171 log_error('Symbol map faces:') 172 for face in si_faces: 173 log_error(face_str(face)) 174 175 176def set_font_family(opts: Optional[Options] = None, override_font_size: Optional[float] = None, debug_font_matching: bool = False) -> None: 177 global current_faces 178 opts = opts or defaults 179 sz = override_font_size or opts.font_size 180 font_map = get_font_files(opts) 181 current_faces = [(font_map['medium'], False, False)] 182 ftypes = 'bold italic bi'.split() 183 indices = {k: 0 for k in ftypes} 184 for k in ftypes: 185 if k in font_map: 186 indices[k] = len(current_faces) 187 current_faces.append((font_map[k], 'b' in k, 'i' in k)) 188 before = len(current_faces) 189 sm = create_symbol_map(opts) 190 num_symbol_fonts = len(current_faces) - before 191 font_features = {} 192 for face, _, _ in current_faces: 193 font_features[face['postscript_name']] = find_font_features(face['postscript_name']) 194 font_features.update(opts.font_features) 195 if debug_font_matching: 196 dump_faces(ftypes, indices) 197 set_font_data( 198 render_box_drawing, prerender_function, descriptor_for_idx, 199 indices['bold'], indices['italic'], indices['bi'], num_symbol_fonts, 200 sm, sz, font_features 201 ) 202 203 204UnderlineCallback = Callable[[ctypes.Array, int, int, int, int], None] 205 206 207def add_line(buf: ctypes.Array, cell_width: int, position: int, thickness: int, cell_height: int) -> None: 208 y = position - thickness // 2 209 while thickness > 0 and -1 < y < cell_height: 210 thickness -= 1 211 ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width) 212 y += 1 213 214 215def add_dline(buf: ctypes.Array, cell_width: int, position: int, thickness: int, cell_height: int) -> None: 216 a = min(position - thickness, cell_height - 1) 217 b = min(position, cell_height - 1) 218 top, bottom = min(a, b), max(a, b) 219 deficit = 2 - (bottom - top) 220 if deficit > 0: 221 if bottom + deficit < cell_height: 222 bottom += deficit 223 elif bottom < cell_height - 1: 224 bottom += 1 225 if deficit > 1: 226 top -= deficit - 1 227 else: 228 top -= deficit 229 top = max(0, min(top, cell_height - 1)) 230 bottom = max(0, min(bottom, cell_height - 1)) 231 for y in {top, bottom}: 232 ctypes.memset(ctypes.addressof(buf) + (cell_width * y), 255, cell_width) 233 234 235def add_curl(buf: ctypes.Array, cell_width: int, position: int, thickness: int, cell_height: int) -> None: 236 max_x, max_y = cell_width - 1, cell_height - 1 237 xfactor = 2.0 * pi / max_x 238 thickness = max(1, thickness) 239 if thickness < 3: 240 half_height = thickness 241 thickness -= 1 242 elif thickness == 3: 243 half_height = thickness = 2 244 else: 245 half_height = thickness // 2 246 thickness -= 2 247 248 def add_intensity(x: int, y: int, val: int) -> None: 249 y += position 250 y = min(y, max_y) 251 idx = cell_width * y + x 252 buf[idx] = min(255, buf[idx] + val) 253 254 # Ensure curve doesn't exceed cell boundary at the bottom 255 position += half_height * 2 256 if position + half_height > max_y: 257 position = max_y - half_height 258 259 # Use the Wu antialias algorithm to draw the curve 260 # cosine waves always have slope <= 1 so are never steep 261 for x in range(cell_width): 262 y = half_height * cos(x * xfactor) 263 y1, y2 = floor(y - thickness), ceil(y) 264 i1 = int(255 * abs(y - floor(y))) 265 add_intensity(x, y1, 255 - i1) # upper bound 266 add_intensity(x, y2, i1) # lower bound 267 # fill between upper and lower bound 268 for t in range(1, thickness + 1): 269 add_intensity(x, y1 + t, 255) 270 271 272def render_special( 273 underline: int = 0, 274 strikethrough: bool = False, 275 missing: bool = False, 276 cell_width: int = 0, cell_height: int = 0, 277 baseline: int = 0, 278 underline_position: int = 0, 279 underline_thickness: int = 0, 280 strikethrough_position: int = 0, 281 strikethrough_thickness: int = 0, 282 dpi_x: float = 96., 283 dpi_y: float = 96., 284) -> ctypes.Array: 285 underline_position = min(underline_position, cell_height - underline_thickness) 286 CharTexture = ctypes.c_ubyte * (cell_width * cell_height) 287 288 if missing: 289 buf = bytearray(cell_width * cell_height) 290 render_missing_glyph(buf, cell_width, cell_height) 291 return CharTexture.from_buffer(buf) 292 293 ans = CharTexture() 294 295 def dl(f: UnderlineCallback, *a: Any) -> None: 296 try: 297 f(ans, cell_width, *a) 298 except Exception as e: 299 log_error('Failed to render {} at cell_width={} and cell_height={} with error: {}'.format( 300 f.__name__, cell_width, cell_height, e)) 301 302 if underline: 303 t = underline_thickness 304 if underline > 1: 305 t = max(1, min(cell_height - underline_position - 1, t)) 306 dl([add_line, add_line, add_dline, add_curl][underline], underline_position, t, cell_height) 307 if strikethrough: 308 dl(add_line, strikethrough_position, strikethrough_thickness, cell_height) 309 310 return ans 311 312 313def render_cursor( 314 which: int, 315 cursor_beam_thickness: float, 316 cursor_underline_thickness: float, 317 cell_width: int = 0, 318 cell_height: int = 0, 319 dpi_x: float = 0, 320 dpi_y: float = 0 321) -> ctypes.Array: 322 CharTexture = ctypes.c_ubyte * (cell_width * cell_height) 323 ans = CharTexture() 324 325 def vert(edge: str, width_pt: float = 1) -> None: 326 width = max(1, min(int(round(width_pt * dpi_x / 72.0)), cell_width)) 327 left = 0 if edge == 'left' else max(0, cell_width - width) 328 for y in range(cell_height): 329 offset = y * cell_width + left 330 for x in range(offset, offset + width): 331 ans[x] = 255 332 333 def horz(edge: str, height_pt: float = 1) -> None: 334 height = max(1, min(int(round(height_pt * dpi_y / 72.0)), cell_height)) 335 top = 0 if edge == 'top' else max(0, cell_height - height) 336 for y in range(top, top + height): 337 offset = y * cell_width 338 for x in range(cell_width): 339 ans[offset + x] = 255 340 341 if which == 1: # beam 342 vert('left', cursor_beam_thickness) 343 elif which == 2: # underline 344 horz('bottom', cursor_underline_thickness) 345 elif which == 3: # hollow 346 vert('left') 347 vert('right') 348 horz('top') 349 horz('bottom') 350 return ans 351 352 353def prerender_function( 354 cell_width: int, 355 cell_height: int, 356 baseline: int, 357 underline_position: int, 358 underline_thickness: int, 359 strikethrough_position: int, 360 strikethrough_thickness: int, 361 cursor_beam_thickness: float, 362 cursor_underline_thickness: float, 363 dpi_x: float, 364 dpi_y: float 365) -> Tuple[Union[int, ctypes.Array], ...]: 366 # Pre-render the special underline, strikethrough and missing and cursor cells 367 f = partial( 368 render_special, cell_width=cell_width, cell_height=cell_height, baseline=baseline, 369 underline_position=underline_position, underline_thickness=underline_thickness, 370 strikethrough_position=strikethrough_position, strikethrough_thickness=strikethrough_thickness, 371 dpi_x=dpi_x, dpi_y=dpi_y 372 ) 373 c = partial( 374 render_cursor, cursor_beam_thickness=cursor_beam_thickness, 375 cursor_underline_thickness=cursor_underline_thickness, cell_width=cell_width, 376 cell_height=cell_height, dpi_x=dpi_x, dpi_y=dpi_y) 377 cells = f(1), f(2), f(3), f(0, True), f(missing=True), c(1), c(2), c(3) 378 return tuple(map(ctypes.addressof, cells)) + (cells,) 379 380 381def render_box_drawing(codepoint: int, cell_width: int, cell_height: int, dpi: float) -> Tuple[int, ctypes.Array]: 382 CharTexture = ctypes.c_ubyte * (cell_width * cell_height) 383 buf = CharTexture() 384 render_box_char( 385 chr(codepoint), cast(BufType, buf), cell_width, cell_height, dpi 386 ) 387 return ctypes.addressof(buf), buf 388 389 390class setup_for_testing: 391 392 def __init__(self, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0): 393 self.family, self.size, self.dpi = family, size, dpi 394 395 def __enter__(self) -> Tuple[Dict[Tuple[int, int, int], bytes], int, int]: 396 opts = defaults._replace(font_family=self.family, font_size=self.size) 397 set_options(opts) 398 sprites = {} 399 400 def send_to_gpu(x: int, y: int, z: int, data: bytes) -> None: 401 sprites[(x, y, z)] = data 402 403 sprite_map_set_limits(100000, 100) 404 set_send_sprite_to_gpu(send_to_gpu) 405 try: 406 set_font_family(opts) 407 cell_width, cell_height = create_test_font_group(self.size, self.dpi, self.dpi) 408 return sprites, cell_width, cell_height 409 except Exception: 410 set_send_sprite_to_gpu(None) 411 raise 412 413 def __exit__(self, *args: Any) -> None: 414 set_send_sprite_to_gpu(None) 415 416 417def render_string(text: str, family: str = 'monospace', size: float = 11.0, dpi: float = 96.0) -> Tuple[int, int, List[bytes]]: 418 with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height): 419 s = Screen(None, 1, len(text)*2) 420 line = s.line(0) 421 s.draw(text) 422 test_render_line(line) 423 cells = [] 424 found_content = False 425 for i in reversed(range(s.columns)): 426 sp = list(line.sprite_at(i)) 427 sp[2] &= 0xfff 428 tsp = sp[0], sp[1], sp[2] 429 if tsp == (0, 0, 0) and not found_content: 430 continue 431 found_content = True 432 cells.append(sprites[tsp]) 433 return cell_width, cell_height, list(reversed(cells)) 434 435 436def shape_string( 437 text: str = "abcd", family: str = 'monospace', size: float = 11.0, dpi: float = 96.0, path: Optional[str] = None 438) -> List[Tuple[int, int, int, Tuple[int, ...]]]: 439 with setup_for_testing(family, size, dpi) as (sprites, cell_width, cell_height): 440 s = Screen(None, 1, len(text)*2) 441 line = s.line(0) 442 s.draw(text) 443 return test_shape(line, path) 444 445 446def display_bitmap(rgb_data: bytes, width: int, height: int) -> None: 447 from tempfile import NamedTemporaryFile 448 from kittens.icat.main import detect_support, show 449 if not hasattr(display_bitmap, 'detected') and not detect_support(): 450 raise SystemExit('Your terminal does not support the graphics protocol') 451 setattr(display_bitmap, 'detected', True) 452 with NamedTemporaryFile(suffix='.rgba', delete=False) as f: 453 f.write(rgb_data) 454 assert len(rgb_data) == 4 * width * height 455 show(f.name, width, height, 0, 32, align='left') 456 457 458def test_render_string( 459 text: str = 'Hello, world!', 460 family: str = 'monospace', 461 size: float = 64.0, 462 dpi: float = 96.0 463) -> None: 464 from kitty.fast_data_types import concat_cells, current_fonts 465 466 cell_width, cell_height, cells = render_string(text, family, size, dpi) 467 rgb_data = concat_cells(cell_width, cell_height, True, tuple(cells)) 468 cf = current_fonts() 469 fonts = [cf['medium'].display_name()] 470 fonts.extend(f.display_name() for f in cf['fallback']) 471 msg = 'Rendered string {} below, with fonts: {}\n'.format(text, ', '.join(fonts)) 472 try: 473 print(msg) 474 except UnicodeEncodeError: 475 sys.stdout.buffer.write(msg.encode('utf-8') + b'\n') 476 display_bitmap(rgb_data, cell_width * len(cells), cell_height) 477 print('\n') 478 479 480def test_fallback_font(qtext: Optional[str] = None, bold: bool = False, italic: bool = False) -> None: 481 with setup_for_testing(): 482 if qtext: 483 trials = [qtext] 484 else: 485 trials = ['你', 'He\u0347\u0305', '\U0001F929'] 486 for text in trials: 487 f = get_fallback_font(text, bold, italic) 488 try: 489 print(text, f) 490 except UnicodeEncodeError: 491 sys.stdout.buffer.write((text + ' %s\n' % f).encode('utf-8')) 492 493 494def showcase() -> None: 495 f = 'monospace' if is_macos else 'Liberation Mono' 496 test_render_string('He\u0347\u0305llo\u0337, w\u0302or\u0306l\u0354d!', family=f) 497 test_render_string('你好,世界', family=f) 498 test_render_string('││││', family=f) 499 test_render_string('A=>>B!=C', family='Fira Code') 500