From 5387423295023031f4536bbfaecc02caed344299 Mon Sep 17 00:00:00 2001 From: DimaAmega Date: Fri, 19 Jun 2026 11:17:51 +0000 Subject: [PATCH] use html instead of svg for terminal shell --- docs/_static/custom.css | 4 + docs/ext/ansi_html.py | 259 +++++++++++++++++++++++++++++++++++++++ docs/ext/ansi_svg.py | 170 ------------------------- docs/ext/terminal_ext.py | 22 ++-- 4 files changed, 276 insertions(+), 179 deletions(-) create mode 100644 docs/ext/ansi_html.py delete mode 100644 docs/ext/ansi_svg.py diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 5159ba7..b499a5c 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -16,3 +16,7 @@ code span.pre { white-space: nowrap; } + +.contree-terminal { + font-size: var(--code-font-size); +} diff --git a/docs/ext/ansi_html.py b/docs/ext/ansi_html.py new file mode 100644 index 0000000..9e5f57a --- /dev/null +++ b/docs/ext/ansi_html.py @@ -0,0 +1,259 @@ +"""Translate ANSI-colored terminal text into an HTML terminal window.""" + +from __future__ import annotations + +import html +import json +import re + +PADDING_X = 10 +PADDING_Y = 8 +HEADER_HEIGHT = 24 + +COLORS = { + 30: "#1e1e1e", + 31: "#e06c75", + 32: "#98c379", + 33: "#e5c07b", + 34: "#61afef", + 35: "#c678dd", + 36: "#56b6c2", + 37: "#abb2bf", + 90: "#5c6370", + 91: "#e06c75", + 92: "#98c379", + 93: "#e5c07b", + 94: "#61afef", + 95: "#c678dd", + 96: "#56b6c2", + 97: "#ffffff", +} + +DEFAULT_FG = "#c5c8c6" +BG_COLOR = "#1e1e1e" +TITLE_BAR_COLOR = "#292929" + +ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m") + +ANSI256_BASIC = [ + "#000000", + "#aa0000", + "#00aa00", + "#aa5500", + "#0000aa", + "#aa00aa", + "#00aaaa", + "#aaaaaa", + "#555555", + "#ff5555", + "#55ff55", + "#ffff55", + "#5555ff", + "#ff55ff", + "#55ffff", + "#ffffff", +] + +SYSTEM_FONT = ( + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" +) +MONO_FONT = ( + "JetBrains Mono, SF Mono, SFMono-Regular, Menlo, Monaco, Cascadia Mono, " + "Segoe UI Mono, Roboto Mono, Oxygen Mono, Ubuntu Monospace, Source Code Pro, " + "Fira Mono, Droid Sans Mono, Consolas, Courier New, monospace" +) + +# Characters that confuse MDX/JSX parsers inside text nodes. +_MDX_TEXT_ENTITIES = str.maketrans( + { + "{": "{", + "}": "}", + "`": "`", + "$": "$", + "_": "_", + "*": "*", + } +) + + +def ansi256_to_hex(n: int) -> str: + if n < 16: + return ANSI256_BASIC[n] + if n < 232: + n -= 16 + r = (n // 36) * 51 + g = ((n % 36) // 6) * 51 + b = (n % 6) * 51 + return f"#{r:02x}{g:02x}{b:02x}" + gray = 8 + (n - 232) * 10 + return f"#{gray:02x}{gray:02x}{gray:02x}" + + +def parse_ansi_spans(text: str) -> list[tuple[str, str, bool]]: + """Parse ANSI text into (text, color, bold) spans.""" + spans: list[tuple[str, str, bool]] = [] + color = DEFAULT_FG + bold = False + pos = 0 + + for m in ANSI_RE.finditer(text): + chunk = text[pos : m.start()] + if chunk: + spans.append((chunk, color, bold)) + pos = m.end() + + codes = [int(c) for c in m.group(1).split(";") if c] if m.group(1) else [0] + i = 0 + while i < len(codes): + code = codes[i] + if code == 0: + color, bold = DEFAULT_FG, False + elif code == 1: + bold = True + elif code == 38 and i + 2 < len(codes) and codes[i + 1] == 5: + color = ansi256_to_hex(codes[i + 2]) + i += 2 + elif code == 39: + color = DEFAULT_FG + elif code in COLORS: + color = COLORS[code] + i += 1 + + tail = text[pos:] + if tail: + spans.append((tail, color, bold)) + return spans + + +def escape_text(text: str, *, mdx: bool) -> str: + """Escape text for HTML; MDX builds also neutralize markdown/JSX syntax.""" + escaped = html.escape(text) + if mdx: + escaped = escaped.translate(_MDX_TEXT_ENTITIES) + return escaped + + +def _html_style(props: dict[str, str | int]) -> str: + """Render a CSS declaration string for plain HTML.""" + parts: list[str] = [] + for key, value in props.items(): + css_key = re.sub(r"[A-Z]", lambda m: "-" + m.group(0).lower(), key) + if isinstance(value, int): + parts.append(f"{css_key}: {value}px") + else: + parts.append(f"{css_key}: {value}") + return "; ".join(parts) + + +def _style_attr(props: dict[str, str | int], *, mdx: bool) -> str: + if mdx: + return f"style={{{json.dumps(props, separators=(',', ':'))}}}" + return f'style="{_html_style(props)}"' + + +def _class_attr(*, mdx: bool) -> str: + return 'className="contree-terminal"' if mdx else 'class="contree-terminal"' + + +def render_line_html(spans: list[tuple[str, str, bool]], *, mdx: bool) -> str: + """Render one line of spans as HTML with inline styles.""" + if not spans: + return "" + + parts: list[str] = [] + for text, color, bold in spans: + style_props: dict[str, str | int] = {"color": color} + if bold: + style_props["fontWeight"] = "bold" + parts.append( + f"" + f"{escape_text(text, mdx=mdx)}" + ) + + return "".join(parts) + + +def ansi_to_html_lines(text: str, *, mdx: bool) -> str: + """Convert ANSI text to HTML line spans joined for MDX (no raw newlines).""" + lines = text.rstrip("\n").split("\n") + br = "
" if mdx else "
" + return br.join(render_line_html(parse_ansi_spans(line), mdx=mdx) for line in lines) + + +def render_terminal(title: str, ansi_text: str, *, mdx: bool = False) -> str: + """Render complete HTML terminal window with ANSI-colored content.""" + content = ansi_to_html_lines(ansi_text, mdx=mdx) + escaped_title = escape_text(title, mdx=mdx) + + dot_style = { + "display": "inline-block", + "width": 10, + "height": 10, + "borderRadius": "50%", + } + red_dot_style = {**dot_style, "background": "#ff5f57", "marginRight": 6} + yellow_dot_style = {**dot_style, "background": "#febc2e", "marginRight": 6} + green_dot_style = {**dot_style, "background": "#28c840"} + + outer_style = { + "border": "1px solid rgba(255,255,255,0.15)", + "borderRadius": 8, + "overflow": "hidden", + "margin": "1rem 0", + } + + header_style = { + "background": TITLE_BAR_COLOR, + "height": HEADER_HEIGHT, + "display": "flex", + "alignItems": "center", + "padding": "0 12px", + "position": "relative", + } + + dots_row_style = { + "display": "flex", + "alignItems": "center", + "flexShrink": 0, + } + + title_style = { + "position": "absolute", + "left": 0, + "right": 0, + "textAlign": "center", + "fontFamily": SYSTEM_FONT, + "fontWeight": "bold", + "color": "#999", + } + + pre_style = { + "margin": 0, + "borderRadius": 0, + "padding": f"{PADDING_Y}px {PADDING_X}px", + "background": BG_COLOR, + "overflowX": "auto", + } + + code_style = { + "fontFamily": MONO_FONT, + "color": DEFAULT_FG, + "whiteSpace": "pre-wrap", + } + + # Single-line output: MDX treats newlines inside HTML as paragraph breaks. + return ( + f"
" + f"
" + f"
" + f"" + f"" + f"" + f"
" + f"
{escaped_title}
" + f"
" + f"
"
+        f"{content}"
+        f"
" + f"
" + ) diff --git a/docs/ext/ansi_svg.py b/docs/ext/ansi_svg.py deleted file mode 100644 index 87196a5..0000000 --- a/docs/ext/ansi_svg.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Translate ANSI-colored terminal text into SVG terminal window.""" - -from __future__ import annotations - -import os -import re -from pathlib import Path - -os.environ["FORCE_COLOR"] = "1" -from xml.sax.saxutils import escape - -FONT_SIZE = 12 -CHAR_WIDTH = 7.4 -LINE_HEIGHT = 15 -PADDING_X = 10 -PADDING_Y = 8 -HEADER_HEIGHT = 24 - -# ANSI SGR color codes → SVG fill colors (terminal palette) -COLORS = { - 30: "#1e1e1e", - 31: "#e06c75", - 32: "#98c379", - 33: "#e5c07b", - 34: "#61afef", - 35: "#c678dd", - 36: "#56b6c2", - 37: "#abb2bf", - 90: "#5c6370", - 91: "#e06c75", - 92: "#98c379", - 93: "#e5c07b", - 94: "#61afef", - 95: "#c678dd", - 96: "#56b6c2", - 97: "#ffffff", -} - -DEFAULT_FG = "#c5c8c6" -BG_COLOR = "#1e1e1e" -TITLE_BAR_COLOR = "#292929" - -ANSI_RE = re.compile(r"\x1b\[([0-9;]*)m") - -TEMPLATE = (Path(__file__).parent / "terminal-template.svg").read_text(encoding="utf-8") - - -ANSI256_BASIC = [ - "#000000", - "#aa0000", - "#00aa00", - "#aa5500", - "#0000aa", - "#aa00aa", - "#00aaaa", - "#aaaaaa", - "#555555", - "#ff5555", - "#55ff55", - "#ffff55", - "#5555ff", - "#ff55ff", - "#55ffff", - "#ffffff", -] - - -def ansi256_to_hex(n: int) -> str: - if n < 16: - return ANSI256_BASIC[n] - if n < 232: - n -= 16 - r = (n // 36) * 51 - g = ((n % 36) // 6) * 51 - b = (n % 6) * 51 - return f"#{r:02x}{g:02x}{b:02x}" - gray = 8 + (n - 232) * 10 - return f"#{gray:02x}{gray:02x}{gray:02x}" - - -def parse_ansi_spans(text: str) -> list[tuple[str, str, bool]]: - """Parse ANSI text into (text, color, bold) spans.""" - spans: list[tuple[str, str, bool]] = [] - color = DEFAULT_FG - bold = False - pos = 0 - - for m in ANSI_RE.finditer(text): - chunk = text[pos : m.start()] - if chunk: - spans.append((chunk, color, bold)) - pos = m.end() - - codes = [int(c) for c in m.group(1).split(";") if c] if m.group(1) else [0] - i = 0 - while i < len(codes): - code = codes[i] - if code == 0: - color, bold = DEFAULT_FG, False - elif code == 1: - bold = True - elif code == 38 and i + 2 < len(codes) and codes[i + 1] == 5: - color = ansi256_to_hex(codes[i + 2]) - i += 2 - elif code == 39: - color = DEFAULT_FG - elif code in COLORS: - color = COLORS[code] - i += 1 - - tail = text[pos:] - if tail: - spans.append((tail, color, bold)) - return spans - - -def render_line_svg(spans: list[tuple[str, str, bool]], y: float) -> str: - """Render one line of spans as SVG with s.""" - if not spans: - return "" - - parts: list[str] = [] - for text, color, bold in spans: - escaped = escape(text).replace(" ", " ") - attrs = f'fill="{color}"' - if bold: - attrs += ' font-weight="bold"' - parts.append(f"{escaped}") - - x = PADDING_X - return f'{"".join(parts)}' - - -def ansi_to_svg_lines(text: str) -> list[str]: - """Convert ANSI text to list of SVG elements.""" - lines = text.rstrip("\n").split("\n") - svg_lines: list[str] = [] - for i, line in enumerate(lines): - y = HEADER_HEIGHT + PADDING_Y + (i + 1) * LINE_HEIGHT - spans = parse_ansi_spans(line) - svg_line = render_line_svg(spans, y) - if svg_line: - svg_lines.append(svg_line) - return svg_lines - - -def render_terminal(title: str, ansi_text: str) -> str: - """Render complete SVG terminal window with ANSI-colored content.""" - content_lines = ansi_to_svg_lines(ansi_text) - num_lines = ansi_text.rstrip("\n").count("\n") + 1 - max_visible = max( - (len(ANSI_RE.sub("", line)) for line in ansi_text.split("\n")), - default=80, - ) - cols = max(max_visible, 80) + 2 - width = cols * CHAR_WIDTH + PADDING_X * 2 - height = HEADER_HEIGHT + PADDING_Y * 2 + num_lines * LINE_HEIGHT - - content = "\n ".join(content_lines) - - return TEMPLATE.format( - title=escape(title), - content=content, - width=width, - height=height, - bg_color=BG_COLOR, - title_bar_color=TITLE_BAR_COLOR, - font_size=FONT_SIZE, - line_height=LINE_HEIGHT, - ) diff --git a/docs/ext/terminal_ext.py b/docs/ext/terminal_ext.py index f2998b9..23b7a16 100644 --- a/docs/ext/terminal_ext.py +++ b/docs/ext/terminal_ext.py @@ -1,4 +1,4 @@ -"""Sphinx extension: render text or shell output as SVG terminal windows. +"""Sphinx extension: render text or shell output as HTML terminal windows. Directives:: @@ -24,7 +24,7 @@ def hello(): import subprocess from typing import Any, ClassVar -from ansi_svg import render_terminal +from ansi_html import render_terminal from docutils import nodes from docutils.parsers.rst import directives from sphinx.application import Sphinx @@ -33,6 +33,10 @@ def hello(): TERMINAL_COLUMNS = 100 +def _is_mdx_build(sphinx_directive: SphinxDirective) -> bool: + return getattr(sphinx_directive.env.app.builder, "format", "") == "mdx" + + def _highlight(text: str, language: str) -> str: """Highlight code with Pygments, return ANSI-colored text.""" from pygments import highlight @@ -44,7 +48,7 @@ def _highlight(text: str, language: str) -> str: class TerminalDirective(SphinxDirective): - """Render literal text content as an SVG terminal window. + """Render literal text content as an HTML terminal window. The argument is the window title. Content is the text body. Use :language: to syntax-highlight via Pygments. @@ -66,12 +70,12 @@ def run(self) -> list[nodes.Node]: language = self.options.get("language") if language: text = _highlight(text, language) - svg = render_terminal(title, text) - return [nodes.raw("", svg, format="html")] + html_output = render_terminal(title, text, mdx=_is_mdx_build(self)) + return [nodes.raw("", html_output, format="html")] class TerminalShellDirective(SphinxDirective): - """Run a shell command and render its output as an SVG terminal. + """Run a shell command and render its output as an HTML terminal. The argument is the shell command to execute. """ @@ -95,15 +99,15 @@ def run(self) -> list[nodes.Node]: output = result.stdout or result.stderr if not output.strip(): return [] - svg = render_terminal(f"$ {command}", output) - return [nodes.raw("", svg, format="html")] + html_output = render_terminal(f"$ {command}", output, mdx=_is_mdx_build(self)) + return [nodes.raw("", html_output, format="html")] def setup(app: Sphinx) -> dict[str, Any]: app.add_directive("terminal", TerminalDirective) app.add_directive("terminal-shell", TerminalShellDirective) return { - "version": "0.2", + "version": "0.1", "parallel_read_safe": True, "parallel_write_safe": True, }