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(" ", "&#160;")
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    )
#   def token_to_css(token: pytermgui.parser.Token, invert: bool = False) -> str:
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 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.