Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
68 changes: 50 additions & 18 deletions internal/share/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"html"
"io"
"io/fs"
"log/slog"
"net"
"net/http"
Expand All @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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{
Expand Down
28 changes: 28 additions & 0 deletions internal/share/pdf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"net"
"net/http"
"slices"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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)
}
}
9 changes: 9 additions & 0 deletions webui/webpack.config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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',
},
],
}),
],
};
Loading