Skip to content

Commit 26dabe8

Browse files
authored
Merge pull request #56 from aniongithub/fix-pdf-export-mermaid-diagrams
fix: render Mermaid diagrams in PDF export
2 parents 7cbda76 + d791e7b commit 26dabe8

2 files changed

Lines changed: 298 additions & 13 deletions

File tree

internal/share/pdf.go

Lines changed: 121 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import (
88
"html"
99
"io"
1010
"log/slog"
11+
"net"
12+
"net/http"
13+
"os"
1114
"os/exec"
15+
"path/filepath"
1216
"runtime"
1317
"strings"
1418
"time"
@@ -77,11 +81,21 @@ func (p *PDFSharer) Export(ctx context.Context, w io.Writer, req ExportRequest)
7781
includeAssets := SettingBool(req.Config, "include_assets", true)
7882
pageSize := SettingString(req.Config, "page_size", "A4")
7983

84+
// Detect mermaid blocks in any page and load mermaid JS if needed
85+
hasMermaid := pagesHaveMermaid(req.Pages)
86+
var mermaidJS []byte
87+
if hasMermaid {
88+
mermaidJS = findMermaidJS()
89+
if mermaidJS == nil {
90+
hasMermaid = false // degrade gracefully — render code blocks as-is
91+
}
92+
}
93+
8094
// Render pages to HTML
81-
htmlDoc := renderHTMLDocument(req.Pages, req.Assets, ctx, includeTOC, includeAssets)
95+
htmlDoc := renderHTMLDocument(req.Pages, req.Assets, ctx, includeTOC, includeAssets, hasMermaid)
8296

8397
// Convert to PDF via headless browser
84-
pdfBytes, err := htmlToPDF(ctx, browserPath, htmlDoc, pageSize)
98+
pdfBytes, err := htmlToPDF(ctx, browserPath, htmlDoc, pageSize, mermaidJS)
8599
if err != nil {
86100
return fmt.Errorf("PDF generation failed: %w", err)
87101
}
@@ -91,13 +105,43 @@ func (p *PDFSharer) Export(ctx context.Context, w io.Writer, req ExportRequest)
91105
}
92106

93107
// renderHTMLDocument builds a complete HTML document from the exported pages.
94-
func renderHTMLDocument(pages []Page, assets AssetReader, ctx context.Context, includeTOC, includeAssets bool) string {
108+
// If hasMermaid is true, includes a script tag that loads mermaid from /mermaid.min.js
109+
// (served by the local HTTP server in htmlToPDF).
110+
func renderHTMLDocument(pages []Page, assets AssetReader, ctx context.Context, includeTOC, includeAssets, hasMermaid bool) string {
95111
var buf strings.Builder
96112

97113
buf.WriteString(`<!DOCTYPE html><html><head><meta charset="utf-8">`)
98114
buf.WriteString(`<style>`)
99115
buf.WriteString(pdfCSS)
100-
buf.WriteString(`</style></head><body>`)
116+
buf.WriteString(`</style>`)
117+
if hasMermaid {
118+
// Load mermaid from local server, then render code blocks to SVG
119+
buf.WriteString(`<script src="/mermaid.min.js"></script>`)
120+
buf.WriteString(`<script>`)
121+
buf.WriteString(`mermaid.initialize({ startOnLoad: false, theme: 'default' });`)
122+
buf.WriteString(`window.addEventListener('DOMContentLoaded', async function() {`)
123+
buf.WriteString(` var nodes = document.querySelectorAll('code.language-mermaid');`)
124+
buf.WriteString(` for (var i = 0; i < nodes.length; i++) {`)
125+
buf.WriteString(` var pre = nodes[i].parentElement;`)
126+
buf.WriteString(` var container = document.createElement('div');`)
127+
buf.WriteString(` container.className = 'mermaid';`)
128+
buf.WriteString(` container.textContent = nodes[i].textContent;`)
129+
buf.WriteString(` pre.replaceWith(container);`)
130+
buf.WriteString(` }`)
131+
buf.WriteString(` var mermaidNodes = document.querySelectorAll('.mermaid');`)
132+
buf.WriteString(` if (mermaidNodes.length > 0) {`)
133+
buf.WriteString(` await mermaid.run({ nodes: mermaidNodes });`)
134+
buf.WriteString(` }`)
135+
buf.WriteString(` document.body.setAttribute('data-mermaid-done', 'true');`)
136+
buf.WriteString(`});`)
137+
buf.WriteString(`</script>`)
138+
}
139+
buf.WriteString(`</head><body>`)
140+
141+
// If no mermaid, immediately mark done for the wait loop
142+
if !hasMermaid {
143+
buf.WriteString(`<script>document.body.setAttribute('data-mermaid-done','true');</script>`)
144+
}
101145

102146
// Table of contents
103147
if includeTOC && len(pages) > 1 {
@@ -172,8 +216,74 @@ func embedImages(body string, imageRefs []string, assets AssetReader, ctx contex
172216
return body
173217
}
174218

219+
// pagesHaveMermaid returns true if any page body contains a mermaid fenced code block.
220+
func pagesHaveMermaid(pages []Page) bool {
221+
for _, p := range pages {
222+
if strings.Contains(p.Body, "```mermaid") {
223+
return true
224+
}
225+
}
226+
return false
227+
}
228+
229+
// findMermaidJS locates and reads the mermaid.min.js bundle.
230+
// It checks common locations relative to the working directory and executable.
231+
func findMermaidJS() []byte {
232+
candidates := []string{
233+
// Development: relative to project root (cwd)
234+
"webui/node_modules/mermaid/dist/mermaid.min.js",
235+
// Two levels up from internal/share/ (tests run from package dir)
236+
"../../webui/node_modules/mermaid/dist/mermaid.min.js",
237+
}
238+
239+
for _, candidate := range candidates {
240+
data, err := os.ReadFile(candidate)
241+
if err == nil {
242+
return data
243+
}
244+
}
245+
246+
// Try relative to the executable (production: mermaid.min.js next to binary)
247+
if exePath, err := os.Executable(); err == nil {
248+
dir := filepath.Dir(exePath)
249+
for _, rel := range []string{
250+
filepath.Join(dir, "mermaid.min.js"),
251+
filepath.Join(dir, "webui", "node_modules", "mermaid", "dist", "mermaid.min.js"),
252+
} {
253+
if data, err := os.ReadFile(rel); err == nil {
254+
return data
255+
}
256+
}
257+
}
258+
259+
return nil
260+
}
261+
175262
// htmlToPDF uses chromedp to render HTML to PDF.
176-
func htmlToPDF(ctx context.Context, browserPath, htmlContent, pageSize string) ([]byte, error) {
263+
func htmlToPDF(ctx context.Context, browserPath, htmlContent, pageSize string, mermaidJS []byte) ([]byte, error) {
264+
// Start a local HTTP server to serve the HTML content.
265+
// This gives the page a proper origin so scripts and local resources work.
266+
mux := http.NewServeMux()
267+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
268+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
269+
w.Write([]byte(htmlContent))
270+
})
271+
if mermaidJS != nil {
272+
mux.HandleFunc("/mermaid.min.js", func(w http.ResponseWriter, r *http.Request) {
273+
w.Header().Set("Content-Type", "application/javascript")
274+
w.Write(mermaidJS)
275+
})
276+
}
277+
listener, err := net.Listen("tcp", "127.0.0.1:0")
278+
if err != nil {
279+
return nil, fmt.Errorf("failed to start local server: %w", err)
280+
}
281+
srv := &http.Server{Handler: mux}
282+
go srv.Serve(listener)
283+
defer srv.Close()
284+
defer listener.Close()
285+
pageURL := fmt.Sprintf("http://127.0.0.1:%d/", listener.Addr().(*net.TCPAddr).Port)
286+
177287
// Create a context with the browser path
178288
opts := append(chromedp.DefaultExecAllocatorOptions[:],
179289
chromedp.ExecPath(browserPath),
@@ -193,14 +303,13 @@ func htmlToPDF(ctx context.Context, browserPath, htmlContent, pageSize string) (
193303

194304
// Navigate to the HTML content and print to PDF
195305
var pdfBuf []byte
196-
err := chromedp.Run(taskCtx,
197-
chromedp.Navigate("about:blank"),
306+
err = chromedp.Run(taskCtx,
307+
chromedp.Navigate(pageURL),
308+
// Wait for Mermaid diagrams to finish rendering.
309+
// The mermaid init script sets data-mermaid-done="true" on <body>
310+
// after all diagrams render (or on error). Poll until it appears.
198311
chromedp.ActionFunc(func(ctx context.Context) error {
199-
frameTree, err := page.GetFrameTree().Do(ctx)
200-
if err != nil {
201-
return err
202-
}
203-
return page.SetDocumentContent(frameTree.Frame.ID, htmlContent).Do(ctx)
312+
return chromedp.Poll(`document.body && document.body.getAttribute('data-mermaid-done') === 'true'`, nil, chromedp.WithPollingInterval(100*time.Millisecond)).Do(ctx)
204313
}),
205314
chromedp.ActionFunc(func(ctx context.Context) error {
206315
paperWidth, paperHeight := paperDimensions(pageSize)

internal/share/pdf_test.go

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
package share
22

3-
import "testing"
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"net"
8+
"net/http"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/chromedp/chromedp"
14+
)
415

516
func TestPDFRegistered(t *testing.T) {
617
if findBrowser() == "" {
@@ -14,3 +25,168 @@ func TestPDFRegistered(t *testing.T) {
1425
t.Errorf("expected name 'pdf', got %q", s.Name())
1526
}
1627
}
28+
29+
func TestPDFMermaidRendering(t *testing.T) {
30+
if findBrowser() == "" {
31+
t.Skip("no Chromium-based browser on $PATH — PDF sharer won't register")
32+
}
33+
if findMermaidJS() == nil {
34+
t.Skip("mermaid.min.js not found — cannot test mermaid rendering")
35+
}
36+
37+
sharer := &PDFSharer{}
38+
39+
// Page with a mermaid diagram
40+
mermaidPage := Page{
41+
Path: "test/mermaid-page",
42+
Title: "Mermaid Test",
43+
Body: `# Test Page
44+
45+
Here is a diagram:
46+
47+
` + "```mermaid\ngraph TD\n A[Start] --> B[Process]\n B --> C[End]\n```" + `
48+
49+
And some text after.
50+
`,
51+
}
52+
53+
ctx := context.Background()
54+
55+
// Export the mermaid page to PDF
56+
var mermaidBuf bytes.Buffer
57+
err := sharer.Export(ctx, &mermaidBuf, ExportRequest{
58+
Pages: []Page{mermaidPage},
59+
Assets: nil,
60+
Config: ShareConfig{Page: "test/mermaid-page", Depth: 0},
61+
})
62+
if err != nil {
63+
t.Fatalf("PDF export with mermaid failed: %v", err)
64+
}
65+
66+
// Verify it's a valid PDF
67+
if !bytes.HasPrefix(mermaidBuf.Bytes(), []byte("%PDF")) {
68+
t.Fatal("mermaid PDF output doesn't start with %PDF header")
69+
}
70+
t.Logf("mermaid PDF size: %d bytes", mermaidBuf.Len())
71+
72+
// Verify mermaid was rendered by checking the HTML intermediate output.
73+
// Use the htmlToPDF local server approach to get the rendered HTML.
74+
browserPath := findBrowser()
75+
mermaidJS := findMermaidJS()
76+
htmlDoc := renderHTMLDocument([]Page{mermaidPage}, nil, ctx, false, false, true)
77+
78+
// Render in browser and capture the resulting HTML to verify SVG
79+
mux := http.NewServeMux()
80+
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
81+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
82+
w.Write([]byte(htmlDoc))
83+
})
84+
mux.HandleFunc("/mermaid.min.js", func(w http.ResponseWriter, r *http.Request) {
85+
w.Header().Set("Content-Type", "application/javascript")
86+
w.Write(mermaidJS)
87+
})
88+
listener, listenErr := net.Listen("tcp", "127.0.0.1:0")
89+
if listenErr != nil {
90+
t.Fatalf("failed to listen: %v", listenErr)
91+
}
92+
srv := &http.Server{Handler: mux}
93+
go srv.Serve(listener)
94+
defer srv.Close()
95+
defer listener.Close()
96+
pageURL := fmt.Sprintf("http://127.0.0.1:%d/", listener.Addr().(*net.TCPAddr).Port)
97+
98+
opts := append(chromedp.DefaultExecAllocatorOptions[:],
99+
chromedp.ExecPath(browserPath),
100+
chromedp.Flag("no-sandbox", true),
101+
chromedp.Flag("disable-gpu", true),
102+
)
103+
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
104+
defer allocCancel()
105+
taskCtx, taskCancel := chromedp.NewContext(allocCtx)
106+
defer taskCancel()
107+
taskCtx, timeoutCancel := context.WithTimeout(taskCtx, 30*time.Second)
108+
defer timeoutCancel()
109+
110+
var bodyHTML string
111+
err = chromedp.Run(taskCtx,
112+
chromedp.Navigate(pageURL),
113+
chromedp.ActionFunc(func(ctx context.Context) error {
114+
return chromedp.Poll(`document.body && document.body.getAttribute('data-mermaid-done') === 'true'`, nil, chromedp.WithPollingInterval(100*time.Millisecond)).Do(ctx)
115+
}),
116+
chromedp.OuterHTML("body", &bodyHTML),
117+
)
118+
if err != nil {
119+
t.Fatalf("chromedp failed: %v", err)
120+
}
121+
122+
// The rendered HTML should contain SVG elements from mermaid
123+
if !strings.Contains(bodyHTML, "<svg") {
124+
t.Error("rendered HTML does not contain SVG — mermaid diagram was not rendered")
125+
}
126+
if !strings.Contains(bodyHTML, "flowchart") || !strings.Contains(bodyHTML, "mermaid") {
127+
t.Error("rendered HTML does not contain expected mermaid/flowchart class")
128+
}
129+
// The original code block should be GONE (replaced by mermaid div)
130+
if strings.Contains(bodyHTML, `class="language-mermaid"`) {
131+
t.Error("original code.language-mermaid element still present — mermaid didn't replace it")
132+
}
133+
t.Logf("rendered body HTML length: %d bytes, contains SVG: true", len(bodyHTML))
134+
}
135+
136+
func TestPDFRenderHTMLContainsMermaidScript(t *testing.T) {
137+
// Verify that renderHTMLDocument includes the Mermaid initialization
138+
// script when pages contain mermaid code blocks.
139+
pages := []Page{
140+
{
141+
Path: "test/page",
142+
Title: "Test",
143+
Body: "# Hello\n\n```mermaid\ngraph TD\n A --> B\n```\n",
144+
},
145+
}
146+
147+
html := renderHTMLDocument(pages, nil, context.Background(), false, false, true)
148+
149+
// Should contain mermaid.min.js script reference (local server)
150+
if !strings.Contains(html, "/mermaid.min.js") {
151+
t.Error("HTML does not contain /mermaid.min.js script reference")
152+
}
153+
154+
// Should contain the data-mermaid-done signal
155+
if !strings.Contains(html, "data-mermaid-done") {
156+
t.Error("HTML does not contain data-mermaid-done signal")
157+
}
158+
159+
// Goldmark should have rendered the mermaid block as a code element
160+
if !strings.Contains(html, `class="language-mermaid"`) {
161+
t.Error("HTML does not contain language-mermaid code block (goldmark output)")
162+
}
163+
164+
// Should contain the graph definition text
165+
if !strings.Contains(html, "A --&gt; B") || !strings.Contains(html, "graph TD") {
166+
// goldmark may or may not HTML-escape inside code blocks
167+
if !strings.Contains(html, "A --> B") && !strings.Contains(html, "A --&gt; B") {
168+
t.Error("HTML does not contain the mermaid graph definition")
169+
}
170+
}
171+
}
172+
173+
func TestPDFRenderHTMLNoMermaid(t *testing.T) {
174+
// When hasMermaid is false, no mermaid script should be included
175+
pages := []Page{
176+
{
177+
Path: "test/page",
178+
Title: "Test",
179+
Body: "# Hello\n\nJust text.\n",
180+
},
181+
}
182+
183+
html := renderHTMLDocument(pages, nil, context.Background(), false, false, false)
184+
185+
if strings.Contains(html, "/mermaid.min.js") {
186+
t.Error("HTML should not contain mermaid script when hasMermaid is false")
187+
}
188+
// Should still have the done signal for the wait loop
189+
if !strings.Contains(html, "data-mermaid-done") {
190+
t.Error("HTML should still contain data-mermaid-done signal for consistency")
191+
}
192+
}

0 commit comments

Comments
 (0)