Skip to content

Commit d791e7b

Browse files
committed
fix: render Mermaid diagrams in PDF export
The PDF export was failing to render Mermaid diagrams because the headless Chromium in the devcontainer cannot fetch external CDN resources. Mermaid code blocks were showing as raw text instead of rendered SVG flowcharts. Solution: Serve mermaid.min.js from a local HTTP server alongside the HTML document. The bundle is located at runtime from webui/node_modules/mermaid/dist/mermaid.min.js. When not found, degrades gracefully (shows code blocks as-is). Changes: - Replace CDN ES module import with local /mermaid.min.js script tag - Add findMermaidJS() to locate the bundle from common paths - Add pagesHaveMermaid() to skip mermaid loading when not needed - Serve mermaid.min.js from the same local HTTP server as the HTML - Rewrite tests to verify SVG rendering via chromedp assertions - Add test for no-mermaid path (data-mermaid-done still set)
1 parent 7cbda76 commit d791e7b

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)