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