From 557eafc1639897b9e96157c7b3a96f14e38fcc2b Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 22 Jan 2026 13:02:28 +0000 Subject: [PATCH] Enforce monospace in screenshots --- src/textual_webterm/local_server.py | 24 ++++++++++++++++++++++++ tests/test_local_server_unit.py | 2 ++ 2 files changed, 26 insertions(+) diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index 888438d..26d588d 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -8,6 +8,7 @@ import hashlib import io import json import logging +import re import signal from pathlib import Path from typing import TYPE_CHECKING @@ -37,6 +38,12 @@ SCREENSHOT_MAX_BYTES = 65536 SCREENSHOT_CACHE_SECONDS = 1.0 SCREENSHOT_MAX_CACHE_SECONDS = 60.0 +SVG_MONO_FONT_STACK = ( + 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' + '"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' + '"DejaVu Sans Mono", "Courier New", monospace' +) + WEBTERM_STATIC_PATH = Path(__file__).parent / "static" @@ -84,6 +91,22 @@ class LocalClientConnector(SessionConnector): await self.server.handle_session_close(self.session_id, self.route_key) +def _rewrite_svg_fonts(svg: str) -> str: + """Make Rich SVG output self-contained and aligned with our monospace styling.""" + + # Rich export_svg embeds @font-face rules that reference external CDNs. + svg = re.sub(r"@font-face\s*\{.*?\}\s*", "", svg, flags=re.DOTALL) + + # Force our local monospace stack even if Rich sets font-family to Fira Code. + override = f"\ntext {{ font-family: {SVG_MONO_FONT_STACK} !important; }}\n" + if "" in svg: + svg = svg.replace("", override + "", 1) + else: + svg = svg.replace(" ", 1) + + return svg + + def _apply_carriage_returns(text: str) -> list[str]: """Interpret \r as 'return to start of line' (overwrite), not a newline. @@ -529,6 +552,7 @@ class LocalServer: ) svg = await asyncio.to_thread(_render_svg) + svg = _rewrite_svg_fonts(svg) etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest() self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg) self._screenshot_cache_etag[route_key] = etag diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index 8677fd1..17e32a6 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -239,6 +239,8 @@ class TestLocalServerHelpers: response = await server._handle_screenshot(request) assert response.content_type == "image/svg+xml" assert "