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(" ", "&#160;")
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 &nbsp;."""
335
336    return escape(text).replace(" ", "&#160;")
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    )
def token_to_css(token: pytermgui.markup.tokens.Token, invert: bool = False) -> str:
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 get ptg-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.