Skip to content
Merged
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
19 changes: 19 additions & 0 deletions csharp-version/src/MarkdownToDocx.Core/Models/HeadingStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,23 @@ public sealed record HeadingStyle
/// H2 directly follows H1.
/// </summary>
public int? SuppressPageBreakIfPrevHeadingLevel { get; init; }

/// <summary>
/// Border line style applied to all border positions on this heading.
/// Supported values: "single" (default), "double", "thick", "dotted", "dashed",
/// "dotDash", "wave", "triple".
/// </summary>
public string BorderStyle { get; init; } = "single";

/// <summary>
/// Unicode character (or short string) prepended to the heading text as a decorative prefix.
/// For example "◆", "★", "▶", or "01". Null or empty means no prefix.
/// </summary>
public string? IconPrefix { get; init; }

/// <summary>
/// Color of the icon prefix in hexadecimal format (e.g., "E91E8C").
/// When null, the heading's main Color is used.
/// </summary>
public string? IconPrefixColor { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,29 +129,46 @@
return props;
}

/// <summary>
/// Converts a border style name to the corresponding BorderValues enum value.
/// </summary>
private static BorderValues ParseBorderStyle(string borderStyle) => borderStyle.ToLowerInvariant() switch
{
"double" => BorderValues.Double,
"thick" => BorderValues.Thick,
"dotted" => BorderValues.Dotted,
"dashed" => BorderValues.Dashed,
"dotdash" => BorderValues.DotDash,
"wave" => BorderValues.Wave,
"triple" => BorderValues.Triple,
_ => BorderValues.Single
};

/// <summary>
/// Creates a ParagraphBorders element from a comma-separated position string
/// </summary>
private static ParagraphBorders CreateBordersFromPositions(
string borderPosition,
string borderColor,
uint borderSize,
uint borderSpace)
uint borderSpace,
string borderStyle = "single")
{
var positions = borderPosition
.ToLowerInvariant()
.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);

var borderVal = ParseBorderStyle(borderStyle);
var paragraphBorders = new ParagraphBorders();

foreach (var pos in positions)
{
OpenXmlElement border = pos switch
{
"left" => new LeftBorder { Val = BorderValues.Single, Color = borderColor, Size = borderSize, Space = borderSpace },
"right" => new RightBorder { Val = BorderValues.Single, Color = borderColor, Size = borderSize, Space = borderSpace },
"top" => new TopBorder { Val = BorderValues.Single, Color = borderColor, Size = borderSize, Space = borderSpace },
_ => new BottomBorder { Val = BorderValues.Single, Color = borderColor, Size = borderSize, Space = borderSpace }
"left" => new LeftBorder { Val = borderVal, Color = borderColor, Size = borderSize, Space = borderSpace },
"right" => new RightBorder { Val = borderVal, Color = borderColor, Size = borderSize, Space = borderSpace },
"top" => new TopBorder { Val = borderVal, Color = borderColor, Size = borderSize, Space = borderSpace },
_ => new BottomBorder { Val = borderVal, Color = borderColor, Size = borderSize, Space = borderSpace }
};
paragraphBorders.AppendChild(border);
}
Expand Down Expand Up @@ -516,6 +533,15 @@
var paragraphProps = CreateHeadingParagraphProperties(level, style);
paragraph.AppendChild(paragraphProps);

// Icon prefix run
if (!string.IsNullOrEmpty(style.IconPrefix))
{
var iconColor = string.IsNullOrEmpty(style.IconPrefixColor) ? style.Color : style.IconPrefixColor;
var iconRun = paragraph.AppendChild(new Run());
iconRun.AppendChild(CreateBaseRunProperties(style.FontSize, iconColor, bold: style.Bold));
iconRun.AppendChild(new Text(style.IconPrefix + " ") { Space = SpaceProcessingModeValues.Preserve });
}

var run = paragraph.AppendChild(new Run());
run.AppendChild(CreateBaseRunProperties(style.FontSize, style.Color, bold: style.Bold));
run.AppendChild(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
Expand Down Expand Up @@ -565,7 +591,8 @@
style.BorderPosition,
style.BorderColor ?? "3498db",
style.BorderSize,
style.BorderSpace));
style.BorderSpace,
style.BorderStyle));

// Background shading
if (!string.IsNullOrEmpty(style.BackgroundColor))
Expand All @@ -590,6 +617,15 @@

mainParagraph.AppendChild(mainProps);

// Icon prefix run
if (!string.IsNullOrEmpty(style.IconPrefix))
{
var iconColor = string.IsNullOrEmpty(style.IconPrefixColor) ? style.Color : style.IconPrefixColor;
var iconRun = mainParagraph.AppendChild(new Run());
iconRun.AppendChild(CreateBaseRunProperties(style.FontSize, iconColor, bold: style.Bold));
iconRun.AppendChild(new Text(style.IconPrefix + " ") { Space = SpaceProcessingModeValues.Preserve });
}

var run = mainParagraph.AppendChild(new Run());
run.AppendChild(CreateBaseRunProperties(style.FontSize, style.Color, bold: style.Bold));
run.AppendChild(new Text(text) { Space = SpaceProcessingModeValues.Preserve });
Expand Down Expand Up @@ -635,7 +671,8 @@
style.BorderPosition,
style.BorderColor ?? "3498db",
style.BorderSize,
style.BorderSpace));
style.BorderSpace,
style.BorderStyle));
}

// Background shading
Expand Down Expand Up @@ -825,7 +862,7 @@
// Adding indent equal to BorderSpace * 20 twips anchors the border at the margin boundary.
if (style.BorderSpace > 0)
{
string indentTwips = (style.BorderSpace * 20).ToString();

Check warning on line 865 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateCodeBlockParagraphProperties(CodeBlockStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 865 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateCodeBlockParagraphProperties(CodeBlockStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
props.AppendChild(new Indentation { Left = indentTwips, Right = indentTwips });
}

Expand Down Expand Up @@ -932,7 +969,7 @@
var indentation = new Indentation { Left = style.LeftIndent };
if (hasPadding && style.PaddingSpace > 0)
{
indentation.Right = (style.PaddingSpace * 20).ToString();

Check warning on line 972 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateQuoteParagraphProperties(QuoteStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 972 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateQuoteParagraphProperties(QuoteStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}
props.AppendChild(indentation);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,25 @@ public sealed record HeadingStyleConfig
/// at the specified level (e.g., set to 1 on H2 to prevent a page break after H1).
/// </summary>
public int? SuppressPageBreakIfPrevHeadingLevel { get; init; }

/// <summary>
/// Border line style for all border positions on this heading.
/// Supported values: "single" (default), "double", "thick", "dotted", "dashed",
/// "dotDash", "wave", "triple".
/// </summary>
public string BorderStyle { get; init; } = "single";

/// <summary>
/// Unicode character (or short string) prepended to the heading text as a decorative prefix.
/// For example "◆", "★", "▶", or "01". Null or empty means no prefix.
/// </summary>
public string? IconPrefix { get; init; }

/// <summary>
/// Color of the icon prefix in hexadecimal format (e.g., "E91E8C").
/// When null or empty, the heading's main Color is used.
/// </summary>
public string? IconPrefixColor { get; init; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ public HeadingStyle ApplyHeadingStyle(int level, StyleConfiguration config)
SpaceAfter = headingConfig.SpaceAfter,
BorderExtent = headingConfig.BorderExtent,
LeftIndent = headingConfig.LeftIndent,
SuppressPageBreakIfPrevHeadingLevel = headingConfig.SuppressPageBreakIfPrevHeadingLevel
SuppressPageBreakIfPrevHeadingLevel = headingConfig.SuppressPageBreakIfPrevHeadingLevel,
BorderStyle = headingConfig.BorderStyle,
IconPrefix = headingConfig.IconPrefix,
IconPrefixColor = headingConfig.IconPrefixColor
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2326,6 +2326,174 @@ public void AddHeading_WithBottomBorderOnly_ShouldNotAddNilBorders()
borders.GetFirstChild<RightBorder>().Should().BeNull();
}

[Fact]
public void AddHeading_WithBorderStyleDouble_ShouldRenderDoubleBorder()
{
// Arrange
using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
var style = CreateDefaultHeadingStyle() with
{
ShowBorder = true,
BorderPosition = "bottom",
BorderColor = "3498db",
BorderSize = 12,
BorderSpace = 2,
BorderStyle = "double"
};

// Act
builder.AddHeading(1, "Double border heading", style);
builder.Save();

// Assert: bottom border uses Double style
_stream.Position = 0;
using var doc = WordprocessingDocument.Open(_stream, false);
var paragraph = doc.MainDocumentPart!.Document.Body!
.Descendants<Paragraph>()
.First(p => p.ParagraphProperties?.ParagraphBorders != null);

var borders = paragraph.ParagraphProperties!.ParagraphBorders!;
borders.GetFirstChild<BottomBorder>()!.Val!.Value.Should().Be(BorderValues.Double);
}

[Fact]
public void AddHeading_WithBorderStyleWave_ShouldRenderWaveBorder()
{
// Arrange
using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
var style = CreateDefaultHeadingStyle() with
{
ShowBorder = true,
BorderPosition = "bottom",
BorderColor = "3498db",
BorderSize = 12,
BorderSpace = 2,
BorderStyle = "wave"
};

// Act
builder.AddHeading(2, "Wave border heading", style);
builder.Save();

// Assert: bottom border uses Wave style
_stream.Position = 0;
using var doc = WordprocessingDocument.Open(_stream, false);
var paragraph = doc.MainDocumentPart!.Document.Body!
.Descendants<Paragraph>()
.First(p => p.ParagraphProperties?.ParagraphBorders != null);

var borders = paragraph.ParagraphProperties!.ParagraphBorders!;
borders.GetFirstChild<BottomBorder>()!.Val!.Value.Should().Be(BorderValues.Wave);
}

[Fact]
public void AddHeading_WithIconPrefix_ShouldPrependIconRun()
{
// Arrange
using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
var style = CreateDefaultHeadingStyle() with
{
IconPrefix = "◆"
};

// Act
builder.AddHeading(1, "Test Heading", style);
builder.Save();

// Assert: paragraph has two runs — icon prefix run and text run
_stream.Position = 0;
using var doc = WordprocessingDocument.Open(_stream, false);
var paragraph = doc.MainDocumentPart!.Document.Body!
.Descendants<Paragraph>()
.First(p => p.Descendants<Run>().Any());

var runs = paragraph.Descendants<Run>().ToList();
runs.Should().HaveCount(2);
runs[0].InnerText.Should().StartWith("◆");
runs[1].InnerText.Should().Be("Test Heading");
}

[Fact]
public void AddHeading_WithIconPrefixColor_ShouldUseCustomColor()
{
// Arrange
using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
var style = CreateDefaultHeadingStyle() with
{
IconPrefix = "★",
IconPrefixColor = "E91E8C"
};

// Act
builder.AddHeading(1, "Colored Icon Heading", style);
builder.Save();

// Assert: icon run uses custom color, text run uses heading color
_stream.Position = 0;
using var doc = WordprocessingDocument.Open(_stream, false);
var paragraph = doc.MainDocumentPart!.Document.Body!
.Descendants<Paragraph>()
.First(p => p.Descendants<Run>().Any());

var runs = paragraph.Descendants<Run>().ToList();
runs.Should().HaveCount(2);
var iconColor = runs[0].RunProperties!.GetFirstChild<Color>()!.Val!.Value;
var textColor = runs[1].RunProperties!.GetFirstChild<Color>()!.Val!.Value;
iconColor.Should().Be("E91E8C");
textColor.Should().Be("2c3e50");
}

[Fact]
public void AddHeading_WithIconPrefixNoColor_ShouldUseHeadingColor()
{
// Arrange
using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
var style = CreateDefaultHeadingStyle() with
{
IconPrefix = "▶",
IconPrefixColor = null
};

// Act
builder.AddHeading(1, "Default Color Icon", style);
builder.Save();

// Assert: icon run uses heading color (same as text run color)
_stream.Position = 0;
using var doc = WordprocessingDocument.Open(_stream, false);
var paragraph = doc.MainDocumentPart!.Document.Body!
.Descendants<Paragraph>()
.First(p => p.Descendants<Run>().Any());

var runs = paragraph.Descendants<Run>().ToList();
runs.Should().HaveCount(2);
var iconColor = runs[0].RunProperties!.GetFirstChild<Color>()!.Val!.Value;
var textColor = runs[1].RunProperties!.GetFirstChild<Color>()!.Val!.Value;
iconColor.Should().Be("2c3e50");
textColor.Should().Be("2c3e50");
}

[Fact]
public void AddHeading_WithNoIconPrefix_ShouldHaveSingleRun()
{
// Arrange
using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
var style = CreateDefaultHeadingStyle() with { IconPrefix = null };

// Act
builder.AddHeading(1, "Plain Heading", style);
builder.Save();

// Assert: only one run (no icon prefix)
_stream.Position = 0;
using var doc = WordprocessingDocument.Open(_stream, false);
var paragraph = doc.MainDocumentPart!.Document.Body!
.Descendants<Paragraph>()
.First(p => p.Descendants<Run>().Any());

paragraph.Descendants<Run>().Should().HaveCount(1);
}

public void Dispose()
{
_stream?.Dispose();
Expand Down
Loading
Loading