pytermgui.exporters
This module provides various methods and utilities to turn TIM into HTML & SVG.
1"""This module provides various methods and utilities to turn TIM into HTML & SVG.""" 2 3# TODO: The HTML and SVG implementations are completely independent at the moment, 4# which is pretty annoying to maintain. It would be great to consolidate them 5# at some point. 6 7from __future__ import annotations 8 9from copy import deepcopy 10from html import escape 11from typing import Iterator 12 13from .colors import Color 14from .markup import StyledText, Token, tim 15from .terminal import get_terminal 16from .widgets import Widget 17 18MARGIN = 15 19BODY_MARGIN = 70 20CHAR_WIDTH = 0.62 21CHAR_HEIGHT = 1.15 22FONT_SIZE = 15 23 24FONT_WIDTH = FONT_SIZE * CHAR_WIDTH 25FONT_HEIGHT = FONT_SIZE * CHAR_HEIGHT * 1.1 26 27HTML_FORMAT = """\ 28<html> 29 <head> 30 <style> 31 body {{ 32 --ptg-background: {background}; 33 --ptg-foreground: {foreground}; 34 color: var(--ptg-foreground); 35 background-color: var(--ptg-background); 36 }} 37 a {{ 38 text-decoration: none; 39 color: inherit; 40 }} 41 code {{ 42 font-size: {font_size}px; 43 font-family: Menlo, 'DejaVu Sans Mono', consolas, 'Courier New', monospace; 44 line-height: 1.2em; 45 }} 46 .ptg-position {{ 47 position: absolute; 48 }} 49{styles} 50 </style> 51 </head> 52 <body> 53 <pre class="ptg"> 54 <code> 55{content} 56 </code> 57 </pre> 58 </body> 59</html>""" 60 61SVG_MARGIN_LEFT = 0 62TEXT_MARGIN_LEFT = 20 63 64TEXT_MARGIN_TOP = 35 65SVG_MARGIN_TOP = 20 66 67SVG_FORMAT = f"""\ 68<svg width="{{total_width}}" height="{{total_height}}" 69 viewBox="0 0 {{total_width}} {{total_height}}" xmlns="http://www.w3.org/2000/svg"> 70 <!-- Generated by PyTermGUI --> 71 <style type="text/css"> 72 text.{{prefix}} {{{{ 73 font-size: {FONT_SIZE}px; 74 font-family: Menlo, 'DejaVu Sans Mono', consolas, 'Courier New', monospace; 75 }}}} 76 77 .{{prefix}}-title {{{{ 78 font-family: 'arial'; 79 fill: #94999A; 80 font-size: 13px; 81 font-weight: bold; 82 }}}} 83{{stylesheet}} 84 </style> 85 {{chrome}} 86{{code}} 87</svg>""" 88 89_STYLE_TO_CSS = { 90 "bold": "font-weight: bold", 91 "italic": "font-style: italic", 92 "dim": "opacity: 0.7", 93 "underline": "text-decoration: underline", 94 "strikethrough": "text-decoration: line-through", 95 "overline": "text-decoration: overline", 96} 97 98 99__all__ = ["token_to_css", "to_html"] 100 101 102def _get_cls(prefix: str | None, index: int) -> str: 103 """Constructs a class identifier with the given prefix and index.""" 104 105 return "ptg" + ("-" + prefix if prefix is not None else "") + str(index) 106 107 108def _generate_stylesheet(document_styles: list[list[str]], prefix: str | None) -> str: 109 """Generates a '\\n' joined CSS stylesheet from the given styles.""" 110 111 stylesheet = "" 112 for i, styles in enumerate(document_styles): 113 stylesheet += "\n." + _get_cls(prefix, i) + " {" + "; ".join(styles) + "}" 114 115 return stylesheet 116 117 118def _generate_index_in(lst: list[list[str]], item: list[str]) -> int: 119 """Returns the given item's index in the list, len(lst) if not found.""" 120 121 index = len(lst) 122 123 if item in lst: 124 return lst.index(item) 125 126 return index 127 128 129# Note: This whole routine will be massively refactored in an upcoming update, 130# once StyledText has a bit of a better way of managing style attributes. 131# Until then we must ignore some linting issues :(. 132def _get_spans( # pylint: disable=too-many-locals 133 line: str, 134 vertical_offset: float, 135 horizontal_offset: float, 136 include_background: bool, 137) -> Iterator[tuple[str, list[str]]]: 138 """Creates `span` elements from the given line, yields them with their styles. 139 140 Args: 141 line: The ANSI line of text to use. 142 143 Yields: 144 Tuples of the span text (more on that later), and a list of CSS styles applied 145 to it. The span text is in the format `<span{}>content</span>`, and it doesn't 146 yet have the styles formatted into it. 147 """ 148 149 def _adjust_pos( 150 position: int | None, scale: float, offset: float, digits: int = 2 151 ) -> float: 152 """Adjusts a given position for the HTML canvas' scale.""" 153 154 if position is None: 155 return 0 156 157 return round(position * scale + offset / FONT_SIZE, digits) 158 159 position = None 160 161 for span in StyledText.group_styles(line): 162 styles = [] 163 if include_background: 164 styles.append("background-color: var(--ptg-background)") 165 166 has_link = False 167 has_inverse = False 168 169 for token in sorted(span.tokens, key=lambda token: token.is_color()): 170 if token.is_plain(): 171 continue 172 173 if Token.is_cursor(token): 174 if token.value != position: 175 # Yield closer if there is already an active positioner 176 if position is not None: 177 yield "</div>", [] 178 179 adjusted = ( 180 _adjust_pos(token.x, CHAR_WIDTH, horizontal_offset), 181 _adjust_pos(token.y, CHAR_HEIGHT, vertical_offset), 182 ) 183 184 yield ( 185 "<div class='ptg-position'" 186 + f" style='left: {adjusted[0]}em; top: {adjusted[1]}em'>" 187 ), [] 188 189 position = token.value 190 191 elif token.is_hyperlink(): 192 has_link = True 193 yield f"<a href='{token.value}'>", [] 194 195 elif token.is_style() and token.value == "inverse": 196 has_inverse = True 197 198 # Add default inverted colors, in case the text doesn't have any 199 # color applied. 200 styles.append("color: var(--ptg-background);") 201 styles.append("background-color: var(--ptg-foreground)") 202 203 continue 204 205 css = token_to_css(token, has_inverse) 206 if css is not None and css not in styles: 207 styles.append(css) 208 209 escaped = ( 210 escape(span.plain) 211 .replace("{", "{{") 212 .replace("}", "}}") 213 .replace(" ", " ") 214 ) 215 216 if len(styles) == 0: 217 yield f"<span>{escaped}</span>", [] 218 continue 219 220 tag = "<span{}>" + escaped + "</span>" 221 tag += "</a>" if has_link else "" 222 223 yield tag, styles 224 225 226def token_to_css(token: Token, invert: bool = False) -> str: 227 """Finds the CSS representation of a token. 228 229 Args: 230 token: The token to represent. 231 invert: If set, the role of background & foreground colors 232 are flipped. 233 """ 234 235 if Token.is_color(token): 236 color = token.color 237 238 style = "color:" + color.hex 239 240 if invert: 241 color.background = not color.background 242 243 if color.background: 244 style = "background-" + style 245 246 return style 247 248 if token.is_style() and token.value in _STYLE_TO_CSS: 249 return _STYLE_TO_CSS[token.value] 250 251 return "" 252 253 254# We take this many arguments for future proofing and customization, not much we can 255# do about it. 256def to_html( # pylint: disable=too-many-arguments, too-many-locals 257 obj: Widget | StyledText | str, 258 prefix: str | None = None, 259 inline_styles: bool = False, 260 include_background: bool = True, 261 vertical_offset: float = 0.0, 262 horizontal_offset: float = 0.0, 263 formatter: str = HTML_FORMAT, 264 joiner: str = "\n", 265) -> str: 266 """Creates a static HTML representation of the given object. 267 268 Note that the output HTML will not be very attractive or easy to read. This is 269 because these files probably aren't meant to be read by a human anyways, so file 270 sizes are more important. 271 272 If you do care about the visual style of the output, you can run it through some 273 prettifiers to get the result you are looking for. 274 275 Args: 276 obj: The object to represent. Takes either a Widget or some markup text. 277 prefix: The prefix included in the generated classes, e.g. instead of `ptg-0`, 278 you would get `ptg-my-prefix-0`. 279 inline_styles: If set, styles will be set for each span using the inline `style` 280 argument, otherwise a full style section is constructed. 281 include_background: Whether to include the terminal's background color in the 282 output. 283 """ 284 285 document_styles: list[list[str]] = [] 286 287 if isinstance(obj, Widget): 288 data = obj.get_lines() 289 290 elif isinstance(obj, str): 291 data = obj.splitlines() 292 293 else: 294 data = str(obj).splitlines() 295 296 lines = [] 297 for dataline in data: 298 line = "" 299 300 for span, styles in _get_spans( 301 dataline, vertical_offset, horizontal_offset, include_background 302 ): 303 index = _generate_index_in(document_styles, styles) 304 if index == len(document_styles): 305 document_styles.append(styles) 306 307 if inline_styles: 308 stylesheet = ";".join(styles) 309 line += span.format(f" styles='{stylesheet}'") 310 311 else: 312 line += span.format(" class='" + _get_cls(prefix, index) + "'") 313 314 # Close any previously not closed divs 315 line += "</div>" * (line.count("<div") - line.count("</div")) 316 lines.append(line) 317 318 stylesheet = "" 319 if not inline_styles: 320 stylesheet = _generate_stylesheet(document_styles, prefix) 321 322 document = formatter.format( 323 foreground=Color.get_default_foreground().hex, 324 background=Color.get_default_background().hex if include_background else "", 325 content=joiner.join(lines), 326 styles=stylesheet, 327 font_size=FONT_SIZE, 328 ) 329 330 return document 331 332 333def _escape_text(text: str) -> str: 334 """Escapes HTML and replaces ' ' with .""" 335 336 return escape(text).replace(" ", " ") 337 338 339def _handle_tokens_svg( 340 text: StyledText, default_fore: str, default_back: str 341) -> tuple[tuple[int, int] | None, str | None, list[str]]: 342 """Builds CSS styles that apply to the text.""" 343 344 styles: list[tuple[Token, str]] = [] 345 pos = None 346 347 fore, back = default_fore, default_back 348 349 has_inverse = any( 350 token.is_style() and token.value == "inverse" for token in text.tokens 351 ) 352 353 fore, back = ( 354 (default_back, default_fore) if has_inverse else (default_fore, default_back) 355 ) 356 357 for token in text.tokens: 358 if Token.is_cursor(token): 359 pos = token.x, token.y 360 continue 361 362 if Token.is_color(token): 363 color = token.color 364 365 if has_inverse: 366 color = deepcopy(color) 367 color.background = not color.background 368 369 if color.background: 370 back = color.hex 371 372 else: 373 fore = color.hex 374 375 continue 376 377 if Token.is_clear(token): 378 for i, (target, _) in enumerate(styles): 379 if token.targets(target): 380 styles.pop(i) 381 382 css = token_to_css(token) 383 384 if css != "": 385 styles.append((token, css)) 386 387 css_styles = [value for _, value in styles] 388 css_styles.append(f"fill:{fore}") 389 390 return (None if pos is None else (pos[0] or 0, pos[1] or 0)), back, css_styles 391 392 393def _slugify(text: str) -> str: 394 """Turns the given text into a slugified form.""" 395 396 return text.replace(" ", "-").replace("_", "-") 397 398 399def _make_tag(tagname: str, content: str = "", **attrs) -> str: 400 """Creates a tag.""" 401 402 tag = f"<{tagname} " 403 404 for key, value in attrs.items(): 405 if key == "raw": 406 tag += " " + value 407 continue 408 409 if key == "cls": 410 key = "class" 411 412 if isinstance(value, float): 413 value = round(value, 2) 414 415 tag += f"{_slugify(key)}='{value}' " 416 417 tag += f">{content}</{tagname}>" 418 419 return tag 420 421 422# This is a bit of a beast of a function, but it does the job and IMO reducing it 423# into parts would just make our lives more complicated. 424def to_svg( # pylint: disable=too-many-locals, too-many-arguments, too-many-statements 425 obj: Widget | StyledText | str, 426 prefix: str | None = None, 427 chrome: bool = True, 428 inline_styles: bool = False, 429 title: str = "PyTermGUI", 430 formatter: str = SVG_FORMAT, 431) -> str: 432 """Creates an SVG screenshot of the given object. 433 434 This screenshot tries to mimick what the Kitty terminal looks like on MacOS, 435 complete with the menu buttons and drop shadow. The `title` argument will be 436 displayed in the window's top bar. 437 438 Args: 439 obj: The object to represent. Takes either a Widget or some markup text. 440 prefix: The prefix included in the generated classes, e.g. instead of `ptg-0`, 441 you would get `ptg-my-prefix-0`. 442 chrome: Sets the visibility of the window "chrome", e.g. the part of the SVG 443 that mimicks the outside border of a terminal. 444 inline_styles: If set, styles will be set for each span using the inline `style` 445 argument, otherwise a full style section is constructed. 446 title: A string to display in the top bar of the fake terminal. 447 formatter: The formatting string to use. Inspect `pytermgui.exporters.SVG_FORMAT` 448 to see all of its arguments. 449 """ 450 451 def _is_block(text: str) -> bool: 452 """Determines whether the given text only contains block characters. 453 454 These characters reside in the unicode range of 9600-9631, which is what we test 455 against. 456 """ 457 458 return all(9600 <= ord(char) <= 9631 for char in text) 459 460 prefix = prefix if prefix is not None else "ptg" 461 462 terminal = get_terminal() 463 default_fore = Color.get_default_foreground().hex 464 default_back = Color.get_default_background().hex 465 466 text = "" 467 468 lines = 1 469 cursor_x = cursor_y = 0.0 470 document_styles: list[list[str]] = [] 471 472 # We manually set all text to have an alignment-baseline of 473 # text-after-edge to avoid block characters rendering in the 474 # wrong place (not at the top of their "box"), but with that 475 # our background rects will be rendered in the wrong place too, 476 # so this is used to offset that. 477 baseline_offset = 0.17 * FONT_HEIGHT 478 479 if isinstance(obj, Widget): 480 obj = "\n".join(obj.get_lines()) 481 482 elif isinstance(obj, StyledText): 483 obj = str(obj) 484 485 for plain in tim.group_styles(obj): 486 should_newline = False 487 488 pos, back, styles = _handle_tokens_svg(plain, default_fore, default_back) 489 490 index = _generate_index_in(document_styles, styles) 491 492 if index == len(document_styles): 493 document_styles.append(styles) 494 495 style_attr = ( 496 f"class='{prefix}' style='{';'.join(styles)}'" 497 if inline_styles 498 else f"class='{prefix} {_get_cls(prefix, index)}'" 499 ) 500 501 # Manual positioning 502 if pos is not None: 503 cursor_x = pos[0] * FONT_WIDTH - 10 504 cursor_y = pos[1] * FONT_HEIGHT - 15 505 506 for line in plain.plain.splitlines(): 507 text_len = len(line) * FONT_WIDTH 508 509 if should_newline: 510 cursor_y += FONT_HEIGHT 511 cursor_x = 0 512 513 lines += 1 514 if lines > terminal.height: 515 break 516 517 text += _make_tag( 518 "rect", 519 x=cursor_x, 520 y=cursor_y - (baseline_offset if not _is_block(line) else 0), 521 fill=back or default_back, 522 width=text_len * 1.02, 523 height=FONT_HEIGHT, 524 ) 525 526 text += _make_tag( 527 "text", 528 _escape_text(line), 529 dy="-0.25em", 530 x=cursor_x, 531 y=cursor_y + FONT_SIZE, 532 textLength=text_len, 533 raw=style_attr, 534 ) 535 536 cursor_x += text_len 537 should_newline = True 538 539 if lines > terminal.height: 540 break 541 542 if plain.plain.endswith("\n"): 543 cursor_y += FONT_HEIGHT 544 cursor_x = 0 545 546 lines += 1 547 548 stylesheet = "" if inline_styles else _generate_stylesheet(document_styles, prefix) 549 550 terminal_width = terminal.width * FONT_WIDTH + 2 * TEXT_MARGIN_LEFT 551 terminal_height = terminal.height * FONT_HEIGHT + 2 * TEXT_MARGIN_TOP 552 553 total_width = terminal_width + (2 * SVG_MARGIN_LEFT if chrome else 0) 554 total_height = terminal_height + (2 * SVG_MARGIN_TOP if chrome else 0) 555 556 if chrome: 557 transform = ( 558 f"translate({TEXT_MARGIN_LEFT + SVG_MARGIN_LEFT}, " 559 + f"{TEXT_MARGIN_TOP + SVG_MARGIN_TOP})" 560 ) 561 562 chrome_part = f"""<g> 563 <rect x="{SVG_MARGIN_LEFT}" y="{SVG_MARGIN_TOP}" 564 rx="9px" ry="9px" stroke-width="1px" stroke-linejoin="round" 565 width="{terminal_width}" height="{terminal_height}" fill="{default_back}" /> 566 <circle cx="{SVG_MARGIN_LEFT+15}" cy="{SVG_MARGIN_TOP + 15}" r="6" fill="#ff6159"/> 567 <circle cx="{SVG_MARGIN_LEFT+35}" cy="{SVG_MARGIN_TOP + 15}" r="6" fill="#ffbd2e"/> 568 <circle cx="{SVG_MARGIN_LEFT+55}" cy="{SVG_MARGIN_TOP + 15}" r="6" fill="#28c941"/> 569 <text x="{terminal_width // 2}" y="{SVG_MARGIN_TOP + FONT_HEIGHT}" text-anchor="middle" 570 class="{prefix}-title">{title}</text> 571 </g> 572 """ 573 574 else: 575 transform = "translate(16, 16)" 576 577 chrome_part = f"""<rect width="{total_width}" height="{total_height}" 578 fill="{default_back}" />""" 579 580 output = _make_tag("g", text, transform=transform) + "\n" 581 582 return formatter.format( 583 # Dimensions 584 total_width=terminal_width + (2 * SVG_MARGIN_LEFT if chrome else 0), 585 total_height=terminal_height + (2 * SVG_MARGIN_TOP if chrome else 0), 586 terminal_width=terminal_width * 1.02, 587 terminal_height=terminal_height - 15, 588 # Styles 589 background=default_back, 590 stylesheet=stylesheet, 591 # Code 592 code=output, 593 prefix=prefix, 594 chrome=chrome_part, 595 )
227def token_to_css(token: Token, invert: bool = False) -> str: 228 """Finds the CSS representation of a token. 229 230 Args: 231 token: The token to represent. 232 invert: If set, the role of background & foreground colors 233 are flipped. 234 """ 235 236 if Token.is_color(token): 237 color = token.color 238 239 style = "color:" + color.hex 240 241 if invert: 242 color.background = not color.background 243 244 if color.background: 245 style = "background-" + style 246 247 return style 248 249 if token.is_style() and token.value in _STYLE_TO_CSS: 250 return _STYLE_TO_CSS[token.value] 251 252 return ""
Finds the CSS representation of a token.
Args
- token: The token to represent.
- invert: If set, the role of background & foreground colors are flipped.
def
to_html( obj: pytermgui.widgets.base.Widget | pytermgui.markup.language.StyledText | str, prefix: str | None = None, inline_styles: bool = False, include_background: bool = True, vertical_offset: float = 0.0, horizontal_offset: float = 0.0, formatter: str = '<html>\n <head>\n <style>\n body {{\n --ptg-background: {background};\n --ptg-foreground: {foreground};\n color: var(--ptg-foreground);\n background-color: var(--ptg-background);\n }}\n a {{\n text-decoration: none;\n color: inherit;\n }}\n code {{\n font-size: {font_size}px;\n font-family: Menlo, \'DejaVu Sans Mono\', consolas, \'Courier New\', monospace;\n line-height: 1.2em;\n }}\n .ptg-position {{\n position: absolute;\n }}\n{styles}\n </style>\n </head>\n <body>\n <pre class="ptg">\n <code>\n{content}\n </code>\n </pre>\n </body>\n</html>', joiner: str = '\n') -> str:
257def to_html( # pylint: disable=too-many-arguments, too-many-locals 258 obj: Widget | StyledText | str, 259 prefix: str | None = None, 260 inline_styles: bool = False, 261 include_background: bool = True, 262 vertical_offset: float = 0.0, 263 horizontal_offset: float = 0.0, 264 formatter: str = HTML_FORMAT, 265 joiner: str = "\n", 266) -> str: 267 """Creates a static HTML representation of the given object. 268 269 Note that the output HTML will not be very attractive or easy to read. This is 270 because these files probably aren't meant to be read by a human anyways, so file 271 sizes are more important. 272 273 If you do care about the visual style of the output, you can run it through some 274 prettifiers to get the result you are looking for. 275 276 Args: 277 obj: The object to represent. Takes either a Widget or some markup text. 278 prefix: The prefix included in the generated classes, e.g. instead of `ptg-0`, 279 you would get `ptg-my-prefix-0`. 280 inline_styles: If set, styles will be set for each span using the inline `style` 281 argument, otherwise a full style section is constructed. 282 include_background: Whether to include the terminal's background color in the 283 output. 284 """ 285 286 document_styles: list[list[str]] = [] 287 288 if isinstance(obj, Widget): 289 data = obj.get_lines() 290 291 elif isinstance(obj, str): 292 data = obj.splitlines() 293 294 else: 295 data = str(obj).splitlines() 296 297 lines = [] 298 for dataline in data: 299 line = "" 300 301 for span, styles in _get_spans( 302 dataline, vertical_offset, horizontal_offset, include_background 303 ): 304 index = _generate_index_in(document_styles, styles) 305 if index == len(document_styles): 306 document_styles.append(styles) 307 308 if inline_styles: 309 stylesheet = ";".join(styles) 310 line += span.format(f" styles='{stylesheet}'") 311 312 else: 313 line += span.format(" class='" + _get_cls(prefix, index) + "'") 314 315 # Close any previously not closed divs 316 line += "</div>" * (line.count("<div") - line.count("</div")) 317 lines.append(line) 318 319 stylesheet = "" 320 if not inline_styles: 321 stylesheet = _generate_stylesheet(document_styles, prefix) 322 323 document = formatter.format( 324 foreground=Color.get_default_foreground().hex, 325 background=Color.get_default_background().hex if include_background else "", 326 content=joiner.join(lines), 327 styles=stylesheet, 328 font_size=FONT_SIZE, 329 ) 330 331 return document
Creates a static HTML representation of the given object.
Note that the output HTML will not be very attractive or easy to read. This is because these files probably aren't meant to be read by a human anyways, so file sizes are more important.
If you do care about the visual style of the output, you can run it through some prettifiers to get the result you are looking for.
Args
- obj: The object to represent. Takes either a Widget or some markup text.
- prefix: The prefix included in the generated classes, e.g. instead of
ptg-0
, you would getptg-my-prefix-0
. - inline_styles: If set, styles will be set for each span using the inline
style
argument, otherwise a full style section is constructed. - include_background: Whether to include the terminal's background color in the output.