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