mirror of
https://git.srhtnin.garden/sarah/SaraView.git
synced 2026-10-13 18:28:14 +02:00
316 lines
13 KiB
Python
316 lines
13 KiB
Python
"""Drawing Mermaid diagrams in a local folder, where there's no server to do
|
|
it (Diagrams.py): the same way the server's Backend/Diagrams.py does, with
|
|
Mermaid in a headless Chromium driven by Playwright.
|
|
|
|
Settings > General > "Draw diagrams in local folders" (Config.LocalDiagrams,
|
|
on by default). Nothing for this ships with SaraView: the first diagram sets
|
|
up what's needed, all of it in ToolsDir (~/.local/share/SaraView/DiagramTools):
|
|
- Playwright, installed with pip into ToolsDir/Python;
|
|
- Mermaid, the version the web view uses, downloaded from npm and checked;
|
|
- a headless Chromium (ToolsDir/Browsers), only when no Chromium-based
|
|
browser (Chrome, Chromium, Brave, Edge) is installed or it won't start.
|
|
An installed one is used as it is, never changed.
|
|
Turning the setting off deletes ToolsDir: all of that, and nothing else.
|
|
|
|
Playwright's sync API has to stay on the thread that started it, so one
|
|
worker thread does all the drawing; Render() hands it a job and waits."""
|
|
|
|
import base64
|
|
import hashlib
|
|
import io
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import threading
|
|
from concurrent.futures import Future
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
|
|
from Core import AssetsDir, ChildEnvironment, DataHome, AppName, IsWindows
|
|
|
|
ToolsDir = DataHome / AppName / "DiagramTools"
|
|
PythonDir = ToolsDir / "Python"
|
|
BrowsersDir = ToolsDir / "Browsers"
|
|
MermaidPath = ToolsDir / "mermaid.min.js"
|
|
PagePath = ToolsDir / "Render.html"
|
|
|
|
PlaywrightVersion = "1.62.0" # same as the server's (Backend/requirements.txt)
|
|
MermaidVersion = "11.17.2"
|
|
MermaidIntegrity = "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="
|
|
FontPath = AssetsDir / "fonts" / "Fixedsys_Excelsior_Regular.ttf"
|
|
RenderTimeout = 20 # seconds, per diagram
|
|
SetupTimeout = 900 # seconds, for the downloads
|
|
|
|
BrowserNames = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser",
|
|
"brave-browser", "brave", "microsoft-edge", "microsoft-edge-stable"]
|
|
WindowsBrowsers = [
|
|
r"Microsoft\Edge\Application\msedge.exe",
|
|
r"Google\Chrome\Application\chrome.exe",
|
|
r"BraveSoftware\Brave-Browser\Application\brave.exe",
|
|
]
|
|
|
|
|
|
class DiagramError(Exception):
|
|
"""The diagram itself is wrong (Mermaid's message)."""
|
|
|
|
|
|
class Unavailable(Exception):
|
|
"""Diagrams can't be drawn here (setup failed, or a frozen build)."""
|
|
|
|
|
|
def ThemeVariables(Palette: dict[str, str], FontSize: int) -> dict:
|
|
"""Mermaid's colours from the theme's roles: the same mapping as
|
|
Backend/Diagrams.py and the web view's Frontend/src/Mermaid.js."""
|
|
Role = Palette.get
|
|
Background = Role("Background")
|
|
Red, Green, Blue = (int(Background[Index:Index + 2], 16) for Index in (1, 3, 5))
|
|
return {
|
|
"darkMode": (0.299 * Red + 0.587 * Green + 0.114 * Blue) / 255 < 0.5,
|
|
"background": Background,
|
|
"fontFamily": '"Fixedsys Excelsior", "Cascadia Mono", monospace',
|
|
"fontSize": f"{FontSize}px",
|
|
"textColor": Role("Text"), "lineColor": Role("TextMuted"),
|
|
"primaryColor": Role("SurfaceAlt"), "primaryTextColor": Role("Text"), "primaryBorderColor": Role("Purple"),
|
|
"secondaryColor": Role("Surface"), "secondaryTextColor": Role("Text"), "secondaryBorderColor": Role("Blue"),
|
|
"tertiaryColor": Role("Background"), "tertiaryTextColor": Role("Text"), "tertiaryBorderColor": Role("Border"),
|
|
"mainBkg": Role("SurfaceAlt"), "nodeBorder": Role("Purple"),
|
|
"clusterBkg": Role("Surface"), "clusterBorder": Role("Border"),
|
|
"edgeLabelBackground": Role("Surface"), "titleColor": Role("Text"),
|
|
"noteBkgColor": Role("Surface"), "noteTextColor": Role("Text"), "noteBorderColor": Role("Yellow"),
|
|
"actorBkg": Role("SurfaceAlt"), "actorBorder": Role("Purple"), "actorTextColor": Role("Text"),
|
|
"actorLineColor": Role("TextMuted"), "signalColor": Role("Text"), "signalTextColor": Role("Text"),
|
|
"labelBoxBkgColor": Role("SurfaceAlt"), "labelBoxBorderColor": Role("Purple"), "labelTextColor": Role("Text"),
|
|
"loopTextColor": Role("Text"), "activationBkgColor": Role("Surface"), "activationBorderColor": Role("Blue"),
|
|
"pie1": Role("Blue"), "pie2": Role("Purple"), "pie3": Role("Green"),
|
|
"pie4": Role("Yellow"), "pie5": Role("Orange"), "pie6": Role("Red"),
|
|
"pieTitleTextColor": Role("Text"), "pieSectionTextColor": Role("Black"), "pieLegendTextColor": Role("Text"),
|
|
"pieStrokeColor": Background, "pieOuterStrokeColor": Role("Border"),
|
|
}
|
|
|
|
|
|
# The same drawing as Backend/Diagrams.py's RenderScript.
|
|
RenderScript = """async ({ Source, Variables, Id }) => {
|
|
await document.fonts.ready;
|
|
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "base", themeVariables: Variables });
|
|
try {
|
|
await mermaid.parse(Source);
|
|
} catch (Error) {
|
|
return { Error: String(Error.message || Error) };
|
|
}
|
|
const { svg } = await mermaid.render(Id, Source);
|
|
const Out = document.getElementById("Out");
|
|
Out.innerHTML = svg;
|
|
const Picture = Out.querySelector("svg");
|
|
const Box = Picture.viewBox.baseVal;
|
|
Picture.style.maxWidth = "none";
|
|
Picture.setAttribute("width", Box.width);
|
|
Picture.setAttribute("height", Box.height);
|
|
return {};
|
|
}"""
|
|
|
|
|
|
# --- Setup ----------------------------------------------------------------
|
|
|
|
def _Run(Command: list[str], Environment: Optional[dict] = None) -> None:
|
|
Result = subprocess.run(Command, env=Environment or ChildEnvironment(), capture_output=True, text=True,
|
|
timeout=SetupTimeout)
|
|
if Result.returncode != 0:
|
|
Detail = (Result.stderr or Result.stdout).strip().splitlines()
|
|
raise Unavailable(Detail[-1] if Detail else f"{Command[0]} failed")
|
|
|
|
|
|
def _PlaywrightEnvironment() -> dict:
|
|
Environment = ChildEnvironment()
|
|
Environment["PYTHONPATH"] = str(PythonDir)
|
|
Environment["PLAYWRIGHT_BROWSERS_PATH"] = str(BrowsersDir)
|
|
return Environment
|
|
|
|
|
|
def _InstallPlaywright() -> None:
|
|
if (PythonDir / "playwright").is_dir():
|
|
return
|
|
if getattr(sys, "frozen", False):
|
|
raise Unavailable("This build of SaraView can't install what diagrams need; "
|
|
"the SaraSuite install can.")
|
|
_Run([sys.executable, "-m", "pip", "install", "--quiet", "--disable-pip-version-check",
|
|
"--target", str(PythonDir), f"playwright=={PlaywrightVersion}"])
|
|
|
|
|
|
def _InstallMermaid() -> None:
|
|
if MermaidPath.is_file():
|
|
return
|
|
Url = f"https://registry.npmjs.org/mermaid/-/mermaid-{MermaidVersion}.tgz"
|
|
try:
|
|
Archive = httpx.get(Url, timeout=120, follow_redirects=True).raise_for_status().content
|
|
except httpx.HTTPError as Error:
|
|
raise Unavailable(f"Couldn't download Mermaid: {Error}")
|
|
Expected = base64.b64decode(MermaidIntegrity.removeprefix("sha512-"))
|
|
if hashlib.sha512(Archive).digest() != Expected:
|
|
raise Unavailable("The Mermaid download didn't match its checksum.")
|
|
with tarfile.open(fileobj=io.BytesIO(Archive), mode="r:gz") as Tar:
|
|
Script = Tar.extractfile("package/dist/mermaid.min.js")
|
|
if Script is None:
|
|
raise Unavailable("The Mermaid download didn't have mermaid.min.js.")
|
|
Partial = MermaidPath.with_suffix(".part")
|
|
Partial.write_bytes(Script.read())
|
|
Partial.replace(MermaidPath)
|
|
|
|
|
|
def _InstalledBrowser() -> Optional[str]:
|
|
if IsWindows:
|
|
for Base in (os.environ.get("ProgramFiles(x86)"), os.environ.get("ProgramFiles"),
|
|
os.environ.get("LOCALAPPDATA")):
|
|
for Relative in WindowsBrowsers:
|
|
if Base and (Path(Base) / Relative).is_file():
|
|
return str(Path(Base) / Relative)
|
|
return None
|
|
return next((Found for Name in BrowserNames if (Found := shutil.which(Name))), None)
|
|
|
|
|
|
def _InstallBrowser() -> None:
|
|
_Run([sys.executable, "-m", "playwright", "install", "--only-shell", "chromium"], _PlaywrightEnvironment())
|
|
|
|
|
|
def _WritePage() -> None:
|
|
FontFace = f'@font-face {{ font-family: "Fixedsys Excelsior"; src: url("{FontPath.as_uri()}"); }}' if FontPath.is_file() else ""
|
|
PagePath.write_text(
|
|
"<!doctype html><html><head><meta charset='utf-8'>"
|
|
f"<style>{FontFace} body {{ margin: 0; }} #Out {{ display: inline-block; padding: 8px; }}</style>"
|
|
f"<script src='{MermaidPath.as_uri()}'></script></head><body><div id='Out'></div></body></html>",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
# --- The worker -----------------------------------------------------------
|
|
|
|
class _Worker:
|
|
def __init__(self) -> None:
|
|
self.Jobs: "queue.Queue[Optional[tuple]]" = queue.Queue()
|
|
self.Thread: Optional[threading.Thread] = None
|
|
self.Lock = threading.Lock()
|
|
self.Failure: Optional[str] = None # why it can't draw, once setup failed
|
|
self.SettingUp = False
|
|
|
|
def Start(self) -> None:
|
|
with self.Lock:
|
|
if self.Thread is None or not self.Thread.is_alive():
|
|
self.Thread = threading.Thread(target=self._Run, name="LocalDiagrams", daemon=True)
|
|
self.Thread.start()
|
|
|
|
def Stop(self) -> None:
|
|
with self.Lock:
|
|
Thread = self.Thread
|
|
self.Thread = None
|
|
if Thread is not None and Thread.is_alive():
|
|
self.Jobs.put(None)
|
|
Thread.join(timeout=30)
|
|
|
|
def _Setup(self):
|
|
"""Everything ToolsDir needs, then a page ready to draw on."""
|
|
self.SettingUp = True
|
|
try:
|
|
ToolsDir.mkdir(parents=True, exist_ok=True)
|
|
_InstallPlaywright()
|
|
_InstallMermaid()
|
|
_WritePage()
|
|
if str(PythonDir) not in sys.path:
|
|
sys.path.insert(0, str(PythonDir))
|
|
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(BrowsersDir)
|
|
from playwright.sync_api import sync_playwright
|
|
|
|
Playwright = sync_playwright().start()
|
|
Browser = None
|
|
Installed = _InstalledBrowser()
|
|
if Installed:
|
|
try:
|
|
Browser = Playwright.chromium.launch(executable_path=Installed)
|
|
except Exception:
|
|
Browser = None # e.g. a Snap Chromium that can't be driven: use our own
|
|
if Browser is None:
|
|
if not BrowsersDir.is_dir():
|
|
_InstallBrowser()
|
|
Browser = Playwright.chromium.launch()
|
|
Page = Browser.new_context(device_scale_factor=2, viewport={"width": 1600, "height": 1200}).new_page()
|
|
Page.route("**/*", lambda Route: Route.continue_() if Route.request.url.startswith("file:") else Route.abort())
|
|
Page.goto(PagePath.as_uri())
|
|
return Playwright, Browser, Page
|
|
finally:
|
|
self.SettingUp = False
|
|
|
|
def _Run(self) -> None:
|
|
Playwright = Browser = Page = None
|
|
Count = 0
|
|
try:
|
|
while True:
|
|
Job = self.Jobs.get()
|
|
if Job is None:
|
|
return
|
|
Source, Palette, FontSize, Result = Job
|
|
try:
|
|
if Page is None:
|
|
Playwright, Browser, Page = self._Setup()
|
|
Count += 1
|
|
Outcome = Page.evaluate(RenderScript, {"Source": Source, "Variables": ThemeVariables(Palette, FontSize),
|
|
"Id": f"Diagram{Count}"})
|
|
if Outcome.get("Error"):
|
|
raise DiagramError(Outcome["Error"])
|
|
Result.set_result(Page.locator("#Out").screenshot(omit_background=True, timeout=RenderTimeout * 1000))
|
|
except DiagramError as Error:
|
|
Result.set_exception(Error)
|
|
except Unavailable as Error:
|
|
self.Failure = str(Error)
|
|
Result.set_exception(Error)
|
|
except Exception as Error:
|
|
if Page is None: # setup itself broke
|
|
self.Failure = str(Error)
|
|
Result.set_exception(Unavailable(str(Error)))
|
|
else:
|
|
Result.set_exception(DiagramError(f"Couldn't draw the diagram ({Error})."))
|
|
finally:
|
|
for Closing in (Browser, Playwright):
|
|
try:
|
|
if Closing is not None:
|
|
Closing.close() if Closing is Browser else Closing.stop()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
_SharedWorker = _Worker()
|
|
|
|
|
|
def IsSetUp() -> bool:
|
|
"""Whether the first diagram will draw without downloading anything."""
|
|
return (PythonDir / "playwright").is_dir() and MermaidPath.is_file()
|
|
|
|
|
|
def IsSettingUp() -> bool:
|
|
return _SharedWorker.SettingUp
|
|
|
|
|
|
def Render(Source: str, Palette: dict[str, str], FontSize: int) -> bytes:
|
|
"""A diagram as a PNG at twice the size. Blocks: call it off the UI thread.
|
|
Raises DiagramError for a wrong diagram, Unavailable if it can't draw."""
|
|
if _SharedWorker.Failure is not None:
|
|
raise Unavailable(_SharedWorker.Failure)
|
|
_SharedWorker.Start()
|
|
Result: Future = Future()
|
|
_SharedWorker.Jobs.put((Source, Palette, FontSize, Result))
|
|
return Result.result(timeout=SetupTimeout + RenderTimeout)
|
|
|
|
|
|
def Remove() -> None:
|
|
"""Stops drawing and deletes ToolsDir: everything set up for local diagrams."""
|
|
_SharedWorker.Stop()
|
|
_SharedWorker.Failure = None
|
|
shutil.rmtree(ToolsDir, ignore_errors=True)
|
|
|
|
|
|
def ToolsSize() -> int:
|
|
"""Bytes in ToolsDir, for the setting's note."""
|
|
return sum(File.stat().st_size for File in ToolsDir.rglob("*") if File.is_file()) if ToolsDir.is_dir() else 0
|