Enforce monospace in screenshots
This commit is contained in:
@@ -8,6 +8,7 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import signal
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -37,6 +38,12 @@ SCREENSHOT_MAX_BYTES = 65536
|
|||||||
SCREENSHOT_CACHE_SECONDS = 1.0
|
SCREENSHOT_CACHE_SECONDS = 1.0
|
||||||
SCREENSHOT_MAX_CACHE_SECONDS = 60.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"
|
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)
|
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 "</style>" in svg:
|
||||||
|
svg = svg.replace("</style>", override + "</style>", 1)
|
||||||
|
else:
|
||||||
|
svg = svg.replace("<svg ", f"<svg><style>{override}</style> ", 1)
|
||||||
|
|
||||||
|
return svg
|
||||||
|
|
||||||
|
|
||||||
def _apply_carriage_returns(text: str) -> list[str]:
|
def _apply_carriage_returns(text: str) -> list[str]:
|
||||||
"""Interpret \r as 'return to start of line' (overwrite), not a newline.
|
"""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 = await asyncio.to_thread(_render_svg)
|
||||||
|
svg = _rewrite_svg_fonts(svg)
|
||||||
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
|
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||||
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
|
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
|
||||||
self._screenshot_cache_etag[route_key] = etag
|
self._screenshot_cache_etag[route_key] = etag
|
||||||
|
|||||||
@@ -239,6 +239,8 @@ class TestLocalServerHelpers:
|
|||||||
response = await server._handle_screenshot(request)
|
response = await server._handle_screenshot(request)
|
||||||
assert response.content_type == "image/svg+xml"
|
assert response.content_type == "image/svg+xml"
|
||||||
assert "<svg" in response.text
|
assert "<svg" in response.text
|
||||||
|
assert "ui-monospace" in response.text
|
||||||
|
assert "cdnjs.cloudflare.com" not in response.text
|
||||||
assert created["called"][0] == "known"
|
assert created["called"][0] == "known"
|
||||||
assert created["called"][1:] == (132, 45)
|
assert created["called"][1:] == (132, 45)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user