From 48ef1ff8a34902c28d5b3425b8c2814498f8d69b Mon Sep 17 00:00:00 2001 From: forest6511 <20209757+forest6511@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:40:13 +0900 Subject: [PATCH] feat: add BorderStyle and IconPrefix decoration to heading styles (#66) - Add BorderStyle property to HeadingStyle/HeadingStyleConfig supporting: single, double, thick, dotted, dashed, dotDash, wave, triple - Add IconPrefix and IconPrefixColor properties for decorative heading prefix - ParseBorderStyle() helper converts YAML string to OpenXml BorderValues enum - IconPrefix rendered as a separate run before heading text, using IconPrefixColor or falling back to the heading Color when IconPrefixColor is null - Both single-paragraph and spacer-mode heading paths updated - 10 new unit tests added (297 total, all passing) --- .../Models/HeadingStyle.cs | 19 ++ .../OpenXml/OpenXmlDocumentBuilder.cs | 51 +++++- .../Models/StyleConfiguration.cs | 19 ++ .../Styling/StyleApplicator.cs | 5 +- .../Unit/OpenXmlDocumentBuilderTests.cs | 168 ++++++++++++++++++ .../Unit/StyleApplicatorTests.cs | 68 +++++++ 6 files changed, 322 insertions(+), 8 deletions(-) diff --git a/csharp-version/src/MarkdownToDocx.Core/Models/HeadingStyle.cs b/csharp-version/src/MarkdownToDocx.Core/Models/HeadingStyle.cs index 0a1f0b2..c1788d3 100644 --- a/csharp-version/src/MarkdownToDocx.Core/Models/HeadingStyle.cs +++ b/csharp-version/src/MarkdownToDocx.Core/Models/HeadingStyle.cs @@ -88,4 +88,23 @@ public sealed record HeadingStyle /// H2 directly follows H1. /// public int? SuppressPageBreakIfPrevHeadingLevel { get; init; } + + /// + /// Border line style applied to all border positions on this heading. + /// Supported values: "single" (default), "double", "thick", "dotted", "dashed", + /// "dotDash", "wave", "triple". + /// + public string BorderStyle { get; init; } = "single"; + + /// + /// Unicode character (or short string) prepended to the heading text as a decorative prefix. + /// For example "◆", "★", "▶", or "01". Null or empty means no prefix. + /// + public string? IconPrefix { get; init; } + + /// + /// Color of the icon prefix in hexadecimal format (e.g., "E91E8C"). + /// When null, the heading's main Color is used. + /// + public string? IconPrefixColor { get; init; } } diff --git a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs index 07fdefd..e741203 100644 --- a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs +++ b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs @@ -129,6 +129,21 @@ private ParagraphProperties CreateBaseParagraphProperties() return props; } + /// + /// Converts a border style name to the corresponding BorderValues enum value. + /// + 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 + }; + /// /// Creates a ParagraphBorders element from a comma-separated position string /// @@ -136,22 +151,24 @@ 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); } @@ -516,6 +533,15 @@ private void AddHeadingSingleParagraph(int level, string text, HeadingStyle styl 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 }); @@ -565,7 +591,8 @@ private void AddHeadingWithSpacers(int level, string text, HeadingStyle style) style.BorderPosition, style.BorderColor ?? "3498db", style.BorderSize, - style.BorderSpace)); + style.BorderSpace, + style.BorderStyle)); // Background shading if (!string.IsNullOrEmpty(style.BackgroundColor)) @@ -590,6 +617,15 @@ private void AddHeadingWithSpacers(int level, string text, HeadingStyle style) 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 }); @@ -635,7 +671,8 @@ private ParagraphProperties CreateHeadingParagraphProperties(int level, HeadingS style.BorderPosition, style.BorderColor ?? "3498db", style.BorderSize, - style.BorderSpace)); + style.BorderSpace, + style.BorderStyle)); } // Background shading diff --git a/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs b/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs index edb9340..c86b2be 100644 --- a/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs +++ b/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs @@ -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). /// public int? SuppressPageBreakIfPrevHeadingLevel { get; init; } + + /// + /// Border line style for all border positions on this heading. + /// Supported values: "single" (default), "double", "thick", "dotted", "dashed", + /// "dotDash", "wave", "triple". + /// + public string BorderStyle { get; init; } = "single"; + + /// + /// Unicode character (or short string) prepended to the heading text as a decorative prefix. + /// For example "◆", "★", "▶", or "01". Null or empty means no prefix. + /// + public string? IconPrefix { get; init; } + + /// + /// Color of the icon prefix in hexadecimal format (e.g., "E91E8C"). + /// When null or empty, the heading's main Color is used. + /// + public string? IconPrefixColor { get; init; } } /// diff --git a/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs b/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs index 822599c..c771643 100644 --- a/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs +++ b/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs @@ -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 }; } diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs index 83d62f4..d4cba83 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs @@ -2326,6 +2326,174 @@ public void AddHeading_WithBottomBorderOnly_ShouldNotAddNilBorders() borders.GetFirstChild().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() + .First(p => p.ParagraphProperties?.ParagraphBorders != null); + + var borders = paragraph.ParagraphProperties!.ParagraphBorders!; + borders.GetFirstChild()!.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() + .First(p => p.ParagraphProperties?.ParagraphBorders != null); + + var borders = paragraph.ParagraphProperties!.ParagraphBorders!; + borders.GetFirstChild()!.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() + .First(p => p.Descendants().Any()); + + var runs = paragraph.Descendants().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() + .First(p => p.Descendants().Any()); + + var runs = paragraph.Descendants().ToList(); + runs.Should().HaveCount(2); + var iconColor = runs[0].RunProperties!.GetFirstChild()!.Val!.Value; + var textColor = runs[1].RunProperties!.GetFirstChild()!.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() + .First(p => p.Descendants().Any()); + + var runs = paragraph.Descendants().ToList(); + runs.Should().HaveCount(2); + var iconColor = runs[0].RunProperties!.GetFirstChild()!.Val!.Value; + var textColor = runs[1].RunProperties!.GetFirstChild()!.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() + .First(p => p.Descendants().Any()); + + paragraph.Descendants().Should().HaveCount(1); + } + public void Dispose() { _stream?.Dispose(); diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs index 60809d0..9fadbdc 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs @@ -589,6 +589,74 @@ public void ApplyHeadingStyle_WithDefaultSuppressPageBreak_ShouldBeNull() style.SuppressPageBreakIfPrevHeadingLevel.Should().BeNull(); } + [Fact] + public void ApplyHeadingStyle_WithBorderStyle_ShouldMapCorrectly() + { + // Arrange + var config = new StyleConfiguration + { + H1 = new HeadingStyleConfig + { + Size = 20, + Bold = true, + Color = "000000", + ShowBorder = true, + BorderStyle = "double" + } + }; + + // Act + var style = _applicator.ApplyHeadingStyle(1, config); + + // Assert + style.BorderStyle.Should().Be("double"); + } + + [Fact] + public void ApplyHeadingStyle_WithDefaultBorderStyle_ShouldBeSingle() + { + // Act + var style = _applicator.ApplyHeadingStyle(1, _testConfig); + + // Assert + style.BorderStyle.Should().Be("single"); + } + + [Fact] + public void ApplyHeadingStyle_WithIconPrefix_ShouldMapCorrectly() + { + // Arrange + var config = new StyleConfiguration + { + H2 = new HeadingStyleConfig + { + Size = 18, + Bold = true, + Color = "000000", + IconPrefix = "◆", + IconPrefixColor = "E91E8C" + } + }; + + // Act + var style = _applicator.ApplyHeadingStyle(2, config); + + // Assert + style.IconPrefix.Should().Be("◆"); + style.IconPrefixColor.Should().Be("E91E8C"); + } + + [Fact] + public void ApplyHeadingStyle_WithDefaultIconPrefix_ShouldBeNull() + { + // Act + var style = _applicator.ApplyHeadingStyle(1, _testConfig); + + // Assert + style.IconPrefix.Should().BeNull(); + style.IconPrefixColor.Should().BeNull(); + } + [Fact] public void ApplyParagraphStyle_ShouldMapInlineCodeFont() {