From 927058513d524df594d8f5391eb90c7d04e6f970 Mon Sep 17 00:00:00 2001 From: Ani Date: Thu, 15 Oct 2026 18:08:58 +0000 Subject: [PATCH] Fix embedded Mermaid export rendering Copy the Mermaid browser bundle into the embedded WebUI assets, use it for PDF and DOCX rendering, and discover macOS browsers installed under the user Applications directory.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>\nCopilot-Session: 08c5eeb6-3c1a-4fe6-acfe-f836fa313c99 --- README.md | 2 +- internal/share/pdf.go | 68 ++++++++++++++++++++++++++++---------- internal/share/pdf_test.go | 28 ++++++++++++++++ webui/webpack.config.js | 9 +++++ 4 files changed, 88 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index bdb7718..7f858b1 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Export is **pluggable** — formats register themselves at startup: | **docx** | Microsoft Word document with rendered Markdown, contents, and embedded images | None | | **pdf** | Multi-page PDF with table of contents | Chrome, Edge, or Chromium on `$PATH` | -DOCX export uses the MIT-licensed [docxgo](https://github.com/mmonterroca/docxgo) library, with no Word installation or external converter required. It preserves headings, emphasis, lists, task lists, quotes, code blocks, tables, and PNG/JPEG/GIF images. External links remain clickable; wikilinks to exported pages become in-document hyperlinks, while wikilinks outside the export are shown as non-clickable dark gray underlined text. Mermaid blocks are rendered as embedded PNGs when Chromium and the local Mermaid bundle are available, with a code-block fallback otherwise. +DOCX export uses the MIT-licensed [docxgo](https://github.com/mmonterroca/docxgo) library, with no Word installation or external converter required. It preserves headings, emphasis, lists, task lists, quotes, code blocks, tables, and PNG/JPEG/GIF images. External links remain clickable; wikilinks to exported pages become in-document hyperlinks, while wikilinks outside the export are shown as non-clickable dark gray underlined text. Mermaid blocks are rendered as embedded PNGs when a supported Chromium-based browser is available; release binaries include the Mermaid browser bundle, with a code-block fallback if rendering fails. PDF export uses [chromedp](https://github.com/chromedp/chromedp) to render markdown → HTML → PDF via a headless browser. It includes a clickable TOC, embedded images, and a print-friendly stylesheet. The PDF sharer only registers if a supported browser is detected — no browser, no PDF option. diff --git a/internal/share/pdf.go b/internal/share/pdf.go index 600ae19..501fd02 100644 --- a/internal/share/pdf.go +++ b/internal/share/pdf.go @@ -7,6 +7,7 @@ import ( "fmt" "html" "io" + "io/fs" "log/slog" "net" "net/http" @@ -17,6 +18,7 @@ import ( "strings" "time" + "github.com/aniongithub/mind-map/webui" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" "github.com/yuin/goldmark" @@ -36,9 +38,11 @@ func init() { } } -func (p *PDFSharer) Name() string { return "pdf" } -func (p *PDFSharer) Description() string { return "Multi-page PDF with table of contents (requires Chrome/Edge)" } -func (p *PDFSharer) ContentType() string { return "application/pdf" } +func (p *PDFSharer) Name() string { return "pdf" } +func (p *PDFSharer) Description() string { + return "Multi-page PDF with table of contents (requires Chrome/Edge)" +} +func (p *PDFSharer) ContentType() string { return "application/pdf" } func (p *PDFSharer) FileExtension() string { return ".pdf" } func (p *PDFSharer) Settings() SharerSettings { @@ -226,9 +230,25 @@ func pagesHaveMermaid(pages []Page) bool { return false } +// embeddedMermaidJS reads the Mermaid bundle copied into the embedded WebUI. +func embeddedMermaidJS() []byte { + dist := webui.DistFS() + if dist == nil { + return nil + } + data, err := fs.ReadFile(dist, "mermaid.min.js") + if err != nil { + return nil + } + return data +} + // findMermaidJS locates and reads the mermaid.min.js bundle. -// It checks common locations relative to the working directory and executable. func findMermaidJS() []byte { + if data := embeddedMermaidJS(); data != nil { + return data + } + candidates := []string{ // Development: relative to project root (cwd) "webui/node_modules/mermaid/dist/mermaid.min.js", @@ -347,29 +367,41 @@ func paperDimensions(size string) (width, height float64) { } } -// findBrowser scans for a Chromium-based browser on the system. -func findBrowser() string { - // Platform-specific paths to try - var candidates []string - - switch runtime.GOOS { +func browserCandidates(goos, homeDir string) []string { + switch goos { case "darwin": - candidates = []string{ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + applications := []string{ + "Google Chrome.app/Contents/MacOS/Google Chrome", + "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "Chromium.app/Contents/MacOS/Chromium", + "Brave Browser.app/Contents/MacOS/Brave Browser", + } + candidates := make([]string, 0, len(applications)*2) + for _, application := range applications { + candidates = append(candidates, filepath.Join("/Applications", application)) + } + if homeDir != "" { + for _, application := range applications { + candidates = append(candidates, filepath.Join(homeDir, "Applications", application)) + } } + return candidates case "windows": - candidates = []string{ + return []string{ `C:\Program Files\Google\Chrome\Application\chrome.exe`, `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, `C:\Program Files\Microsoft\Edge\Application\msedge.exe`, } - default: // linux and others - candidates = []string{} + default: + return nil } +} + +// findBrowser scans for a Chromium-based browser on the system. +func findBrowser() string { + homeDir, _ := os.UserHomeDir() + candidates := browserCandidates(runtime.GOOS, homeDir) // Also check $PATH for common names pathNames := []string{ diff --git a/internal/share/pdf_test.go b/internal/share/pdf_test.go index 77cfcb5..1c35908 100644 --- a/internal/share/pdf_test.go +++ b/internal/share/pdf_test.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "slices" "strings" "testing" "time" @@ -190,3 +191,30 @@ func TestPDFRenderHTMLNoMermaid(t *testing.T) { t.Error("HTML should still contain data-mermaid-done signal for consistency") } } + +func TestEmbeddedMermaidJS(t *testing.T) { + data := embeddedMermaidJS() + if len(data) == 0 { + t.Fatal("embedded WebUI does not contain mermaid.min.js") + } + if !bytes.Contains(data, []byte("mermaid")) { + t.Fatal("embedded mermaid.min.js does not look like a Mermaid bundle") + } +} + +func TestDarwinBrowserCandidatesIncludeUserApplications(t *testing.T) { + got := browserCandidates("darwin", "/Users/tester") + want := []string{ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + "/Users/tester/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Users/tester/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", + "/Users/tester/Applications/Chromium.app/Contents/MacOS/Chromium", + "/Users/tester/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", + } + if !slices.Equal(got, want) { + t.Fatalf("browserCandidates() = %#v, want %#v", got, want) + } +} diff --git a/webui/webpack.config.js b/webui/webpack.config.js index 8fdac07..e4931b3 100644 --- a/webui/webpack.config.js +++ b/webui/webpack.config.js @@ -1,6 +1,7 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const MiniCssExtractPlugin = require('mini-css-extract-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { entry: './src/index.tsx', @@ -39,5 +40,13 @@ module.exports = { favicon: path.resolve(__dirname, 'src/assets/favicon.svg'), }), new MiniCssExtractPlugin({ filename: 'index.css' }), + new CopyWebpackPlugin({ + patterns: [ + { + from: path.resolve(__dirname, 'node_modules/mermaid/dist/mermaid.min.js'), + to: 'mermaid.min.js', + }, + ], + }), ], };