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