diff --git a/.gitignore b/.gitignore index 2b179d1..d0514e9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ Frontend/dist/ .DS_Store .vscode/ .idea/ +Backend/Vendor/ diff --git a/Backend/Diagrams.py b/Backend/Diagrams.py new file mode 100644 index 0000000..e1ad1b9 --- /dev/null +++ b/Backend/Diagrams.py @@ -0,0 +1,243 @@ +"""Mermaid diagrams as images, for the desktop client (POST /api/diagram). + +The web view draws ```mermaid blocks itself (Frontend/src/Mermaid.js), but +Flet can't, so the client asks the server instead. This draws them the same +way, with the same Mermaid, in a headless Chromium (Playwright) that's +started on the first diagram and kept for the next ones, and returns a PNG +at twice the size (sharp on HiDPI screens; the client shows it at half). + +Mermaid comes from the frontend build (the Dockerfile copies it to +Backend/Vendor/; for local development, Frontend/node_modules after +`npm install`), the font from Frontend's public/Fonts.""" + +from __future__ import annotations + +import asyncio +import hashlib +import json +import re +import tempfile +import tomllib +from collections import OrderedDict +from pathlib import Path +from typing import Any, Optional + +from Settings import DefaultThemesDirectory, PaletteRoles + +ApplicationDirectory = Path(__file__).resolve().parent +ProjectDirectory = ApplicationDirectory.parent + +MermaidCandidates = [ + ApplicationDirectory / "Vendor" / "mermaid.min.js", + ProjectDirectory / "Frontend" / "node_modules" / "mermaid" / "dist" / "mermaid.min.js", +] +FontCandidates = [ + ProjectDirectory / "Frontend" / "dist" / "Fonts" / "FSEX302.ttf", + ProjectDirectory / "Frontend" / "public" / "Fonts" / "FSEX302.ttf", +] + +MaxSourceLength = 20_000 +RenderTimeout = 20 # seconds +CacheSize = 200 +HexColour = re.compile(r"^#[0-9a-fA-F]{6}$") + + +class DiagramError(Exception): + """The diagram itself is wrong (Mermaid's message).""" + + +class DiagramsUnavailable(Exception): + """This server can't draw diagrams (no Chromium or no Mermaid).""" + + +def _FirstExisting(Candidates: list[Path]) -> Optional[Path]: + return next((Candidate for Candidate in Candidates if Candidate.is_file()), None) + + +def _DefaultPalette() -> dict[str, str]: + with (DefaultThemesDirectory / "CatppuccinMocha.toml").open("rb") as ThemeFile: + return tomllib.load(ThemeFile).get("Palette", {}) + + +def CleanPalette(Palette: dict[str, Any]) -> dict[str, str]: + """The default theme, overridden by whichever roles were sent as #rrggbb.""" + Clean = _DefaultPalette() + for Role in PaletteRoles: + Value = Palette.get(Role) + if isinstance(Value, str) and HexColour.match(Value): + Clean[Role] = Value + return Clean + + +def ThemeVariables(Palette: dict[str, str], FontSize: int) -> dict[str, Any]: + """Mermaid's colours from the theme's roles: the same mapping as the web + view's Frontend/src/Mermaid.js, so a diagram looks the same in both.""" + Role = Palette.get + Background = Role("Background") + Red, Green, Blue = (int(Background[Index:Index + 2], 16) for Index in (1, 3, 5)) + Luminance = (0.299 * Red + 0.587 * Green + 0.114 * Blue) / 255 + return { + "darkMode": Luminance < 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"), + } + + +# Draws #Out from the source; the page has Mermaid and the font loaded. +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; + // Mermaid shrinks diagrams to fit their container; here they get their own size. + 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 {}; +}""" + + +class _Renderer: + def __init__(self) -> None: + self.Lock = asyncio.Lock() + self.Playwright = None + self.Browser = None + self.Page = None + self.PageDirectory: Optional[tempfile.TemporaryDirectory] = None + self.Count = 0 + self.Cache: OrderedDict[str, bytes] = OrderedDict() + + async def _EnsurePage(self) -> None: + if self.Page is not None and not self.Page.is_closed(): + return + Mermaid = _FirstExisting(MermaidCandidates) + if Mermaid is None: + raise DiagramsUnavailable("Diagrams aren't set up on this server (Mermaid is missing).") + try: + from playwright.async_api import async_playwright + except ImportError: + raise DiagramsUnavailable("Diagrams aren't set up on this server (Playwright is missing).") + + if self.Browser is None or not self.Browser.is_connected(): + try: + self.Playwright = self.Playwright or await async_playwright().start() + self.Browser = await self.Playwright.chromium.launch() + except Exception as Error: + raise DiagramsUnavailable(f"Diagrams aren't set up on this server ({Error}).") + + Font = _FirstExisting(FontCandidates) + FontFace = f'@font-face {{ font-family: "Fixedsys Excelsior"; src: url("{Font.as_uri()}"); }}' if Font else "" + self.PageDirectory = self.PageDirectory or tempfile.TemporaryDirectory(prefix="SaraViewDiagrams") + PagePath = Path(self.PageDirectory.name) / "Render.html" + PagePath.write_text( + "" + f"" + f"
", + encoding="utf-8", + ) + Context = await self.Browser.new_context(device_scale_factor=2, viewport={"width": 1600, "height": 1200}) + self.Page = await Context.new_page() + # Only the page's own files: a diagram can't make the server fetch anything. + async def OnlyLocalFiles(Route) -> None: + if Route.request.url.startswith("file:"): + await Route.continue_() + else: + await Route.abort() + + await self.Page.route("**/*", OnlyLocalFiles) + await self.Page.goto(PagePath.as_uri()) + + async def Render(self, Source: str, Palette: dict[str, Any], FontSize: int) -> bytes: + if not Source.strip(): + raise DiagramError("The diagram is empty.") + if len(Source) > MaxSourceLength: + raise DiagramError(f"The diagram is too long (over {MaxSourceLength} characters).") + Variables = ThemeVariables(CleanPalette(Palette), max(8, min(int(FontSize), 40))) + Key = hashlib.sha256(json.dumps([Source, Variables], sort_keys=True).encode()).hexdigest() + + async with self.Lock: + if Key in self.Cache: + self.Cache.move_to_end(Key) + return self.Cache[Key] + await self._EnsurePage() + self.Count += 1 + try: + Result = await asyncio.wait_for( + self.Page.evaluate(RenderScript, {"Source": Source, "Variables": Variables, "Id": f"Diagram{self.Count}"}), + RenderTimeout, + ) + if Result.get("Error"): + raise DiagramError(Result["Error"]) + Png = await self.Page.locator("#Out").screenshot(omit_background=True, timeout=RenderTimeout * 1000) + except DiagramError: + raise + except Exception as Error: + # A stuck or crashed page: start a fresh one next time. + if self.Page is not None: + await self.Page.context.close() + self.Page = None + raise DiagramError(f"Couldn't draw the diagram ({Error}).") + + self.Cache[Key] = Png + if len(self.Cache) > CacheSize: + self.Cache.popitem(last=False) + return Png + + +_SharedRenderer = _Renderer() + + +async def RenderDiagram(Source: str, Palette: dict[str, Any], FontSize: int) -> bytes: + return await _SharedRenderer.Render(Source, Palette, FontSize) diff --git a/Backend/Main.py b/Backend/Main.py index cca553a..ecdebb6 100644 --- a/Backend/Main.py +++ b/Backend/Main.py @@ -29,6 +29,7 @@ from Auth import ( from BookReader import ReadBookForFile from Blame import LoadBlame, RenameUserInBlame, UpdateBlameOnSave from Core import NormalizeRelativePath, ResolvePath +from Diagrams import DiagramError, DiagramsUnavailable, RenderDiagram from Embeds import BuildOgTags from Editor import WriteTextFile from Explorer import ( @@ -833,6 +834,25 @@ def PutSettingsEndpoint(NewSettings: dict, CurrentUser: dict = Depends(GetCurren return SaveSettings(CurrentUser["Username"], NewSettings) +class DiagramRequest(BaseModel): + Source: str + Palette: dict[str, str] = {} + FontSize: int = 14 + + +# A ```mermaid block as a PNG, for the desktop client (the web view draws its +# own). Palette is the client's theme by role, so it matches the client. +@Application.post("/api/diagram") +async def PostDiagramEndpoint(Body: DiagramRequest, CurrentUser: dict = Depends(GetCurrentUserOrGuest)) -> Response: + try: + Png = await RenderDiagram(Body.Source, Body.Palette, Body.FontSize) + except DiagramError as Error: + raise HTTPException(status_code=422, detail=str(Error)) + except DiagramsUnavailable as Error: + raise HTTPException(status_code=503, detail=str(Error)) + return Response(content=Png, media_type="image/png") + + @Application.get("/api/themes") def GetThemesEndpoint(CurrentUser: dict = Depends(GetCurrentUser)) -> list[str]: return ListThemeNames() diff --git a/Backend/requirements.txt b/Backend/requirements.txt index bb4e3f0..20a0075 100644 --- a/Backend/requirements.txt +++ b/Backend/requirements.txt @@ -2,3 +2,4 @@ fastapi==0.115.0 uvicorn[standard]==0.30.6 tomli-w==1.0.0 python-multipart==0.0.12 +playwright==1.62.0 diff --git a/Client/Diagrams.py b/Client/Diagrams.py new file mode 100644 index 0000000..c48c1d9 --- /dev/null +++ b/Client/Diagrams.py @@ -0,0 +1,141 @@ +"""Mermaid diagrams (```mermaid blocks), like the web view's. Flet can't draw +them, so the server does (POST /api/diagram, Backend/Diagrams.py) and they're +shown as pictures in this client's theme. In a local folder (no server), or +if the server can't draw them, they stay code blocks. + +A DiagramBlock shows its code straight away and swaps in the picture once it +arrives (fetched on a background thread), so opening a note never waits on +the server. Pictures are kept for the session, so the live editor's +re-renders don't ask again.""" + +import re +import struct +from typing import Callable, Optional + +import flet as ft + +from Core import CodeStyleSheet, MarkdownCodeTheme, MarkdownSheet, Palette +from Sources import SourceError + +DiagramFence = re.compile(r"^ {0,3}(`{3,}|~{3,})[ \t]*mermaid[ \t]*$", re.IGNORECASE) + +# Per session: pictures and Mermaid's complaints by (source, font size), and +# whether the server said it can't draw diagrams at all. +Pictures: dict[tuple[str, int], bytes] = {} +Problems: dict[tuple[str, int], str] = {} +ServerCantDraw = False + + +def DiagramSource(BlockText: str) -> Optional[str]: + """The diagram inside a ```mermaid code block, or None for other blocks.""" + Lines = BlockText.split("\n") + Match = DiagramFence.match(Lines[0]) + if not Match: + return None + Fence = Match.group(1) + Body = Lines[1:] + if Body: + Last = Body[-1].strip() + if Last.startswith(Fence) and not Last.strip(Fence[0]): + Body = Body[:-1] + return "\n".join(Body) + + +def ThemePalette() -> dict[str, str]: + """This client's theme by the server's role names (see Core.Palette).""" + return { + "Background": Palette.Base, "Surface": Palette.Mantle, "SurfaceAlt": Palette.Surface0, + "Border": Palette.Surface1, "Text": Palette.Text, "TextMuted": Palette.Overlay0, + "Black": Palette.Crust, "White": Palette.White, "Red": Palette.Red, "Orange": Palette.Peach, + "Yellow": Palette.Yellow, "Green": Palette.Green, "Blue": Palette.Blue, "Purple": Palette.Mauve, + } + + +def CanDraw(TheApp) -> bool: + return TheApp.Source.Kind == "Server" and not ServerCantDraw + + +class DiagramBlock(ft.Container): + def __init__(self, TheApp, Source: str, FontSize: int): + self.TheApp = TheApp + self.Source = Source + self.FontSize = FontSize + self.Key = (Source, FontSize) + self.Mounted = False + self.Fetching = False + super().__init__(content=self._Content()) + + def _Code(self) -> ft.Control: + return ft.Markdown( + value=f"```\n{self.Source}\n```", + extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, + md_style_sheet=MarkdownSheet(self.FontSize), + code_theme=MarkdownCodeTheme(), + code_style_sheet=CodeStyleSheet(), + ) + + def _Content(self) -> ft.Control: + Picture = Pictures.get(self.Key) + if Picture is not None: + # The server draws at twice the size; shown at half, it's sharp on HiDPI. + Width, Height = struct.unpack(">II", Picture[16:24]) + return ft.Row([ft.Image(src=Picture, width=Width / 2, height=Height / 2)], + alignment=ft.MainAxisAlignment.CENTER, scroll=ft.ScrollMode.AUTO) + Problem = Problems.get(self.Key) + if Problem is not None: + return ft.Column([self._Code(), ft.Text(f"Diagram error: {Problem}", color=Palette.Red, + size=self.FontSize - 2, selectable=True)], spacing=4) + return self._Code() + + def did_mount(self) -> None: + self.Mounted = True + if self.Key not in Pictures and self.Key not in Problems and not self.Fetching and CanDraw(self.TheApp): + self.Fetching = True + self.page.run_thread(self._Fetch) + + def will_unmount(self) -> None: + self.Mounted = False + + def _Fetch(self) -> None: + global ServerCantDraw + try: + Pictures[self.Key] = self.TheApp.Source.Diagram(self.Source, ThemePalette(), self.FontSize) + except SourceError as Error: + if Error.Status == 422: + Problems[self.Key] = str(Error) + elif Error.Status in (404, 503): + ServerCantDraw = True # an older server, or one without Chromium: stay code blocks + finally: + self.Fetching = False + if self.Mounted: + self.content = self._Content() + self.update() + + +def MaybeDiagram(TheApp, BlockText: str, FontSize: int) -> Optional[ft.Control]: + """A DiagramBlock for a ```mermaid code block (LiveMarkdown's blocks), else None.""" + Source = DiagramSource(BlockText) + if Source is None or not CanDraw(TheApp): + return None + return DiagramBlock(TheApp, Source, FontSize) + + +def WithDiagrams(TheApp, Document: str, FontSize: int, MakeMarkdown: Callable[[str], ft.Control]) -> list[ft.Control]: + """The rendered view's controls: the Markdown, split around its diagrams.""" + if not CanDraw(TheApp) or not any(DiagramFence.match(Line) for Line in Document.split("\n")): + return [MakeMarkdown(Document)] + from LiveMarkdown import SplitBlocks # LiveMarkdown imports this module + + Controls: list[ft.Control] = [] + Position = 0 + for Block in SplitBlocks(Document): + Source = DiagramSource(Document[Block.Start:Block.End]) if Block.Kind == "Code" else None + if Source is None: + continue + if Document[Position:Block.Start].strip(): + Controls.append(MakeMarkdown(Document[Position:Block.Start])) + Controls.append(DiagramBlock(TheApp, Source, FontSize)) + Position = Block.End + if Document[Position:].strip(): + Controls.append(MakeMarkdown(Document[Position:])) + return Controls diff --git a/Client/LiveMarkdown.py b/Client/LiveMarkdown.py index e912f61..83a86e9 100644 --- a/Client/LiveMarkdown.py +++ b/Client/LiveMarkdown.py @@ -6,7 +6,8 @@ another block, puts it back rendered. A block is what Markdown itself separates: a heading line, a fenced code block, a horizontal rule, or a run of lines up to a blank line (a paragraph, list, table or quote). The text between blocks (blank lines) is kept -byte for byte, so editing one block never reformats the rest of the file.""" +byte for byte, so editing one block never reformats the rest of the file. +A ```mermaid block shows as its diagram when MakeDiagram gives one (Diagrams.py).""" import re from dataclasses import dataclass @@ -101,7 +102,8 @@ def SplitBlocks(Document: str) -> list[Block]: class LiveMarkdown(ft.Column): def __init__(self, Document: str, FontSize: int, OnChange: Callable[[], None], - OnFocusChange: Callable[[bool], None], OnTapLink: Callable): + OnFocusChange: Callable[[bool], None], OnTapLink: Callable, + MakeDiagram: Optional[Callable[[str, int], Optional[ft.Control]]] = None): super().__init__(scroll=ft.ScrollMode.AUTO, expand=True, spacing=0, horizontal_alignment=ft.CrossAxisAlignment.STRETCH) self.Document = Document @@ -109,6 +111,7 @@ class LiveMarkdown(ft.Column): self.OnChange = OnChange self.OnFocusChange = OnFocusChange self.OnTapLink = OnTapLink + self.MakeDiagram = MakeDiagram self.Blocks: list[Block] = [] self.BlockControls: list[ft.Control] = [] # Rendered blocks by their text, reused when the layout is rebuilt so @@ -217,8 +220,9 @@ class LiveMarkdown(ft.Column): # --- Rendering ------------------------------------------------------- def _Render(self, Text: str) -> ft.Control: + Diagram = self.MakeDiagram(Text, self.FontSize) if self.MakeDiagram else None Rendered = ft.Container( - content=ft.Markdown( + content=Diagram or ft.Markdown( value=Text, extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, md_style_sheet=MarkdownSheet(self.FontSize), diff --git a/Client/Sources.py b/Client/Sources.py index ea3a912..0afcd23 100644 --- a/Client/Sources.py +++ b/Client/Sources.py @@ -114,6 +114,7 @@ class Source: def LocalFile(self, Root: str, RelativePath: str) -> Path: ... def Book(self, Root: str, RelativePath: str) -> dict: ... def Graph(self, Root: str, RelativePath: str) -> dict: ... + def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: ... @property def CanWrite(self) -> bool: @@ -275,6 +276,9 @@ class LocalSource(Source): return BuildLocalGraph(self, Root, RelativePath) + def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: + raise SourceError("Diagrams are drawn by a SaraView server.") + class ServerSource(Source): """A SaraView server, through the same /api endpoints the web frontend @@ -464,6 +468,11 @@ class ServerSource(Source): # A big root takes the server a while to read through. return self._Json("GET", "/graph", params=self._At(Root, RelativePath), timeout=120) + def Diagram(self, Source: str, ThemePalette: dict[str, str], FontSize: int) -> bytes: + """A ```mermaid block as a PNG, drawn by the server (Diagrams.py).""" + Body = {"Source": Source, "Palette": ThemePalette, "FontSize": FontSize} + return self._Request("POST", "/diagram", json=Body).content + def MakeSource(Config: AppConfig, Token: str = "") -> Source: return ServerSource(Config, Token) if Config.Mode == "Server" else LocalSource(Config) diff --git a/Client/Viewer.py b/Client/Viewer.py index 08c7b05..5668e43 100644 --- a/Client/Viewer.py +++ b/Client/Viewer.py @@ -2,7 +2,7 @@ EditorView/ImageView idea, with SaraView's View/Edit/Save toolbar). - Markdown is rendered in View; the other text types are shown as - highlighted code. Edit is a plain monospace field with an explicit Save + highlighted code. On a server, ```mermaid blocks show as diagrams (Diagrams.py). Edit is a plain monospace field with an explicit Save (Ctrl+S). On a server, saving records line blame for you, like the web view. - With Markdown editing set to Live (Settings > General), Markdown opens in LiveMarkdown instead: rendered, and click a block to edit it in place. @@ -20,6 +20,7 @@ import flet as ft from App import App from Core import ChipButton, CodeStyleSheet, HumanSize, MarkdownCodeTheme, MarkdownSheet, Palette +from Diagrams import MaybeDiagram, WithDiagrams from LiveMarkdown import LiveMarkdown from Sources import Entry, SourceError, TextFileTypes @@ -121,7 +122,8 @@ class FileView(ft.Container): self.TextField.value = self.Content if self.IsLive: self.Live = LiveMarkdown(self.Content, self.TheApp.Config.FontSize, self._OnTextChanged, - self.OnFocusChange, self._OnTapLink) + self.OnFocusChange, self._OnTapLink, + MakeDiagram=lambda Text, Size: MaybeDiagram(self.TheApp, Text, Size)) return self.Live return self._RenderedView() if self.FileType == "image": @@ -133,16 +135,21 @@ class FileView(ft.Container): def _RenderedView(self) -> ft.Control: Text = self.TextField.value or "" + Views: Optional[list[ft.Control]] = None if self.FileType == "markdown" and len(Text) <= HighlightCharLimit: - View: ft.Control = ft.Markdown( - value=Text, - selectable=True, - extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, - md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize), - code_theme=MarkdownCodeTheme(), - code_style_sheet=CodeStyleSheet(), - on_tap_link=self._OnTapLink, - ) + def MakeMarkdown(Part: str) -> ft.Control: + return ft.Markdown( + value=Part, + selectable=True, + extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED, + md_style_sheet=MarkdownSheet(self.TheApp.Config.FontSize), + code_theme=MarkdownCodeTheme(), + code_style_sheet=CodeStyleSheet(), + on_tap_link=self._OnTapLink, + ) + + Views = WithDiagrams(self.TheApp, Text, self.TheApp.Config.FontSize, MakeMarkdown) + View: ft.Control = Views[0] elif self.FileType in ("config", "html") and len(Text) <= HighlightCharLimit: Extension = "." + self.Item.Name.rsplit(".", 1)[-1].lower() View = ft.Markdown( @@ -156,8 +163,8 @@ class FileView(ft.Container): else: View = ft.Text(Text, selectable=True, size=self.TheApp.Config.FontSize) if not Text: - View = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True) - return ft.Column([View], scroll=ft.ScrollMode.AUTO, expand=True) + View, Views = ft.Text("This file is empty.", color=Palette.Overlay0, italic=True), None + return ft.Column(Views or [View], scroll=ft.ScrollMode.AUTO, expand=True) def _InfoView(self) -> ft.Control: Icon = ft.Icons.MOVIE_OUTLINED if self.FileType == "video" else ft.Icons.INSERT_DRIVE_FILE_OUTLINED diff --git a/Dockerfile b/Dockerfile index d88f459..6a2cc48 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,8 +9,12 @@ FROM python:3.12-slim WORKDIR /App COPY Backend/requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt +# Headless Chromium for drawing the desktop client's diagrams (Backend/Diagrams.py). +RUN playwright install --with-deps --only-shell chromium COPY Backend/ ./Backend/ COPY --from=FrontendBuild /Frontend/dist ./Frontend/dist +# The same Mermaid the web view uses, for Backend/Diagrams.py. +COPY --from=FrontendBuild /Frontend/node_modules/mermaid/dist/mermaid.min.js ./Backend/Vendor/mermaid.min.js WORKDIR /App/Backend EXPOSE 8420