From 9f43cccfce3b39b8ac637b4e8cdfe2f0946e9c23 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 29 Mar 2022 17:34:14 +0100 Subject: [PATCH] Exporting console content to SVG (#2101) * Add skeleton export_svg and save_svg methods to Console * Exporting SVG * SVG export - Writing HTML foreign object into naively calculated SVG rect background * Exporting as foreign object SVG * Working with drop-shadow * Update terminal output style to include tab and background/border * Add more terminal themes, support dim, reverse in SVG output * Fix some HTML export tests * Allow for templating of SVG output * Fix mypy issue involving shadowed variable in SVG export * Remove unused code from main block in console.py * Remove unused record=True in __main__.py Console init * Small tidy ups in console.py SVG export * Add test for exporting to SVG * Add tests for export SVG and save SVG * Update docs with info on SVG exports * Add support for blink and blink2 to SVG export, use Fira Code webfont fallback * Update tests for SVG exporting * Add more information to docs about SVG exporting * Update SVG exporting tests * Explain how to use different terminal theme in SVG export docs * Remove some development testing code * Remove some more testing code * Improve docs, fix typo * Fixing a typo * Add note to changelog about SVG export functionality * Use CSS styling instead of inline styles on SVG export * Fix issues noted in code review, fix reverse style * Update SVG used in Rich docs --- .../workflows/update_benchmark_website.yml | 2 +- CHANGELOG.md | 4 + docs/images/svg_export.svg | 817 ++++++++++++++++++ docs/source/console.rst | 32 +- rich/__main__.py | 8 +- rich/console.py | 301 ++++++- rich/terminal_theme.py | 98 +++ tests/test_console.py | 146 ++++ 8 files changed, 1391 insertions(+), 17 deletions(-) create mode 100644 docs/images/svg_export.svg diff --git a/.github/workflows/update_benchmark_website.yml b/.github/workflows/update_benchmark_website.yml index e6eccebb2..d2185039c 100644 --- a/.github/workflows/update_benchmark_website.yml +++ b/.github/workflows/update_benchmark_website.yml @@ -13,7 +13,7 @@ jobs: python-version: '3.10.2' - run: 'pip install asv' - run: 'asv publish' - - uses: pina/github-action-push-to-another-repository@v1.4.1 + - uses: cpina/github-action-push-to-another-repository@v1.4.1 name: 'Copy files to Textualize/rich-benchmarks repo' env: API_TOKEN_GITHUB: ${{ secrets.PUBLISH_ASV }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e58888666..596bd9977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- SVG export functionality https://github.com/Textualize/rich/pull/2101 + ### Fixed - Add missing `end` keyword argument to `Text.from_markup` diff --git a/docs/images/svg_export.svg b/docs/images/svg_export.svg new file mode 100644 index 000000000..91c5efa3e --- /dev/null +++ b/docs/images/svg_export.svg @@ -0,0 +1,817 @@ + + + + +
+
+
+ + + + + +
Rich can export to SVG
+
+
+
Rich features
+
+
Colors 4-bit color
+
8-bit color
+
Truecolor (16.7 million)
+
Dumb terminals
+
Automatic color conversion
+
+
Styles All ansi styles: bold, dim, italic, underline, strikethrough, reverse, and even blink.
+
+
Text Word wrap text. Justify left, center, right or full.
+
+
Lorem ipsum dolor sit amet, Lorem ipsum dolor sit amet, Lorem ipsum dolor sit amet, Lorem ipsum dolor sit amet,
+
consectetur adipiscing elit. Quisque consectetur adipiscing elit. Quisque consectetur adipiscing elit. Quisque consectetur adipiscing elit. Quisque
+
in metus sed sapien ultricies pretium in metus sed sapien ultricies pretium in metus sed sapien ultricies in metus sed sapien ultricies pretium
+
a at justo. Maecenas luctus velit et a at justo. Maecenas luctus velit et pretium a at justo. Maecenas luctus a at justo. Maecenas luctus velit et
+
auctor maximus. auctor maximus. velit et auctor maximus. auctor maximus.
+
+
Asian 🇨🇳 该库支持中文,日文和韩文文本!
+
language 🇯🇵 ライブラリは中国語、日本語、韓国語のテキストをサポートしています
+
support 🇰🇷 이 라이브러리는 중국어, 일본어 및 한국어 텍스트를 지원합니다
+
+
Markup Rich supports a simple bbcode-like markup for color, style, and emoji! 👍 🍎 🐜 🐻 🥖 🚌
+
+
Tables Date Title Production Budget Box Office
+
─────────────────────────────────────────────────────────────────────────────────────────
+
Dec 20, 2019 Star Wars: The Rise of Skywalker $275,000,000 $375,126,118
+
May 25, 2018 Solo: A Star Wars Story $275,000,000 $393,151,347
+
Dec 15, 2017 Star Wars Ep. VIII: The Last Jedi $262,000,000 $1,332,539,889
+
May 19, 1999 Star Wars Ep. I: The phantom Menace $115,000,000 $1,027,044,677
+
+
Syntax 1 def iter_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: {
+
highlighting 2 """Iterate and generate a tuple with a flag for last value.""" 'foo': [
+
& 3 iter_values = iter(values) │ │ 3.1427,
+
pretty 4 try: │ │ ('Paul Atreides', 'Vladimir Harkonnen', 'Thufir Hawat')
+
printing 5 │ │ previous_value = next(iter_values) ],
+
6 except StopIteration: 'atomic': (False, True, None)
+
7 │ │ return }
+
8 for value in iter_values:
+
9 │ │ yield False, previous_value
+
10 │ │ previous_value = value
+
11 yield True, previous_value
+
+
Markdown # Markdown ╔════════════════════════════════════════════════════════════════════════╗
+
Markdown
+
Supports much of the *markdown* __syntax__! ╚════════════════════════════════════════════════════════════════════════╝
+
+
- Headers Supports much of the markdown syntax!
+
- Basic formatting: **bold**, *italic*, `code`
+
- Block quotes Headers
+
- Lists, and more... Basic formatting: bold, italic, code
+
Block quotes
+
Lists, and more...
+
+
+more! Progress bars, columns, styled logging handler, tracebacks, etc...
+
+
+
+
+
+ +
+
diff --git a/docs/source/console.rst b/docs/source/console.rst index ed4b6c46f..585c2be38 100644 --- a/docs/source/console.rst +++ b/docs/source/console.rst @@ -249,15 +249,41 @@ If Python's builtin :mod:`readline` module is previously loaded, elaborate line Exporting --------- -The Console class can export anything written to it as either text or html. To enable exporting, first set ``record=True`` on the constructor. This tells Rich to save a copy of any data you ``print()`` or ``log()``. Here's an example:: +The Console class can export anything written to it as either text, svg, or html. To enable exporting, first set ``record=True`` on the constructor. This tells Rich to save a copy of any data you ``print()`` or ``log()``. Here's an example:: from rich.console import Console console = Console(record=True) -After you have written content, you can call :meth:`~rich.console.Console.export_text` or :meth:`~rich.console.Console.export_html` to get the console output as a string. You can also call :meth:`~rich.console.Console.save_text` or :meth:`~rich.console.Console.save_html` to write the contents directly to disk. +After you have written content, you can call :meth:`~rich.console.Console.export_text`, :meth:`~rich.console.Console.export_svg` or :meth:`~rich.console.Console.export_html` to get the console output as a string. You can also call :meth:`~rich.console.Console.save_text`, :meth:`~rich.console.Console.save_svg`, or :meth:`~rich.console.Console.save_html` to write the contents directly to disk. For examples of the html output generated by Rich Console, see :ref:`appendix-colors`. +Exporting SVGs +^^^^^^^^^^^^^^ + +When using :meth:`~rich.console.Console.export_svg` or :meth:`~rich.console.Console.save_svg`, the width of the generated SVG will +match the width (in terms of character cells) of your terminal window. The height of the exported SVG will scale automatically to accommodate the console output. + +The generated SVG can be viewed inside any web browser, and can be included on a webpage either by directly including the SVG markup +or by referencing the file itself using an ```` tag. For finer control over the dimensions, you'll have to use an ```` tag. + +The image below shows an example of an SVG exported by Rich. + +.. image:: ../images/svg_export.svg + +You can customise the theme used during SVG export by importing the desired theme from the :mod:`rich.terminal_theme` module and passing it to +:meth:`~rich.console.Console.export_svg` or :meth:`~rich.console.Console.save_svg` via the ``theme`` parameter:: + + + from rich.console import Console + from rich.terminal_theme import MONOKAI + + console = Console(record=True) + console.save_svg("example.svg", theme=MONOKAI) + +Alternatively, you can create your own theme by constructing a :class:`rich.terminal_theme.TerminalTheme` instance +yourself and passing that in. + Error console ------------- @@ -381,7 +407,7 @@ If Rich detects that it is not writing to a terminal it will strip control codes Letting Rich auto-detect terminals is useful as it will write plain text when you pipe output to a file or other application. Interactive mode -~~~~~~~~~~~~~~~~ +---------------- Rich will remove animations such as progress bars and status indicators when not writing to a terminal as you probably don't want to write these out to a text file (for example). You can override this behavior by setting the ``force_interactive`` argument on the constructor. Set it to True to enable animations or False to disable them. diff --git a/rich/__main__.py b/rich/__main__.py index 7b3ffb4f2..eb293a0ba 100644 --- a/rich/__main__.py +++ b/rich/__main__.py @@ -51,7 +51,6 @@ def make_test_card() -> Table: pad_edge=False, ) color_table.add_row( - # "[bold yellow]256[/] colors or [bold green]16.7 million[/] colors [blue](if supported by your terminal)[/].", ( "✓ [bold green]4-bit color[/]\n" "✓ [bold blue]8-bit color[/]\n" @@ -226,7 +225,12 @@ def iter_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: console.print(test_card) taken = round((process_time() - start) * 1000.0, 1) - Console().print(test_card) + c = Console(record=True) + c.print(test_card) + # c.save_svg( + # path="/Users/darrenburns/Library/Application Support/JetBrains/PyCharm2021.3/scratches/svg_export.svg", + # title="Rich can export to SVG", + # ) print(f"rendered in {pre_cache_taken}ms (cold cache)") print(f"rendered in {taken}ms (warm cache)") diff --git a/rich/console.py b/rich/console.py index 2a9a86dab..bcacbc738 100644 --- a/rich/console.py +++ b/rich/console.py @@ -60,7 +60,7 @@ from .segment import Segment from .style import Style, StyleType from .styled import Styled -from .terminal_theme import DEFAULT_TERMINAL_THEME, TerminalTheme +from .terminal_theme import DEFAULT_TERMINAL_THEME, SVG_EXPORT_THEME, TerminalTheme from .text import Text, TextType from .theme import Theme, ThemeStack @@ -115,6 +115,127 @@ class NoChange: """ +CONSOLE_SVG_FORMAT = """\ + + + + +
+
+
+ + + + + +
{title}
+
+
+ {code} +
+
+
+ +
+
+""" + _TERM_COLORS = {"256color": ColorSystem.EIGHT_BIT, "16color": ColorSystem.STANDARD} @@ -284,11 +405,9 @@ def __rich_console__( # A type that may be rendered by Console. RenderableType = Union[ConsoleRenderable, RichCast, str] - # The result of calling a __rich_console__ method. RenderResult = Iterable[Union[RenderableType, Segment]] - _null_highlighter = NullHighlighter() @@ -544,7 +663,6 @@ def _is_jupyter() -> bool: # pragma: no cover "windows": ColorSystem.WINDOWS, } - _COLOR_SYSTEMS_NAMES = {system: name for name, system in COLOR_SYSTEMS.items()} @@ -614,7 +732,7 @@ class Console: no_color (Optional[bool], optional): Enabled no color mode, or None to auto detect. Defaults to None. tab_size (int, optional): Number of spaces used to replace a tab character. Defaults to 8. record (bool, optional): Boolean to enable recording of terminal output, - required to call :meth:`export_html` and :meth:`export_text`. Defaults to False. + required to call :meth:`export_html`, :meth:`export_svg`, and :meth:`export_text`. Defaults to False. markup (bool, optional): Boolean to enable :ref:`console_markup`. Defaults to True. emoji (bool, optional): Enable emoji code. Defaults to True. emoji_variant (str, optional): Optional emoji variant, either "text" or "emoji". Defaults to None. @@ -1340,7 +1458,7 @@ def render_str( highlight: Optional[bool] = None, highlighter: Optional[HighlighterType] = None, ) -> "Text": - """Convert a string to a Text instance. This is is called automatically if + """Convert a string to a Text instance. This is called automatically if you print or log a string. Args: @@ -1390,7 +1508,7 @@ def render_str( def get_style( self, name: Union[str, Style], *, default: Optional[Union[Style, str]] = None ) -> Style: - """Get a Style instance by it's theme name or parse a definition. + """Get a Style instance by its theme name or parse a definition. Args: name (str): The name of a style or a style definition. @@ -2192,9 +2310,173 @@ def save_html( with open(path, "wt", encoding="utf-8") as write_file: write_file.write(html) + def export_svg( + self, + *, + title: str = "Rich", + theme: Optional[TerminalTheme] = None, + clear: bool = True, + code_format: str = CONSOLE_SVG_FORMAT, + ) -> str: + """Generate an SVG string from the console contents (requires record=True in Console constructor) + + Args: + title (str): The title of the tab in the output image + theme (TerminalTheme, optional): The ``TerminalTheme`` object to use to style the terminal + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True`` + code_format (str): Format string used to generate the SVG. Rich will inject a number of variables + into the string in order to form the final SVG output. The default template used and the variables + injected by Rich can be found by inspecting the ``console.CONSOLE_SVG_FORMAT`` variable. + + Returns: + str: The string representation of the SVG. That is, the ``code_format`` template with content injected. + """ + assert ( + self.record + ), "To export console contents set record=True in the constructor or instance" + + _theme = theme or SVG_EXPORT_THEME + + with self._record_buffer_lock: + segments = Segment.simplify(self._record_buffer) + segments = Segment.filter_control(segments) + parts = [(text, style or Style.null()) for text, style, _ in segments] + terminal_text = Text.assemble(*parts) + lines = terminal_text.wrap(self, width=self.width, overflow="fold") + segments = self.render(lines, options=self.options) + segment_lines = list( + Segment.split_and_crop_lines( + segments, length=self.width, include_new_lines=False + ) + ) + + fragments = [] + theme_foreground_color = _theme.foreground_color.hex + theme_background_color = _theme.background_color.hex + + theme_foreground_css = f"color: {theme_foreground_color}; text-decoration-color: {theme_foreground_color};" + theme_background_css = f"background-color: {theme_background_color};" + + theme_css = theme_foreground_css + theme_background_css + + styles: Dict[str, int] = {} + styles[theme_css] = 1 + + for line in segment_lines: + line_spans = [] + for segment in line: + text, style, _ = segment + text = escape(text) + if style: + rules = style.get_html_style(_theme) + if style.link: + text = f'{text}' + + if style.blink or style.blink2: + text = f'{text}' + + # If the style doesn't contain a color, we still + # need to make sure we output the default foreground color + # from the TerminalTheme. + if not style.reverse: + foreground_css = theme_foreground_css + background_css = theme_background_css + else: + foreground_css = f"color: {theme_background_color}; text-decoration-color: {theme_background_color};" + background_css = ( + f"background-color: {theme_foreground_color};" + ) + + if style.color is None: + rules += f";{foreground_css}" + if style.bgcolor is None: + rules += f";{background_css}" + + style_number = styles.setdefault(rules, len(styles) + 1) + text = f'{text}' + else: + text = f'{text}' + line_spans.append(text) + + fragments.append(f"
{''.join(line_spans)}
") + + stylesheet_rules = [] + for style_rule, style_number in styles.items(): + if style_rule: + stylesheet_rules.append(f".r{style_number} {{{ style_rule }}}") + stylesheet = "\n".join(stylesheet_rules) + + if clear: + self._record_buffer.clear() + + # These values are the ones that I found to work well after experimentation. + # Many of them can be tweaked, but too much variation from these values could + # result in visually broken output/clipping issues. + terminal_padding = 12 + font_size = 18 + line_height = font_size + 4 + code_start_y = 60 + required_code_height = line_height * len(lines) + margin = 140 + + # Monospace fonts are generally around 0.5-0.55 width/height ratio, but I've + # added extra width to ensure that the output SVG is big enough. + monospace_font_width_scale = 0.60 + + # This works out as a good heuristic for the final size of the drawn terminal. + terminal_height = required_code_height + code_start_y + terminal_width = ( + self.width * monospace_font_width_scale * font_size + + 2 * terminal_padding + + self.width + ) + total_height = terminal_height + 2 * margin + total_width = terminal_width + 2 * margin + + rendered_code = code_format.format( + code="\n".join(fragments), + total_height=total_height, + total_width=total_width, + theme_foreground_color=theme_foreground_color, + theme_background_color=theme_background_color, + margin=margin, + font_size=font_size, + line_height=line_height, + title=title, + stylesheet=stylesheet, + ) + + return rendered_code + + def save_svg( + self, + path: str, + *, + title: str = "Rich", + theme: Optional[TerminalTheme] = None, + clear: bool = True, + code_format: str = CONSOLE_SVG_FORMAT, + ) -> None: + """Generate an SVG file from the console contents (requires record=True in Console constructor). + + Args: + path (str): The path to write the SVG to. + title (str): The title of the tab in the output image + theme (TerminalTheme, optional): The ``TerminalTheme`` object to use to style the terminal + clear (bool, optional): Clear record buffer after exporting. Defaults to ``True`` + code_format (str): Format string used to generate the SVG. Rich will inject a number of variables + into the string in order to form the final SVG output. The default template used and the variables + injected by Rich can be found by inspecting the ``console.CONSOLE_SVG_FORMAT`` variable. + """ + svg = self.export_svg( + title=title, theme=theme, clear=clear, code_format=code_format + ) + with open(path, "wt", encoding="utf-8") as write_file: + write_file.write(svg) + if __name__ == "__main__": # pragma: no cover - console = Console() + console = Console(record=True) console.log( "JSONRPC [i]request[/i]", @@ -2247,6 +2529,3 @@ def save_html( }, } ) - console.log("foo") - - console.print_json(data={"name": "apple", "count": 1}, indent=None) diff --git a/rich/terminal_theme.py b/rich/terminal_theme.py index 801ac0b7b..ace8e93de 100644 --- a/rich/terminal_theme.py +++ b/rich/terminal_theme.py @@ -53,3 +53,101 @@ def __init__( (255, 255, 255), ], ) + +SVG_EXPORT_THEME = TerminalTheme( + (12, 12, 12), + (242, 242, 242), + [ + (12, 12, 12), + (205, 49, 49), + (13, 188, 121), + (229, 229, 16), + (36, 114, 200), + (188, 63, 188), + (17, 168, 205), + (229, 229, 229), + ], + [ + (102, 102, 102), + (241, 76, 76), + (35, 209, 139), + (245, 245, 67), + (59, 142, 234), + (214, 112, 214), + (41, 184, 219), + (229, 229, 229), + ], +) + +MONOKAI = TerminalTheme( + (12, 12, 12), + (217, 217, 217), + [ + (26, 26, 26), + (244, 0, 95), + (152, 224, 36), + (253, 151, 31), + (157, 101, 255), + (244, 0, 95), + (88, 209, 235), + (196, 197, 181), + (98, 94, 76), + ], + [ + (244, 0, 95), + (152, 224, 36), + (224, 213, 97), + (157, 101, 255), + (244, 0, 95), + (88, 209, 235), + (246, 246, 239), + ], +) +DIMMED_MONOKAI = TerminalTheme( + (25, 25, 25), + (185, 188, 186), + [ + (58, 61, 67), + (190, 63, 72), + (135, 154, 59), + (197, 166, 53), + (79, 118, 161), + (133, 92, 141), + (87, 143, 164), + (185, 188, 186), + (136, 137, 135), + ], + [ + (251, 0, 31), + (15, 114, 47), + (196, 112, 51), + (24, 109, 227), + (251, 0, 103), + (46, 112, 109), + (253, 255, 185), + ], +) +NIGHT_OWLISH = TerminalTheme( + (255, 255, 255), + (64, 63, 83), + [ + (1, 22, 39), + (211, 66, 62), + (42, 162, 152), + (218, 170, 1), + (72, 118, 214), + (64, 63, 83), + (8, 145, 106), + (122, 129, 129), + (122, 129, 129), + ], + [ + (247, 110, 110), + (73, 208, 197), + (218, 194, 107), + (92, 167, 228), + (105, 112, 152), + (0, 201, 144), + (152, 159, 177), + ], +) diff --git a/tests/test_console.py b/tests/test_console.py index 701ccf740..82339a3b6 100644 --- a/tests/test_console.py +++ b/tests/test_console.py @@ -450,6 +450,152 @@ def test_export_html_inline(): assert html == expected +EXPECTED_SVG = """\ + + + + +
+
+
+ + + + + +
Rich
+
+
+
foo Click
+
+
+
+
+ +
+
+""" + + +def test_export_svg(): + console = Console(record=True, width=100) + console.print( + "[b red on blue reverse]foo[/] [blink][link=https://example.org]Click[/link]" + ) + svg = console.export_svg() + assert svg == EXPECTED_SVG + + +def test_save_svg(): + console = Console(record=True, width=100) + console.print( + "[b red on blue reverse]foo[/] [blink][link=https://example.org]Click[/link]" + ) + with tempfile.TemporaryDirectory() as path: + export_path = os.path.join(path, "example.svg") + console.save_svg(export_path) + with open(export_path, "rt") as svg_file: + assert svg_file.read() == EXPECTED_SVG + + def test_save_text(): console = Console(record=True, width=100) console.print("foo")