Skip to content
Open
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
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,13 @@ graph TD;
A-->B;
```

By default diagrams are rendered as PNG. Use `--mermaid-output` to select the output format:

* `--mermaid-output=png` (default) — renders as a scaled PNG. Use `--mermaid-scale` to control the scaling factor.
* `--mermaid-output=svg` — renders as a resolution-independent SVG. `--mermaid-scale` is not applicable and will produce an error if set.

When using SVG output, you can optionally embed the original diagram source inside the SVG `<desc>` element by passing `--mermaid-bundle`. This allows the source to be recovered from the attachment later. `--mermaid-bundle` is not applicable with PNG output and will produce an error if set.

### Render D2 Diagram

Optionally you can enable [D2](https://github.com/terrastruct/d2) rendering via `--features="d2"`.
Expand Down Expand Up @@ -877,7 +884,9 @@ GLOBAL OPTIONS:
--parents string A list containing the parents of the document separated by parents-delimiter (default: '/'). These will be prepended to the ones defined in the document itself. [$MARK_PARENTS]
--parents-delimiter string The delimiter used for the parents list (default: "/") [$MARK_PARENTS_DELIMITER]
--content-appearance string default content appearance for pages without a Content-Appearance header. Possible values: full-width, fixed. [$MARK_CONTENT_APPEARANCE]
--mermaid-scale float defines the scaling factor for mermaid renderings. (default: 1) [$MARK_MERMAID_SCALE]
--mermaid-scale float Scaling factor for mermaid PNG renderings. Not applicable when --mermaid-output=svg. (default: 1) [$MARK_MERMAID_SCALE]
--mermaid-output string Output format for mermaid diagrams: 'png' (default, respects --mermaid-scale) or 'svg' (resolution-independent, --mermaid-scale is not applicable). (default: "png") [$MARK_MERMAID_OUTPUT]
--mermaid-bundle Embed the mermaid diagram source in the SVG <desc> element. Only valid with --mermaid-output=svg; errors if used with --mermaid-output=png. [$MARK_MERMAID_BUNDLE]
--include-path string Path for shared includes, used as a fallback if the include doesn't exist in the current directory. [$MARK_INCLUDE_PATH]
--changes-only Avoids re-uploading pages that haven't changed since the last run. [$MARK_CHANGES_ONLY]
--preserve-comments Fetch and preserve inline comments on existing Confluence pages. [$MARK_PRESERVE_COMMENTS]
Expand Down
6 changes: 6 additions & 0 deletions mark.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type Config struct {
DropH1 bool
StripLinebreaks bool
MermaidScale float64
MermaidOutput string
MermaidBundle bool
D2Scale float64
Features []string
ImageAlign string
Expand Down Expand Up @@ -270,6 +272,8 @@ func ProcessFile(file string, api *confluence.API, config Config) (*confluence.P

cfg := types.MarkConfig{
MermaidScale: config.MermaidScale,
MermaidOutput: config.MermaidOutput,
MermaidBundle: config.MermaidBundle,
D2Scale: config.D2Scale,
DropFirstH1: config.DropH1,
StripNewlines: config.StripLinebreaks,
Expand Down Expand Up @@ -352,6 +356,8 @@ func ProcessFile(file string, api *confluence.API, config Config) (*confluence.P

cfg := types.MarkConfig{
MermaidScale: config.MermaidScale,
MermaidOutput: config.MermaidOutput,
MermaidBundle: config.MermaidBundle,
D2Scale: config.D2Scale,
DropFirstH1: config.DropH1,
StripNewlines: config.StripLinebreaks,
Expand Down
152 changes: 151 additions & 1 deletion mermaid/mermaid.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"bytes"
"context"
"encoding/binary"
"encoding/xml"
"math"
"strconv"
"strings"
"time"

mermaid "github.com/dreampuf/mermaid.go"
Expand Down Expand Up @@ -37,7 +39,9 @@ func ProcessMermaidLocally(title string, mermaidDiagram []byte, scale float64) (

binary.LittleEndian.PutUint64(scaleAsBytes, math.Float64bits(scale))

mermaidBytes := append(mermaidDiagram, scaleAsBytes...)
mermaidBytes := make([]byte, 0, len(mermaidDiagram)+len(scaleAsBytes))
mermaidBytes = append(mermaidBytes, mermaidDiagram...)
mermaidBytes = append(mermaidBytes, scaleAsBytes...)

checkSum, err := attachment.GetChecksum(bytes.NewReader(mermaidBytes))
log.Debug().Msgf("Checksum: %q -> %s", title, checkSum)
Expand All @@ -62,3 +66,149 @@ func ProcessMermaidLocally(title string, mermaidDiagram []byte, scale float64) (
Height: strconv.FormatInt(boxModel.Height, 10),
}, nil
}

// ProcessMermaidSVG renders a Mermaid diagram as a plain SVG file.
// The mermaid-scale flag is not applicable to SVG output.
func ProcessMermaidSVG(title string, mermaidDiagram []byte) (attachment.Attachment, error) {
return processMermaidSVG(title, mermaidDiagram, false)
}

// ProcessMermaidWithBundle renders a Mermaid diagram as an SVG file with the
// original diagram source embedded in the SVG <desc> element (via
// mermaid.go's WithBundle option). The resulting attachment is an SVG rather
// than a PNG, so it is resolution-independent and the source can be recovered
// from the attachment. The mermaid-scale flag is not applicable to SVG output.
func ProcessMermaidWithBundle(title string, mermaidDiagram []byte) (attachment.Attachment, error) {
Comment on lines 68 to +81
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing tests for ProcessMermaidLocally, but the newly added SVG paths (ProcessMermaidSVG / ProcessMermaidWithBundle) are untested. Adding tests that assert (1) output filename extension, (2) checksum behavior (e.g., bundle vs non-bundle should not collide if checksum incorporates the option), and (3) that WithBundle actually embeds the source (e.g., contains the diagram) would help prevent regressions.

Copilot uses AI. Check for mistakes.
return processMermaidSVG(title, mermaidDiagram, true)
}

func processMermaidSVG(title string, mermaidDiagram []byte, bundle bool) (attachment.Attachment, error) {
ctx, cancel := context.WithTimeout(context.TODO(), renderTimeout)
defer cancel()

log.Debug().Msgf("Setting up Mermaid renderer (SVG, bundle=%v): %q", bundle, title)
renderer, err := mermaid.NewRenderEngine(ctx, nil)
if err != nil {
return attachment.Attachment{}, err
}
defer renderer.Cancel()

log.Debug().Msgf("Rendering (SVG, bundle=%v): %q", bundle, title)
var svgContent string
if bundle {
svgContent, err = renderer.Render(string(mermaidDiagram), mermaid.WithBundle())
} else {
svgContent, err = renderer.Render(string(mermaidDiagram))
}
if err != nil {
Comment on lines +76 to +103
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New SVG/bundling rendering path (ProcessMermaidWithBundle) is not covered by tests. Since mermaid/mermaid_test.go already validates the PNG path, please add a similar test for the bundle/SVG path (at least asserting .svg filename, non-empty SVG output, and that the SVG contains a with the original diagram source) to prevent regressions.

Copilot uses AI. Check for mistakes.
return attachment.Attachment{}, err
}

checksumInput := make([]byte, 0, len(mermaidDiagram)+1)
checksumInput = append(checksumInput, mermaidDiagram...)
checksumInput = append(checksumInput, boolByte(bundle))
checkSum, err := attachment.GetChecksum(bytes.NewReader(checksumInput))
log.Debug().Msgf("Checksum: %q -> %s", title, checkSum)
if err != nil {
return attachment.Attachment{}, err
}
if title == "" {
Comment on lines +105 to +115
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In processMermaidSVG(), the attachment checksum is computed from mermaidDiagram only. That means toggling bundling on/off (same title + same diagram) will produce different SVG bytes but the same checksum, so attachment.ResolveAttachments() will treat the remote attachment as unchanged and skip uploading the new SVG.

Include the bundle flag (and any other rendering-affecting options) in the checksum input (similar to how ProcessMermaidLocally appends the scale) so changes in bundling correctly trigger an update.

Copilot uses AI. Check for mistakes.
title = checkSum
}

svgWidth, svgHeight := extractSVGDimensions(svgContent)

return attachment.Attachment{
ID: "",
Name: title,
Filename: title + ".svg",
FileBytes: []byte(svgContent),
Checksum: checkSum,
Replace: title,
Width: svgWidth,
Height: svgHeight,
}, nil
Comment on lines +121 to +130
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProcessMermaidWithBundle returns an attachment without Width/Height populated. Downstream rendering (calculateAlign/calculateLayout and the ac:image template) relies on these dimensions for alignment/layout decisions and original-size metadata, so SVG mermaid images may be laid out incorrectly (e.g., wide diagrams not forced to center/wide/full-width). Consider extracting width/height from the rendered SVG (e.g., from width/height attributes or viewBox) and setting Attachment.Width/Height accordingly (or otherwise providing dimensions for SVG output).

Copilot uses AI. Check for mistakes.
}

// boolByte converts a bool to a single byte for use in checksum inputs.
func boolByte(b bool) byte {
if b {
return 1
}
return 0
}

// extractSVGDimensions parses the width and height from an SVG string.
// It reads the width/height attributes of the root <svg> element, accepting
// unitless values and stripping a trailing "px". If either attribute is
// absent, uses another unit suffix, or is otherwise non-numeric, it falls
// back to the viewBox (third and fourth fields).
func extractSVGDimensions(svgContent string) (width, height string) {
type svgAttrs struct {
Width string
Height string
ViewBox string
}
var attrs svgAttrs
dec := xml.NewDecoder(strings.NewReader(svgContent))
for {
tok, err := dec.Token()
if err != nil {
break
}
if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "svg" {
for _, attr := range se.Attr {
switch attr.Name.Local {
case "width":
attrs.Width = attr.Value
case "height":
attrs.Height = attr.Value
case "viewBox":
attrs.ViewBox = attr.Value
}
}
break
}
}

parseAbsoluteLength := func(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}

if strings.HasSuffix(s, "px") {
s = strings.TrimSpace(strings.TrimSuffix(s, "px"))
} else if s[len(s)-1] < '0' || s[len(s)-1] > '9' {
// Treat relative or other non-absolute units (e.g. "%", "em",
// "rem", "vw", "vh") as unknown so callers can fall back to viewBox.
return ""
}

v, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return ""
}

return strconv.Itoa(int(math.Round(v)))
}

w := parseAbsoluteLength(attrs.Width)
h := parseAbsoluteLength(attrs.Height)

// Fall back to viewBox ("minX minY width height").
// The SVG spec allows comma or whitespace separators (e.g. "0,0,640,480").
if (w == "" || h == "") && attrs.ViewBox != "" {
parts := strings.Fields(strings.ReplaceAll(attrs.ViewBox, ",", " "))
if len(parts) == 4 {
if w == "" {
w = parseAbsoluteLength(parts[2])
}
if h == "" {
h = parseAbsoluteLength(parts[3])
}
}
}

return w, h
}
Loading