pytermgui.exporters
This module provides various methods and utilities to turn TIM into HTML.
View Source
0"""This module provides various methods and utilities to turn TIM into HTML.""" 1 2from __future__ import annotations 3 4from html import escape 5from typing import Iterator 6 7from .colors import Color 8from .widgets import Widget 9from .terminal import get_terminal 10from .parser import Token, TokenType, StyledText, tim 11 12MARGIN = 15 13BODY_MARGIN = 70 14CHAR_WIDTH = 0.62 15CHAR_HEIGHT = 1.15 16FONT_SIZE = 15 17 18HTML_FORMAT = """\ 19<html> 20 <head> 21 <style> 22 body {{ 23 --ptg-background: {background}; 24 --ptg-foreground: {foreground}; 25 color: var(--ptg-foreground); 26 background-color: var(--ptg-background); 27 }} 28 a {{ 29 text-decoration: none; 30 color: inherit; 31 }} 32 code {{ 33 font-size: {font_size}px; 34 font-family: Menlo, 'DejaVu Sans Mono', consolas, 'Courier New', monospace; 35 line-height: 1.2em; 36 }} 37 .ptg-position {{ 38 position: absolute; 39 }} 40{styles} 41 </style> 42 </head> 43 <body> 44 <pre class="ptg"> 45 <code> 46{content} 47 </code> 48 </pre> 49 </body> 50</html>""" 51 52SVG_FORMAT = """\ 53<svg width="{total_width}" height="{total_height}" viewBox="0 0 {total_width} {total_height}" 54 xmlns="http://www.w3.org/2000/svg"> 55 <style> 56 body {{ 57 --ptg-background: {background}; 58 --ptg-foreground: {foreground}; 59 color: var(--ptg-foreground); 60 margin: {body_margin}px; 61 }} 62 span {{ 63 display: inline-block; 64 }} 65 code {{ 66 font-family: Menlo, 'DejaVu Sans Mono', consolas, 'Courier New', monospace; 67 line-height: 1.2em; 68 }} 69 a {{ 70 text-decoration: none; 71 color: inherit; 72 }} 73 #ptg-terminal {{ 74 position: relative; 75 display: flex; 76 flex-direction: column; 77 background-color: var(--ptg-background); 78 border-radius: 9px; 79 box-shadow: 0 22px 70px 4px rgba(0, 0, 0, 0.56); 80 width: {margined_width}px; 81 height: {margined_height}px; 82 }} 83 #ptg-terminal-navbuttons {{ 84 position: absolute; 85 top: 8px; 86 left: 8px; 87 }} 88 #ptg-terminal-body {{ 89 margin: 15px; 90 font-size: {font_size}px; 91 overflow: hidden scroll; 92 white-space: normal; 93 }} 94 #ptg-terminal-title {{ 95 font-family: sans-serif; 96 font-size: 12px; 97 font-weight: bold; 98 color: #95989b; 99 margin-top: 4px; 100 display: flex; 101 flex-direction: column; 102 align-items: center; 103 }} 104 .ptg-position {{ 105 position: absolute; 106 }} 107{styles} 108 </style> 109 <foreignObject width="100%" height="100%" x="0" y="0"> 110 <body xmlns="http://www.w3.org/1999/xhtml"> 111 <div id="ptg-terminal"> 112 <svg id="ptg-terminal-navbuttons" width="90" height="21" 113 viewBox="0 0 90 21" xmlns="http://www.w3.org/2000/svg"> 114 <circle cx="8" cy="6" r="6" fill="#ff6159"/> 115 <circle cx="28" cy="6" r="6" fill="#ffbd2e"/> 116 <circle cx="48" cy="6" r="6" fill="#28c941"/> 117 </svg> 118 <div id="ptg-terminal-title">{title}</div> 119 <pre id="ptg-terminal-body"> 120 <code> 121{content} 122 </code> 123 </pre> 124 </div> 125 </body> 126 </foreignObject> 127</svg>""" 128 129_STYLE_TO_CSS = { 130 "bold": "font-weight: bold", 131 "italic": "font-style: italic", 132 "dim": "opacity: 0.7", 133 "underline": "text-decoration: underline", 134 "strikethrough": "text-decoration: line-through", 135 "overline": "text-decoration: overline", 136} 137 138 139__all__ = ["token_to_css", "to_html"] 140 141 142def _get_cls(prefix: str | None, index: int) -> str: 143 """Constructs a class identifier with the given prefix and index.""" 144 145 return "ptg" + ("-" + prefix if prefix is not None else "") + str(index) 146 147 148def _generate_stylesheet(document_styles: list[list[str]], prefix: str | None) -> str: 149 """Generates a '\\n' joined CSS stylesheet from the given styles.""" 150 151 stylesheet = "" 152 for i, styles in enumerate(document_styles): 153 stylesheet += "\n." + _get_cls(prefix, i) + " {" + "; ".join(styles) + "}" 154 155 return stylesheet 156 157 158def _generate_index_in(lst: list[list[str]], item: list[str]) -> int: 159 """Returns the given item's index in the list, len(lst) if not found.""" 160 161 index = len(lst) 162 163 if item in lst: 164 return lst.index(item) 165 166 return index 167 168 169# Note: This whole routine will be massively refactored in an upcoming update, 170# once StyledText has a bit of a better way of managing style attributes. 171# Until then we must ignore some linting issues :(. 172def _get_spans( # pylint: disable=too-many-locals 173 line: str, 174 vertical_offset: float, 175 horizontal_offset: float, 176 include_background: bool, 177) -> Iterator[tuple[str, list[str]]]: 178 """Creates `span` elements from the given line, yields them with their styles. 179 180 Args: 181 line: The ANSI line of text to use. 182 183 Yields: 184 Tuples of the span text (more on that later), and a list of CSS styles applied 185 to it. The span text is in the format `<span{}>content</span>`, and it doesn't 186 yet have the styles formatted into it. 187 """ 188 189 def _adjust_pos( 190 position: int, scale: float, offset: float, digits: int = 2 191 ) -> float: 192 """Adjusts a given position for the HTML canvas' scale.""" 193 194 return round(position * scale + offset / FONT_SIZE, digits) 195 196 position = None 197 198 for styled in tim.get_styled_plains(line): 199 styles = [] 200 if include_background: 201 styles.append("background-color: var(--ptg-background)") 202 203 has_link = False 204 has_inverse = False 205 206 for token in sorted( 207 styled.tokens, key=lambda token: token.ttype is TokenType.COLOR 208 ): 209 if token.ttype is TokenType.PLAIN: 210 continue 211 212 if token.ttype is TokenType.POSITION: 213 assert isinstance(token.data, str) 214 215 if token.data != position: 216 # Yield closer if there is already an active positioner 217 if position is not None: 218 yield "</div>", [] 219 220 position = token.data 221 split = tuple(map(int, position.split(","))) 222 223 adjusted = ( 224 _adjust_pos(split[0], CHAR_WIDTH, horizontal_offset), 225 _adjust_pos(split[1], CHAR_HEIGHT, vertical_offset), 226 ) 227 228 yield ( 229 "<div class='ptg-position'" 230 + f" style='left: {adjusted[0]}em; top: {adjusted[1]}em'>" 231 ), [] 232 233 elif token.ttype is TokenType.LINK: 234 has_link = True 235 yield f"<a href='{token.data}'>", [] 236 237 elif token.ttype is TokenType.STYLE and token.name == "inverse": 238 has_inverse = True 239 240 # Add default inverted colors, in case the text doesn't have any 241 # color applied. 242 styles.append("color: var(--ptg-background);") 243 styles.append("background-color: var(--ptg-foreground)") 244 245 continue 246 247 css = token_to_css(token, has_inverse) 248 if css is not None and css not in styles: 249 styles.append(css) 250 251 escaped = ( 252 escape(styled.plain) 253 .replace("{", "{{") 254 .replace("}", "}}") 255 .replace(" ", " ") 256 ) 257 258 if len(styles) == 0: 259 yield f"<span>{escaped}</span>", [] 260 continue 261 262 tag = "<span{}>" + escaped + "</span>" 263 tag += "</a>" if has_link else "" 264 265 yield tag, styles 266 267 268def token_to_css(token: Token, invert: bool = False) -> str: 269 """Finds the CSS representation of a token. 270 271 Args: 272 token: The token to represent. 273 invert: If set, the role of background & foreground colors 274 are flipped. 275 """ 276 277 if token.ttype is TokenType.COLOR: 278 color = token.data 279 assert isinstance(color, Color) 280 281 style = "color:" + color.hex 282 283 if invert: 284 color.background = not color.background 285 286 if color.background: 287 style = "background-" + style 288 289 return style 290 291 if token.ttype is TokenType.STYLE and token.name in _STYLE_TO_CSS: 292 return _STYLE_TO_CSS[token.name] 293 294 return "" 295 296 297# We take this many arguments for future proofing and customization, not much we can 298# do about it. 299def to_html( # pylint: disable=too-many-arguments, too-many-locals 300 obj: Widget | StyledText | str, 301 prefix: str | None = None, 302 inline_styles: bool = False, 303 include_background: bool = True, 304 vertical_offset: float = 0.0, 305 horizontal_offset: float = 0.0, 306 formatter: str = HTML_FORMAT, 307 joiner: str = "\n", 308) -> str: 309 """Creates a static HTML representation of the given object. 310 311 Note that the output HTML will not be very attractive or easy to read. This is 312 because these files probably aren't meant to be read by a human anyways, so file 313 sizes are more important. 314 315 If you do care about the visual style of the output, you can run it through some 316 prettifiers to get the result you are looking for. 317 318 Args: 319 obj: The object to represent. Takes either a Widget or some markup text. 320 prefix: The prefix included in the generated classes, e.g. instead of `ptg-0`, 321 you would get `ptg-my-prefix-0`. 322 inline_styles: If set, styles will be set for each span using the inline `style` 323 argument, otherwise a full style section is constructed. 324 include_background: Whether to include the terminal's background color in the 325 output. 326 """ 327 328 document_styles: list[list[str]] = [] 329 330 if isinstance(obj, Widget): 331 data = obj.get_lines() 332 333 else: 334 data = obj.splitlines() 335 336 lines = [] 337 for dataline in data: 338 line = "" 339 340 for span, styles in _get_spans( 341 dataline, vertical_offset, horizontal_offset, include_background 342 ): 343 index = _generate_index_in(document_styles, styles) 344 if index == len(document_styles): 345 document_styles.append(styles) 346 347 if inline_styles: 348 stylesheet = ";".join(styles) 349 line += span.format(f" styles='{stylesheet}'") 350 351 else: 352 line += span.format(" class='" + _get_cls(prefix, index) + "'") 353 354 # Close any previously not closed divs 355 line += "</div>" * (line.count("<div") - line.count("</div")) 356 lines.append(line) 357 358 stylesheet = "" 359 if not inline_styles: 360 stylesheet = _generate_stylesheet(document_styles, prefix) 361 362 document = formatter.format( 363 foreground=Color.get_default_foreground().hex, 364 background=Color.get_default_background().hex if include_background else "", 365 content=joiner.join(lines), 366 styles=stylesheet, 367 font_size=FONT_SIZE, 368 ) 369 370 return document 371 372 373def to_svg( 374 obj: Widget | StyledText | str, 375 prefix: str | None = None, 376 inline_styles: bool = False, 377 title: str = "PyTermGUI", 378 formatter: str = SVG_FORMAT, 379) -> str: 380 """Creates an SVG screenshot of the given object. 381 382 This screenshot tries to mimick what the Kitty terminal looks like on MacOS, 383 complete with the menu buttons and drop shadow. The `title` argument will be 384 displayed in the window's top bar. 385 386 Args: 387 obj: The object to represent. Takes either a Widget or some markup text. 388 prefix: The prefix included in the generated classes, e.g. instead of `ptg-0`, 389 you would get `ptg-my-prefix-0`. 390 inline_styles: If set, styles will be set for each span using the inline `style` 391 argument, otherwise a full style section is constructed. 392 title: A string to display in the top bar of the fake terminal. 393 formatter: The formatting string to use. Inspect `pytermgui.exporters.SVG_FORMAT` 394 to see all of its arguments. 395 """ 396 397 terminal = get_terminal() 398 width = terminal.width * FONT_SIZE * CHAR_WIDTH + MARGIN + 10 399 height = terminal.height * FONT_SIZE * CHAR_HEIGHT + 105 400 401 formatter = formatter.replace("{body_margin}", str(BODY_MARGIN)) 402 403 total_width = width + 2 * MARGIN + 2 * BODY_MARGIN 404 formatter = formatter.replace("{total_width}", str(total_width)) 405 406 total_height = height + 2 * MARGIN + 2 * BODY_MARGIN 407 formatter = formatter.replace("{total_height}", str(total_height)) 408 409 formatter = formatter.replace("{margined_width}", str(width)) 410 formatter = formatter.replace("{margined_height}", str(height)) 411 formatter = formatter.replace("{title}", title) 412 413 return to_html( 414 obj, 415 prefix=prefix, 416 inline_styles=inline_styles, 417 formatter=formatter, 418 vertical_offset=5 + MARGIN, 419 horizontal_offset=MARGIN, 420 joiner="\n<br />", 421 )
View Source
269def token_to_css(token: Token, invert: bool = False) -> str: 270 """Finds the CSS representation of a token. 271 272 Args: 273 token: The token to represent. 274 invert: If set, the role of background & foreground colors 275 are flipped. 276 """ 277 278 if token.ttype is TokenType.COLOR: 279 color = token.data 280 assert isinstance(color, Color) 281 282 style = "color:" + color.hex 283 284 if invert: 285 color.background = not color.background 286 287 if color.background: 288 style = "background-" + style 289 290 return style 291 292 if token.ttype is TokenType.STYLE and token.name in _STYLE_TO_CSS: 293 return _STYLE_TO_CSS[token.name] 294 295 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:
View Source
300def to_html( # pylint: disable=too-many-arguments, too-many-locals 301 obj: Widget | StyledText | str, 302 prefix: str | None = None, 303 inline_styles: bool = False, 304 include_background: bool = True, 305 vertical_offset: float = 0.0, 306 horizontal_offset: float = 0.0, 307 formatter: str = HTML_FORMAT, 308 joiner: str = "\n", 309) -> str: 310 """Creates a static HTML representation of the given object. 311 312 Note that the output HTML will not be very attractive or easy to read. This is 313 because these files probably aren't meant to be read by a human anyways, so file 314 sizes are more important. 315 316 If you do care about the visual style of the output, you can run it through some 317 prettifiers to get the result you are looking for. 318 319 Args: 320 obj: The object to represent. Takes either a Widget or some markup text. 321 prefix: The prefix included in the generated classes, e.g. instead of `ptg-0`, 322 you would get `ptg-my-prefix-0`. 323 inline_styles: If set, styles will be set for each span using the inline `style` 324 argument, otherwise a full style section is constructed. 325 include_background: Whether to include the terminal's background color in the 326 output. 327 """ 328 329 document_styles: list[list[str]] = [] 330 331 if isinstance(obj, Widget): 332 data = obj.get_lines() 333 334 else: 335 data = obj.splitlines() 336 337 lines = [] 338 for dataline in data: 339 line = "" 340 341 for span, styles in _get_spans( 342 dataline, vertical_offset, horizontal_offset, include_background 343 ): 344 index = _generate_index_in(document_styles, styles) 345 if index == len(document_styles): 346 document_styles.append(styles) 347 348 if inline_styles: 349 stylesheet = ";".join(styles) 350 line += span.format(f" styles='{stylesheet}'") 351 352 else: 353 line += span.format(" class='" + _get_cls(prefix, index) + "'") 354 355 # Close any previously not closed divs 356 line += "</div>" * (line.count("<div") - line.count("</div")) 357 lines.append(line) 358 359 stylesheet = "" 360 if not inline_styles: 361 stylesheet = _generate_stylesheet(document_styles, prefix) 362 363 document = formatter.format( 364 foreground=Color.get_default_foreground().hex, 365 background=Color.get_default_background().hex if include_background else "", 366 content=joiner.join(lines), 367 styles=stylesheet, 368 font_size=FONT_SIZE, 369 ) 370 371 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.