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(" ", "&#160;")
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 &nbsp;."""
342
343    return escape(text).replace(" ", "&#160;")
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 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.