From fad2e039145a9b843a0e7bee2bc545b639190e42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20R=C3=BCger?= Date: Wed, 8 Apr 2026 15:56:35 +0200 Subject: [PATCH] feat: add mermaid-output and mermaid-bundle flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce two new flags for controlling mermaid rendering: --mermaid-output png (default) PNG via RenderAsScaledPng; respects --mermaid-scale --mermaid-output svg SVG via Render(); --mermaid-scale is not applicable and will error if set --mermaid-bundle Embed mermaid source in the SVG element (WithBundle). Only valid with --mermaid-output=svg; errors if used with --mermaid-output=png Validation in CLI: - --mermaid-output must be 'png' or 'svg'; any other value errors - --mermaid-scale + --mermaid-output=svg → error - --mermaid-bundle + --mermaid-output=png → error Checksum correctness: - processMermaidSVG appends a bundle-flag byte to the checksum input so toggling --mermaid-bundle with the same diagram correctly triggers a re-upload (same approach as PNG appending scale bytes) SVG dimension extraction: - extractSVGDimensions uses parseAbsoluteLength which accepts bare numbers and px values, but treats relative units (%, em, rem, vw, vh, …) as unknown and falls back to viewBox — prevents 100% being misinterpreted as 100px for large-diagram alignment logic - Dimensions stored as float strings (SVG may use e.g. "85.4375") Changes: - mermaid/mermaid.go: ProcessMermaidSVG (plain SVG), refactored ProcessMermaidWithBundle sharing processMermaidSVG helper, extractSVGDimensions with parseAbsoluteLength, boolByte helpers - mermaid/mermaid_test.go: TestProcessMermaidSVG, TestProcessMermaidWithBundle (use ParseFloat for dimensions), TestExtractSVGDimensions (7 cases incl. %, em fallback), TestSVGBundleChecksumsDiffer - types/types.go: MermaidOutput string and MermaidBundle bool - mark.go: same fields in Config; propagated to both cfg sites - util/flags.go: --mermaid-output (string) and --mermaid-bundle (bool) with Usage strings noting applicability constraints - util/cli.go: wire both flags; validate output value and incompatible combinations - renderer/fencedcodeblock.go: branch on MermaidOutput + MermaidBundle - README.md: document SVG output options and update flags table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 11 ++- mark.go | 6 ++ mermaid/mermaid.go | 152 ++++++++++++++++++++++++++++++++- mermaid/mermaid_test.go | 165 ++++++++++++++++++++++++++++++++++++ renderer/fencedcodeblock.go | 30 +++++-- types/types.go | 2 + util/cli.go | 12 +++ util/flags.go | 14 ++- 8 files changed, 380 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 9f66ac89..05d34b1a 100644 --- a/README.md +++ b/README.md @@ -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 `` 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"`. @@ -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 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] diff --git a/mark.go b/mark.go index f8bed62d..d4e78005 100644 --- a/mark.go +++ b/mark.go @@ -69,6 +69,8 @@ type Config struct { DropH1 bool StripLinebreaks bool MermaidScale float64 + MermaidOutput string + MermaidBundle bool D2Scale float64 Features []string ImageAlign string @@ -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, @@ -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, diff --git a/mermaid/mermaid.go b/mermaid/mermaid.go index 4c5dd2b9..e223f5e5 100644 --- a/mermaid/mermaid.go +++ b/mermaid/mermaid.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/binary" + "encoding/xml" "math" "strconv" + "strings" "time" mermaid "github.com/dreampuf/mermaid.go" @@ -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) @@ -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 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) { + 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 { + 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 == "" { + 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 +} + +// 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 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 +} diff --git a/mermaid/mermaid_test.go b/mermaid/mermaid_test.go index 836f9b3c..be65e677 100644 --- a/mermaid/mermaid_test.go +++ b/mermaid/mermaid_test.go @@ -1,14 +1,62 @@ package mermaid import ( + "encoding/xml" "fmt" "strconv" + "strings" "testing" "github.com/kovetskiy/mark/v16/attachment" "github.com/stretchr/testify/assert" ) +// findDescContent walks an SVG XML string and returns the text content of the +// first element (regardless of namespace or attributes) and whether one +// was found at all. +func findDescContent(svgStr string) (string, bool) { + dec := xml.NewDecoder(strings.NewReader(svgStr)) + for { + tok, err := dec.Token() + if err != nil { + break + } + if se, ok := tok.(xml.StartElement); ok && se.Name.Local == "desc" { + var sb strings.Builder + for { + inner, err := dec.Token() + if err != nil { + break + } + if cd, ok := inner.(xml.CharData); ok { + sb.Write([]byte(cd)) + } + if ee, ok := inner.(xml.EndElement); ok && ee.Name.Local == "desc" { + break + } + } + return sb.String(), true + } + } + return "", false +} + +// hasSVGRoot returns true when the XML stream contains a root element, +// tolerating an optional processing instruction or other preamble. +func hasSVGRoot(svgStr string) bool { + dec := xml.NewDecoder(strings.NewReader(svgStr)) + for { + tok, err := dec.Token() + if err != nil { + break + } + if se, ok := tok.(xml.StartElement); ok { + return se.Name.Local == "svg" + } + } + return false +} + func TestExtractMermaidImage(t *testing.T) { tests := []struct { name string @@ -52,3 +100,120 @@ func TestExtractMermaidImage(t *testing.T) { }) } } + +func TestProcessMermaidSVG(t *testing.T) { + diagram := []byte("graph TD;\n A-->B;") + got, err := ProcessMermaidSVG("svgtest", diagram) + assert.NoError(t, err) + assert.Equal(t, "svgtest.svg", got.Filename) + assert.Equal(t, "svgtest", got.Name) + assert.Equal(t, "svgtest", got.Replace) + assert.NotEmpty(t, got.FileBytes) + assert.True(t, hasSVGRoot(string(got.FileBytes)), "output should be SVG with an root element") + // Dimensions should be extracted from the SVG (returned as integer pixel strings) + gotWidth, widthErr := strconv.Atoi(got.Width) + assert.NoError(t, widthErr, "Width should be an integer string") + assert.Greater(t, gotWidth, 0, "Width should be positive") + gotHeight, heightErr := strconv.Atoi(got.Height) + assert.NoError(t, heightErr, "Height should be an integer string") + assert.Greater(t, gotHeight, 0, "Height should be positive") +} + +func TestProcessMermaidWithBundle(t *testing.T) { + diagram := []byte("graph TD;\n A-->B;") + got, err := ProcessMermaidWithBundle("bundletest", diagram) + assert.NoError(t, err) + assert.Equal(t, "bundletest.svg", got.Filename) + assert.Equal(t, "bundletest", got.Name) + assert.Equal(t, "bundletest", got.Replace) + assert.NotEmpty(t, got.FileBytes) + svgStr := string(got.FileBytes) + assert.True(t, hasSVGRoot(svgStr), "output should be SVG with an root element") + // WithBundle embeds the diagram source in a element; parse the SVG + // to find it robustly regardless of attributes or namespace on the element. + descContent, hasDesc := findDescContent(svgStr) + assert.True(t, hasDesc, "bundled SVG should contain a element") + assert.Contains(t, descContent, "graph TD;", "bundled SVG should contain original diagram source") + // Dimensions should be extracted from the SVG (returned as integer pixel strings) + gotWidth, widthErr := strconv.Atoi(got.Width) + assert.NoError(t, widthErr, "Width should be an integer string") + assert.Greater(t, gotWidth, 0, "Width should be positive") + gotHeight, heightErr := strconv.Atoi(got.Height) + assert.NoError(t, heightErr, "Height should be an integer string") + assert.Greater(t, gotHeight, 0, "Height should be positive") +} + +func TestExtractSVGDimensions(t *testing.T) { + tests := []struct { + name string + svg string + wantWidth string + wantHeight string + }{ + { + name: "width and height attributes", + svg: ``, + wantWidth: "300", + wantHeight: "200", + }, + { + name: "width and height with px unit", + svg: ``, + wantWidth: "450", + wantHeight: "300", + }, + { + name: "fallback to viewBox", + svg: ``, + wantWidth: "640", + wantHeight: "480", + }, + { + name: "partial fallback: missing height falls back to viewBox", + svg: ``, + wantWidth: "320", + wantHeight: "480", + }, + { + name: "relative percent width falls back to viewBox", + svg: ``, + wantWidth: "800", + wantHeight: "600", + }, + { + name: "em unit treated as unknown, falls back to viewBox", + svg: ``, + wantWidth: "320", + wantHeight: "240", + }, + { + name: "comma-separated viewBox", + svg: ``, + wantWidth: "640", + wantHeight: "480", + }, + { + name: "no dimensions", + svg: ``, + wantWidth: "", + wantHeight: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w, h := extractSVGDimensions(tt.svg) + assert.Equal(t, tt.wantWidth, w) + assert.Equal(t, tt.wantHeight, h) + }) + } +} + +func TestSVGBundleChecksumsDiffer(t *testing.T) { + diagram := []byte("graph TD;\n A-->B;") + plain, err := ProcessMermaidSVG("chk", diagram) + assert.NoError(t, err) + bundled, err := ProcessMermaidWithBundle("chk", diagram) + assert.NoError(t, err) + assert.NotEqual(t, plain.Checksum, bundled.Checksum, + "plain SVG and bundled SVG should have different checksums so toggling --mermaid-bundle triggers a re-upload") +} diff --git a/renderer/fencedcodeblock.go b/renderer/fencedcodeblock.go index e9486d8e..d423ccbb 100644 --- a/renderer/fencedcodeblock.go +++ b/renderer/fencedcodeblock.go @@ -176,16 +176,28 @@ func (r *ConfluenceFencedCodeBlockRenderer) renderFencedCodeBlock(writer util.Bu } } else if lang == "mermaid" && slices.Contains(r.MarkConfig.Features, "mermaid") { - attachment, err := mermaid.ProcessMermaidLocally(title, lval, r.MarkConfig.MermaidScale) + var ( + att attachment.Attachment + err error + ) + if r.MarkConfig.MermaidOutput == "svg" { + if r.MarkConfig.MermaidBundle { + att, err = mermaid.ProcessMermaidWithBundle(title, lval) + } else { + att, err = mermaid.ProcessMermaidSVG(title, lval) + } + } else { + att, err = mermaid.ProcessMermaidLocally(title, lval, r.MarkConfig.MermaidScale) + } if err != nil { line, col := GetLineCol(source, node.Pos()) return ast.WalkStop, fmt.Errorf("line %d, col %d: mermaid rendering failed: %v", line, col, err) } - r.Attachments.Attach(attachment) + r.Attachments.Attach(att) - effectiveAlign := calculateAlign(r.MarkConfig.ImageAlign, attachment.Width) - effectiveLayout := calculateLayout(effectiveAlign, attachment.Width) - displayWidth := calculateDisplayWidth(attachment.Width, effectiveLayout) + effectiveAlign := calculateAlign(r.MarkConfig.ImageAlign, att.Width) + effectiveLayout := calculateLayout(effectiveAlign, att.Width) + displayWidth := calculateDisplayWidth(att.Width, effectiveLayout) err = r.Stdlib.Templates.ExecuteTemplate( writer, @@ -204,13 +216,13 @@ func (r *ConfluenceFencedCodeBlockRenderer) renderFencedCodeBlock(writer util.Bu }{ effectiveAlign, effectiveLayout, - attachment.Width, - attachment.Height, + att.Width, + att.Height, displayWidth, "", - attachment.Name, + att.Name, "", - attachment.Filename, + att.Filename, "", }, ) diff --git a/types/types.go b/types/types.go index de8ba4eb..0133fe64 100644 --- a/types/types.go +++ b/types/types.go @@ -2,6 +2,8 @@ package types type MarkConfig struct { MermaidScale float64 + MermaidOutput string + MermaidBundle bool D2Scale float64 DropFirstH1 bool StripNewlines bool diff --git a/util/cli.go b/util/cli.go index c9008cf8..30a05fd2 100644 --- a/util/cli.go +++ b/util/cli.go @@ -120,6 +120,8 @@ func RunMark(ctx context.Context, cmd *cli.Command) error { DropH1: cmd.Bool("drop-h1"), StripLinebreaks: cmd.Bool("strip-linebreaks"), MermaidScale: cmd.Float("mermaid-scale"), + MermaidOutput: cmd.String("mermaid-output"), + MermaidBundle: cmd.Bool("mermaid-bundle"), D2Scale: cmd.Float("d2-scale"), Features: cmd.StringSlice("features"), ImageAlign: cmd.String("image-align"), @@ -128,6 +130,16 @@ func RunMark(ctx context.Context, cmd *cli.Command) error { Output: os.Stdout, } + if config.MermaidOutput != "png" && config.MermaidOutput != "svg" { + return fmt.Errorf("--mermaid-output must be 'png' or 'svg', got %q", config.MermaidOutput) + } + if cmd.IsSet("mermaid-scale") && config.MermaidOutput == "svg" { + return fmt.Errorf("--mermaid-scale is not applicable when --mermaid-output=svg") + } + if cmd.IsSet("mermaid-bundle") && config.MermaidOutput == "png" { + return fmt.Errorf("--mermaid-bundle is not applicable when --mermaid-output=png") + } + return mark.Run(config) } diff --git a/util/flags.go b/util/flags.go index 89bfed2d..8b4517f7 100644 --- a/util/flags.go +++ b/util/flags.go @@ -178,9 +178,21 @@ var Flags = []cli.Flag{ &cli.FloatFlag{ Name: "mermaid-scale", Value: 1.0, - Usage: "defines the scaling factor for mermaid renderings.", + Usage: "Scaling factor for mermaid PNG renderings. Not applicable when --mermaid-output=svg.", Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_MERMAID_SCALE"), altsrctoml.TOML("mermaid-scale", altsrc.NewStringPtrSourcer(&filename))), }, + &cli.StringFlag{ + Name: "mermaid-output", + Value: "png", + Usage: "Output format for mermaid diagrams: 'png' (default, respects --mermaid-scale) or 'svg' (resolution-independent, --mermaid-scale is not applicable).", + Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_MERMAID_OUTPUT"), altsrctoml.TOML("mermaid-output", altsrc.NewStringPtrSourcer(&filename))), + }, + &cli.BoolFlag{ + Name: "mermaid-bundle", + Value: false, + Usage: "Embed the mermaid diagram source in the SVG element. Only valid with --mermaid-output=svg; errors if used with --mermaid-output=png.", + Sources: cli.NewValueSourceChain(cli.EnvVar("MARK_MERMAID_BUNDLE"), altsrctoml.TOML("mermaid-bundle", altsrc.NewStringPtrSourcer(&filename))), + }, &cli.StringFlag{ Name: "include-path", Value: "",