Skip to content

Commit 2f9c247

Browse files
Copilotjongalloway
andauthored
feat: add Subtitle property to Diagram model with sequence diagram support
Agent-Logs-Url: https://github.com/jongalloway/DiagramForge/sessions/e40e4541-4e5f-4722-9ac9-3fe1f49faa8e Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
1 parent 75ccd9e commit 2f9c247

16 files changed

Lines changed: 416 additions & 7 deletions

doc/frontmatter.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ After the closing fence, the remaining content is parsed normally as Mermaid or
2727
| Key | Accepted forms | Notes |
2828
| --- | --- | --- |
2929
| `theme` | Built-in theme name | Uses `Theme.GetByName(...)`; unknown names throw. |
30+
| `title` | `title` | Sets `Diagram.Title`; inline parser directives take precedence. |
31+
| `subtitle` | `subtitle` | Sets `Diagram.Subtitle`; rendered below the title in a smaller, muted font. Inline parser directives take precedence. |
3032
| `palette` | Single-line JSON array of hex strings | Example: `["#FF0000", "#00FF00"]` |
3133
| `borderStyle` | `borderStyle`, `border-style` | Values: `solid`, `subtle`, `rainbow` |
3234
| `fillStyle` | `fillStyle`, `fill-style` | Values: `flat`, `subtle`, `diagonal-strong` |

doc/rendering-pipeline.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,8 @@ in this order:
120120
2. **`<defs>`** — arrow markers for edge arrowheads.
121121
3. **Background `<rect>`** — full-canvas fill with rounded corners.
122122
4. **Title `<text>`** — centered at the top, if `Diagram.Title` is set.
123-
5. **Groups**`<rect>` + optional `<text>` label for each group. Rendered
123+
5. **Subtitle `<text>`** — centered below the title in a smaller, muted font, if `Diagram.Subtitle` is set.
124+
6. **Groups**`<rect>` + optional `<text>` label for each group. Rendered
124125
first so they appear behind nodes.
125126
6. **Edges** — cubic Bézier `<path>` elements with anchor points on node edges.
126127
Rendered behind nodes. Optional edge labels at the midpoint.

doc/semantic-model.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ All model types live in `DiagramForge.Models`.
2020
```
2121
Diagram
2222
├── Title string?
23+
├── Subtitle string?
2324
├── SourceSyntax string?
2425
├── DiagramType string?
2526
├── LayoutHints LayoutHints
@@ -60,7 +61,8 @@ The root container. One `Diagram` instance represents one rendered image.
6061

6162
| Property | Type | Set by | Description |
6263
|----------|------|--------|-------------|
63-
| `Title` | `string?` | Parser | Optional human-readable title displayed above the diagram. |
64+
| `Title` | `string?` | Parser / Frontmatter | Optional human-readable title displayed above the diagram. |
65+
| `Subtitle` | `string?` | Parser / Frontmatter | Optional subtitle displayed below the title in a smaller, muted font. |
6466
| `SourceSyntax` | `string?` | Parser | Identifies the parser that produced this model (e.g., `"mermaid"`, `"conceptual"`). |
6567
| `DiagramType` | `string?` | Parser | Specific diagram variant (e.g., `"flowchart"`, `"venn"`, `"mindmap"`). |
6668
| `Nodes` | `Dictionary<string, Node>` | Parser | All nodes, keyed by unique ID. |

src/DiagramForge/Abstractions/IDiagramSemanticModelBuilder.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ public interface IDiagramSemanticModelBuilder
1212
/// <summary>Sets the optional diagram title.</summary>
1313
IDiagramSemanticModelBuilder WithTitle(string title);
1414

15+
/// <summary>Sets the optional diagram subtitle displayed below the title.</summary>
16+
IDiagramSemanticModelBuilder WithSubtitle(string subtitle);
17+
1518
/// <summary>Specifies the source syntax identifier (e.g., "mermaid").</summary>
1619
IDiagramSemanticModelBuilder WithSourceSyntax(string syntaxId);
1720

src/DiagramForge/DiagramRenderer.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,14 @@ public string Render(string diagramText, Theme? theme, string? paletteJson, bool
198198
diagram.LayoutHints.EdgeRouting = frontmatter.EdgeRouting.Value;
199199
}
200200

201+
// Frontmatter title/subtitle override the parser-supplied values only when the parser
202+
// did not already set them (inline directives take precedence over frontmatter).
203+
if (frontmatter.Title is not null && string.IsNullOrWhiteSpace(diagram.Title))
204+
diagram.Title = frontmatter.Title;
205+
206+
if (frontmatter.Subtitle is not null && string.IsNullOrWhiteSpace(diagram.Subtitle))
207+
diagram.Subtitle = frontmatter.Subtitle;
208+
201209
ResolveIcons(diagram);
202210
_layoutEngine.Layout(diagram, effectiveTheme);
203211
return _svgRenderer.Render(diagram, effectiveTheme);
@@ -407,6 +415,8 @@ private static FrontmatterOptions ParseFrontmatter(string raw)
407415
string? parsedShadowStyle = null;
408416
bool? parsedTransparentBackground = null;
409417
EdgeRouting? parsedEdgeRouting = null;
418+
string? parsedTitle = null;
419+
string? parsedSubtitle = null;
410420

411421
foreach (string rawLine in frontmatter.Split('\n'))
412422
{
@@ -468,9 +478,17 @@ private static FrontmatterOptions ParseFrontmatter(string raw)
468478
{
469479
parsedEdgeRouting = ParseEdgeRouting(Unquote(line["edge-routing:".Length..].Trim()), raw);
470480
}
481+
else if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
482+
{
483+
parsedTitle = Unquote(line["title:".Length..].Trim());
484+
}
485+
else if (line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase))
486+
{
487+
parsedSubtitle = Unquote(line["subtitle:".Length..].Trim());
488+
}
471489
}
472490

473-
return new FrontmatterOptions(diagramText, parsedTheme, parsedPaletteJson, parsedBorderStyle, parsedFillStyle, parsedShadowStyle, parsedTransparentBackground, parsedEdgeRouting);
491+
return new FrontmatterOptions(diagramText, parsedTheme, parsedPaletteJson, parsedBorderStyle, parsedFillStyle, parsedShadowStyle, parsedTransparentBackground, parsedEdgeRouting, parsedTitle, parsedSubtitle);
474492
}
475493

476494
private static void ApplyBorderStyle(Theme theme, string borderStyle)
@@ -607,7 +625,7 @@ private static bool ParseBoolean(string rawValue, string raw, string fieldName)
607625
};
608626
}
609627

610-
private sealed record FrontmatterOptions(string DiagramText, Theme? Theme, string? PaletteJson, string? BorderStyle, string? FillStyle, string? ShadowStyle, bool? TransparentBackground, EdgeRouting? EdgeRouting = null);
628+
private sealed record FrontmatterOptions(string DiagramText, Theme? Theme, string? PaletteJson, string? BorderStyle, string? FillStyle, string? ShadowStyle, bool? TransparentBackground, EdgeRouting? EdgeRouting = null, string? Title = null, string? Subtitle = null);
611629
}
612630

613631
[System.Text.Json.Serialization.JsonSerializable(typeof(List<string>))]

src/DiagramForge/DiagramSemanticModelBuilder.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ public IDiagramSemanticModelBuilder WithTitle(string title)
1616
return this;
1717
}
1818

19+
public IDiagramSemanticModelBuilder WithSubtitle(string subtitle)
20+
{
21+
_diagram.Subtitle = subtitle;
22+
return this;
23+
}
24+
1925
public IDiagramSemanticModelBuilder WithSourceSyntax(string syntaxId)
2026
{
2127
_diagram.SourceSyntax = syntaxId;

src/DiagramForge/Layout/DefaultLayoutEngine.Sequence.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,25 @@ private static void LayoutSequenceDiagram(
2323
.ThenBy(n => n.Id, StringComparer.Ordinal)
2424
.ToList();
2525

26+
// Reserve vertical space for the title and/or subtitle so they don't overlap participants.
27+
bool hasTitle = !string.IsNullOrWhiteSpace(diagram.Title);
28+
bool hasSubtitle = !string.IsNullOrWhiteSpace(diagram.Subtitle);
29+
double headingOffset = 0;
30+
if (hasTitle || hasSubtitle)
31+
headingOffset += theme.TitleFontSize + 8;
32+
if (hasTitle && hasSubtitle)
33+
headingOffset += theme.FontSize + 4;
34+
2635
double runX = pad;
2736
double participantStripHeight = ordered.Max(node => node.Height);
2837
foreach (var node in ordered)
2938
{
3039
node.X = runX;
31-
node.Y = pad + (participantStripHeight - node.Height);
40+
node.Y = pad + headingOffset + (participantStripHeight - node.Height);
3241
runX += node.Width + hGap;
3342
}
3443

35-
double firstMessageY = pad + participantStripHeight + vGap / 2;
44+
double firstMessageY = pad + headingOffset + participantStripHeight + vGap / 2;
3645
double messageRowHeight = vGap;
3746

3847
foreach (var edge in diagram.Edges)

src/DiagramForge/Models/Diagram.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ public class Diagram
99
/// <summary>Optional human-readable title of the diagram.</summary>
1010
public string? Title { get; set; }
1111

12+
/// <summary>Optional subtitle displayed below the title in a smaller, muted font.</summary>
13+
public string? Subtitle { get; set; }
14+
1215
/// <summary>Identifies the source syntax that produced this model (e.g., "mermaid", "conceptual").</summary>
1316
public string? SourceSyntax { get; set; }
1417

src/DiagramForge/Parsers/Mermaid/MermaidSequenceParser.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,24 @@ Node GetOrCreateParticipant(string id)
4444
{
4545
var line = document.Lines[i];
4646

47+
// title: <text> — diagram title directive
48+
if (line.StartsWith("title:", StringComparison.OrdinalIgnoreCase))
49+
{
50+
var titleText = line["title:".Length..].Trim().Trim('"');
51+
if (!string.IsNullOrEmpty(titleText))
52+
builder.WithTitle(titleText);
53+
continue;
54+
}
55+
56+
// subtitle: <text> — diagram subtitle directive
57+
if (line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase))
58+
{
59+
var subtitleText = line["subtitle:".Length..].Trim().Trim('"');
60+
if (!string.IsNullOrEmpty(subtitleText))
61+
builder.WithSubtitle(subtitleText);
62+
continue;
63+
}
64+
4765
// participant ID
4866
// participant ID as Alias
4967
if (line.StartsWith("participant ", StringComparison.OrdinalIgnoreCase))

src/DiagramForge/Rendering/SvgRenderer.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public string Render(Diagram diagram, Theme theme)
3939
// Title — diagram-type layouts may override font size / position via metadata.
4040
// Prefer the namespaced "diagram:titleFontSize" / "diagram:titleY" keys; fall back
4141
// to the legacy un-namespaced keys for backward compatibility.
42+
double renderedTitleFontSize = 0;
43+
double renderedTitleY = 0;
4244
if (!string.IsNullOrWhiteSpace(diagram.Title))
4345
{
4446
double titleFontSize;
@@ -55,6 +57,18 @@ public string Render(Diagram diagram, Theme theme)
5557
else
5658
titleY = theme.DiagramPadding - 4;
5759
sb.AppendLine($""" <text x="{SvgRenderSupport.F(width / 2)}" y="{SvgRenderSupport.F(titleY)}" text-anchor="middle" font-family="{SvgRenderSupport.Escape(theme.FontFamily)}" font-size="{SvgRenderSupport.F(titleFontSize)}" font-weight="bold" fill="{SvgRenderSupport.Escape(theme.TitleTextColor)}">{SvgRenderSupport.Escape(diagram.Title)}</text>""");
60+
renderedTitleFontSize = titleFontSize;
61+
renderedTitleY = titleY;
62+
}
63+
64+
// Subtitle — rendered below the title in a smaller, muted font.
65+
if (!string.IsNullOrWhiteSpace(diagram.Subtitle))
66+
{
67+
double subtitleFontSize = theme.FontSize;
68+
double subtitleY = !string.IsNullOrWhiteSpace(diagram.Title)
69+
? renderedTitleY + renderedTitleFontSize + 4
70+
: theme.DiagramPadding - 4;
71+
sb.AppendLine($""" <text x="{SvgRenderSupport.F(width / 2)}" y="{SvgRenderSupport.F(subtitleY)}" text-anchor="middle" font-family="{SvgRenderSupport.Escape(theme.FontFamily)}" font-size="{SvgRenderSupport.F(subtitleFontSize)}" fill="{SvgRenderSupport.Escape(theme.SubtleTextColor)}">{SvgRenderSupport.Escape(diagram.Subtitle)}</text>""");
5872
}
5973

6074
// Groups (render behind nodes). Parents render first so nested child groups
@@ -207,7 +221,8 @@ private static double ComputeHeight(Diagram diagram, Theme theme)
207221
if (TryGetCycleArcBounds(diagram, out _, out _, out _, out var cycleMaxY))
208222
maxY = Math.Max(maxY, cycleMaxY);
209223
double titleOffset = !string.IsNullOrWhiteSpace(diagram.Title) ? theme.TitleFontSize + 8 : 0;
210-
return maxY + theme.DiagramPadding + titleOffset;
224+
double subtitleOffset = !string.IsNullOrWhiteSpace(diagram.Subtitle) ? theme.FontSize + 4 : 0;
225+
return maxY + theme.DiagramPadding + titleOffset + subtitleOffset;
211226
}
212227

213228
private static bool TryGetCycleArcBounds(Diagram diagram, out double minX, out double maxX, out double minY, out double maxY)

0 commit comments

Comments
 (0)