@@ -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 )
0 commit comments