From fd3c050255467c41029dedd26338e150900d3aa7 Mon Sep 17 00:00:00 2001
From: forest6511 <20209757+forest6511@users.noreply.github.com>
Date: Mon, 16 Mar 2026 08:23:05 +0900
Subject: [PATCH] feat: add native styling support for fenced div blocks
(:::classname)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Implements Issue #63. Fenced div blocks (:::classname ... :::) can now be
styled via YAML preset configuration without post-processing scripts.
## YAML configuration
Styles:
FencedDivs:
try:
BackgroundColor: "F2F2F2"
BorderTopColor: "AAAAAA"
BorderTopSize: 4
BorderBottomColor: "AAAAAA"
BorderBottomSize: 4
hint:
BackgroundColor: "EBF5FB"
## Implementation
- FencedDivStyle / FencedDivContent models (Core)
- FencedDivClassConfig + FencedDivs dict in StyleConfiguration (Styling)
- ApplyFencedDivStyle() in StyleApplicator
- AddFencedDiv() in OpenXmlDocumentBuilder:
- Background shading on all child paragraphs/headings/lists
- Top separator border on first block, bottom on last block
- Body cell background shading on tables (via TableStyle.BodyCellBackgroundColor)
- Spacer paragraphs injected when div starts/ends with a table
- CustomContainer case in Program.cs (Markdig UseAdvancedExtensions already
includes UseCustomContainers, so no parser change required)
- 5 new unit tests: 282 → 287 passing
Closes #63
---
.../src/MarkdownToDocx.CLI/Helpers.cs | 46 ++++
.../src/MarkdownToDocx.CLI/Program.cs | 10 +
.../Interfaces/IDocumentBuilder.cs | 8 +
.../Models/FencedDivContent.cs | 77 ++++++
.../Models/FencedDivStyle.cs | 79 ++++++
.../MarkdownToDocx.Core/Models/TableStyle.cs | 7 +
.../OpenXml/OpenXmlDocumentBuilder.cs | 235 +++++++++++++++++-
.../Interfaces/IStyleApplicator.cs | 8 +
.../Models/StyleConfiguration.cs | 64 +++++
.../Styling/StyleApplicator.cs | 26 ++
.../Unit/OpenXmlDocumentBuilderTests.cs | 235 ++++++++++++++++++
11 files changed, 794 insertions(+), 1 deletion(-)
create mode 100644 csharp-version/src/MarkdownToDocx.Core/Models/FencedDivContent.cs
create mode 100644 csharp-version/src/MarkdownToDocx.Core/Models/FencedDivStyle.cs
diff --git a/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs b/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs
index c600af5..3641f26 100644
--- a/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs
+++ b/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs
@@ -1,3 +1,4 @@
+using Markdig.Extensions.CustomContainers;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
@@ -175,6 +176,51 @@ public static string ResolveRelativePath(string path, string basePath)
public static TableData GetTableData(Table table) =>
MarkdownToDocx.Core.Markdown.TableExtractor.Extract(table);
+ ///
+ /// Extracts structured child blocks from a Markdig CustomContainer (:::classname ... :::).
+ /// Supports paragraphs, headings, tables, and lists within the div.
+ /// Unrecognized child block types are silently skipped.
+ ///
+ public static FencedDivContent GetFencedDivContent(CustomContainer container)
+ {
+ var blocks = new List();
+
+ foreach (var child in container)
+ {
+ switch (child)
+ {
+ case ParagraphBlock paragraph:
+ var runs = new List();
+ if (paragraph.Inline != null)
+ ExtractInlineRuns(paragraph.Inline, runs, bold: false, italic: false);
+ if (runs.Count > 0)
+ blocks.Add(new FencedDivParagraph { Runs = runs });
+ break;
+
+ case HeadingBlock heading:
+ var headingText = GetBlockText(heading);
+ if (!string.IsNullOrEmpty(headingText))
+ blocks.Add(new FencedDivHeading { Level = heading.Level, Text = headingText });
+ break;
+
+ case Table table:
+ blocks.Add(new FencedDivTable { Data = GetTableData(table) });
+ break;
+
+ case ListBlock list:
+ var items = GetListItems(list).ToList();
+ if (items.Count > 0)
+ {
+ var startNumber = int.TryParse(list.OrderedStart, out var parsed) ? parsed : 1;
+ blocks.Add(new FencedDivList { Items = items, IsOrdered = list.IsOrdered, StartNumber = startNumber });
+ }
+ break;
+ }
+ }
+
+ return new FencedDivContent { Blocks = blocks };
+ }
+
private static void ExtractInlineRuns(Inline? inline, List runs, bool bold, bool italic)
{
if (inline == null) return;
diff --git a/csharp-version/src/MarkdownToDocx.CLI/Program.cs b/csharp-version/src/MarkdownToDocx.CLI/Program.cs
index 8ed6daa..67726e6 100644
--- a/csharp-version/src/MarkdownToDocx.CLI/Program.cs
+++ b/csharp-version/src/MarkdownToDocx.CLI/Program.cs
@@ -1,3 +1,4 @@
+using Markdig.Extensions.CustomContainers;
using Markdig.Extensions.Tables;
using Markdig.Syntax;
using MarkdownToDocx.CLI;
@@ -135,6 +136,15 @@
builder.AddTable(tableData, tableStyle);
break;
+ case CustomContainer container:
+ var className = container.Info ?? string.Empty;
+ if (!config.Styles.FencedDivs.TryGetValue(className, out var divConfig))
+ divConfig = new MarkdownToDocx.Styling.Models.FencedDivClassConfig();
+ var divStyle = styleApplicator.ApplyFencedDivStyle(divConfig, config.Styles);
+ var divContent = Helpers.GetFencedDivContent(container);
+ builder.AddFencedDiv(divContent, divStyle);
+ break;
+
case ThematicBreakBlock:
builder.AddThematicBreak();
break;
diff --git a/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs
index 18875ac..5136cb8 100644
--- a/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs
+++ b/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs
@@ -75,6 +75,14 @@ public interface IDocumentBuilder : IDisposable
/// Table style configuration
void AddTable(TableData tableData, TableStyle style);
+ ///
+ /// Adds a fenced div block to the document.
+ /// Renders child blocks with background shading and optional top/bottom separator borders.
+ ///
+ /// Structured child blocks extracted from the fenced div
+ /// Fenced div visual style
+ void AddFencedDiv(FencedDivContent content, FencedDivStyle style);
+
///
/// Adds a thematic break (horizontal rule) to the document
///
diff --git a/csharp-version/src/MarkdownToDocx.Core/Models/FencedDivContent.cs b/csharp-version/src/MarkdownToDocx.Core/Models/FencedDivContent.cs
new file mode 100644
index 0000000..f711a74
--- /dev/null
+++ b/csharp-version/src/MarkdownToDocx.Core/Models/FencedDivContent.cs
@@ -0,0 +1,77 @@
+namespace MarkdownToDocx.Core.Models;
+
+///
+/// Structured content extracted from a fenced div block.
+/// Contains an ordered list of typed child blocks ready for rendering.
+///
+public sealed class FencedDivContent
+{
+ ///
+ /// Ordered sequence of child blocks within the fenced div.
+ ///
+ public IReadOnlyList Blocks { get; init; } = [];
+}
+
+///
+/// Base type for all child blocks within a fenced div.
+///
+public abstract class FencedDivBlock { }
+
+///
+/// A paragraph (with inline formatting) inside a fenced div.
+///
+public sealed class FencedDivParagraph : FencedDivBlock
+{
+ ///
+ /// Structured inline runs with bold/italic/code formatting.
+ ///
+ public IReadOnlyList Runs { get; init; } = [];
+}
+
+///
+/// A heading inside a fenced div.
+///
+public sealed class FencedDivHeading : FencedDivBlock
+{
+ ///
+ /// Heading level (1–6).
+ ///
+ public int Level { get; init; }
+
+ ///
+ /// Plain text content of the heading.
+ ///
+ public string Text { get; init; } = string.Empty;
+}
+
+///
+/// A table inside a fenced div.
+///
+public sealed class FencedDivTable : FencedDivBlock
+{
+ ///
+ /// Structured table data with rows, cells, and alignment.
+ ///
+ public TableData Data { get; init; } = new();
+}
+
+///
+/// A list (ordered or unordered) inside a fenced div.
+///
+public sealed class FencedDivList : FencedDivBlock
+{
+ ///
+ /// List items.
+ ///
+ public IReadOnlyList Items { get; init; } = [];
+
+ ///
+ /// True for numbered list, false for bullet list.
+ ///
+ public bool IsOrdered { get; init; }
+
+ ///
+ /// First number for ordered lists.
+ ///
+ public int StartNumber { get; init; } = 1;
+}
diff --git a/csharp-version/src/MarkdownToDocx.Core/Models/FencedDivStyle.cs b/csharp-version/src/MarkdownToDocx.Core/Models/FencedDivStyle.cs
new file mode 100644
index 0000000..b3c90dd
--- /dev/null
+++ b/csharp-version/src/MarkdownToDocx.Core/Models/FencedDivStyle.cs
@@ -0,0 +1,79 @@
+namespace MarkdownToDocx.Core.Models;
+
+///
+/// Styling configuration for a fenced div block (:::classname ... :::).
+/// Background color is applied to all child paragraphs.
+/// Top/bottom separator lines appear on the first and last paragraph-like elements.
+///
+public sealed record FencedDivStyle
+{
+ ///
+ /// Background fill color in hex (e.g., "F2F2F2"). Empty = no shading.
+ ///
+ public string BackgroundColor { get; init; } = string.Empty;
+
+ ///
+ /// Top separator line color in hex (e.g., "AAAAAA"). Empty = no top border.
+ ///
+ public string BorderTopColor { get; init; } = string.Empty;
+
+ ///
+ /// Top separator line thickness in eighths of a point (default: 4 = 0.5pt).
+ ///
+ public uint BorderTopSize { get; init; } = 4;
+
+ ///
+ /// Bottom separator line color in hex (e.g., "AAAAAA"). Empty = no bottom border.
+ ///
+ public string BorderBottomColor { get; init; } = string.Empty;
+
+ ///
+ /// Bottom separator line thickness in eighths of a point (default: 4 = 0.5pt).
+ ///
+ public uint BorderBottomSize { get; init; } = 4;
+
+ ///
+ /// Space between separator line and text in points (default: 0).
+ ///
+ public uint BorderSpace { get; init; } = 0;
+
+ ///
+ /// Spacing before the first block in the div in twips.
+ ///
+ public string SpaceBefore { get; init; } = "0";
+
+ ///
+ /// Spacing after the last block in the div in twips.
+ ///
+ public string SpaceAfter { get; init; } = "0";
+
+ ///
+ /// Left indent for div paragraphs in twips.
+ ///
+ public string LeftIndent { get; init; } = "0";
+
+ ///
+ /// Font size in half-points (inherited from paragraph style).
+ ///
+ public int FontSize { get; init; }
+
+ ///
+ /// Text color in hex (inherited from paragraph style).
+ ///
+ public string Color { get; init; } = string.Empty;
+
+ ///
+ /// Line spacing in twips (inherited from paragraph style).
+ ///
+ public string LineSpacing { get; init; } = "360";
+
+ ///
+ /// Monospace font for inline code ASCII characters.
+ ///
+ public string InlineCodeFontAscii { get; init; } = "Courier New";
+
+ ///
+ /// Monospace font for inline code East Asian characters.
+ ///
+ public string InlineCodeFontEastAsia { get; init; } = "Noto Sans Mono CJK JP";
+}
diff --git a/csharp-version/src/MarkdownToDocx.Core/Models/TableStyle.cs b/csharp-version/src/MarkdownToDocx.Core/Models/TableStyle.cs
index cda8197..e82d716 100644
--- a/csharp-version/src/MarkdownToDocx.Core/Models/TableStyle.cs
+++ b/csharp-version/src/MarkdownToDocx.Core/Models/TableStyle.cs
@@ -75,4 +75,11 @@ public sealed record TableStyle
/// Values below 100 leave horizontal space around the table.
///
public int WidthPercent { get; init; } = 90;
+
+ ///
+ /// Background color applied to all body cells in hex (e.g., "F2F2F2").
+ /// When set (e.g., when the table is inside a fenced div), overrides the default
+ /// transparent body cell background. Null or empty means no shading.
+ ///
+ public string? BodyCellBackgroundColor { get; init; }
}
diff --git a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
index 9584751..07fdefd 100644
--- a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
+++ b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
@@ -1083,11 +1083,16 @@ private TableCell CreateTableCell(TableCellData cellData, int columnCount, bool
Width = colWidthDxa.ToString(CultureInfo.InvariantCulture)
});
- // Header background shading
+ // Cell background shading: header always uses HeaderBackgroundColor;
+ // body cells use BodyCellBackgroundColor when set (e.g., inside a fenced div).
if (isHeader)
{
cellProps.AppendChild(CreateBackgroundShading(style.HeaderBackgroundColor));
}
+ else if (!string.IsNullOrEmpty(style.BodyCellBackgroundColor))
+ {
+ cellProps.AppendChild(CreateBackgroundShading(style.BodyCellBackgroundColor));
+ }
cell.AppendChild(cellProps);
@@ -1138,6 +1143,234 @@ private TableCell CreateTableCell(TableCellData cellData, int columnCount, bool
return cell;
}
+ ///
+ public void AddFencedDiv(FencedDivContent content, FencedDivStyle style)
+ {
+ ArgumentNullException.ThrowIfNull(content);
+ ArgumentNullException.ThrowIfNull(style);
+
+ var blocks = content.Blocks;
+ if (blocks.Count == 0) return;
+
+ // Identify the first and last block indices that can carry paragraph borders.
+ // Tables use cell shading instead of paragraph borders, so they are excluded here.
+ int firstBorderable = -1;
+ int lastBorderable = -1;
+ for (int i = 0; i < blocks.Count; i++)
+ {
+ if (blocks[i] is not FencedDivTable)
+ {
+ if (firstBorderable < 0) firstBorderable = i;
+ lastBorderable = i;
+ }
+ }
+
+ // When the div starts or ends with a table, inject invisible spacer paragraphs
+ // that carry the separator borders so the zone boundary is still visible.
+ bool needTopSpacer = firstBorderable != 0 && !string.IsNullOrEmpty(style.BorderTopColor);
+ bool needBottomSpacer = lastBorderable != blocks.Count - 1 && !string.IsNullOrEmpty(style.BorderBottomColor);
+
+ if (needTopSpacer)
+ AddDivBorderSpacer(style, addTop: true, addBottom: false, isFirst: true, isLast: false);
+
+ for (int i = 0; i < blocks.Count; i++)
+ {
+ bool addTopBorder = !needTopSpacer && i == firstBorderable;
+ bool addBottomBorder = !needBottomSpacer && i == lastBorderable;
+
+ switch (blocks[i])
+ {
+ case FencedDivParagraph p:
+ AddDivParagraph(p.Runs, style, addTopBorder, addBottomBorder, i == 0, i == blocks.Count - 1);
+ break;
+
+ case FencedDivHeading h:
+ AddDivHeading(h, style, addTopBorder, addBottomBorder, i == 0, i == blocks.Count - 1);
+ break;
+
+ case FencedDivList l:
+ AddDivList(l, style, addTopBorder, addBottomBorder, i == 0, i == blocks.Count - 1);
+ break;
+
+ case FencedDivTable t:
+ AddDivTable(t.Data, style);
+ break;
+ }
+ }
+
+ if (needBottomSpacer)
+ AddDivBorderSpacer(style, addTop: false, addBottom: true, isFirst: false, isLast: true);
+ }
+
+ ///
+ /// Renders a paragraph inside a fenced div with background shading and optional separator borders.
+ ///
+ private void AddDivParagraph(
+ IReadOnlyList runs,
+ FencedDivStyle style,
+ bool addTopBorder,
+ bool addBottomBorder,
+ bool isFirst,
+ bool isLast)
+ {
+ var paragraph = _body.AppendChild(new Paragraph());
+ var props = CreateDivParagraphProperties(style, addTopBorder, addBottomBorder, isFirst, isLast);
+ paragraph.AppendChild(props);
+
+ foreach (var inlineRun in runs)
+ {
+ var run = paragraph.AppendChild(new Run());
+ var runProps = CreateBaseRunProperties(style.FontSize, style.Color, inlineRun.Bold, inlineRun.Italic);
+ if (inlineRun.IsCode)
+ ApplyInlineCodeFont(runProps, style.InlineCodeFontAscii, style.InlineCodeFontEastAsia);
+ run.AppendChild(runProps);
+ run.AppendChild(new Text(inlineRun.Text) { Space = SpaceProcessingModeValues.Preserve });
+ }
+ }
+
+ ///
+ /// Renders a heading inside a fenced div as a bold paragraph with background shading.
+ ///
+ private void AddDivHeading(
+ FencedDivHeading heading,
+ FencedDivStyle style,
+ bool addTopBorder,
+ bool addBottomBorder,
+ bool isFirst,
+ bool isLast)
+ {
+ var paragraph = _body.AppendChild(new Paragraph());
+ var props = CreateDivParagraphProperties(style, addTopBorder, addBottomBorder, isFirst, isLast);
+ paragraph.AppendChild(props);
+
+ var run = paragraph.AppendChild(new Run());
+ run.AppendChild(CreateBaseRunProperties(style.FontSize, style.Color, bold: true, italic: false));
+ run.AppendChild(new Text(heading.Text) { Space = SpaceProcessingModeValues.Preserve });
+ }
+
+ ///
+ /// Renders a list inside a fenced div with background shading on each item paragraph.
+ ///
+ private void AddDivList(
+ FencedDivList list,
+ FencedDivStyle style,
+ bool addTopBorder,
+ bool addBottomBorder,
+ bool isFirst,
+ bool isLast)
+ {
+ int itemNumber = list.StartNumber;
+ var items = list.Items.ToList();
+ for (int j = 0; j < items.Count; j++)
+ {
+ bool itemIsFirst = isFirst && j == 0;
+ bool itemIsLast = isLast && j == items.Count - 1;
+ bool itemTopBorder = addTopBorder && j == 0;
+ bool itemBottomBorder = addBottomBorder && j == items.Count - 1;
+
+ var paragraph = _body.AppendChild(new Paragraph());
+ var props = CreateDivParagraphProperties(style, itemTopBorder, itemBottomBorder, itemIsFirst, itemIsLast);
+ paragraph.AppendChild(props);
+
+ var run = paragraph.AppendChild(new Run());
+ run.AppendChild(CreateBaseRunProperties(style.FontSize, style.Color));
+
+ string bullet = list.IsOrdered ? $"{itemNumber}. " : "• ";
+ run.AppendChild(new Text(bullet + items[j].Text) { Space = SpaceProcessingModeValues.Preserve });
+
+ if (list.IsOrdered) itemNumber++;
+ }
+ }
+
+ ///
+ /// Renders a table inside a fenced div, applying background shading to all body cells.
+ ///
+ private void AddDivTable(TableData tableData, FencedDivStyle style)
+ {
+ var pageConfig = _textDirection.GetPageConfiguration();
+ int fullTextAreaTwips = (int)(uint)pageConfig.Width
+ - pageConfig.LeftMargin
+ - pageConfig.RightMargin
+ - pageConfig.GutterMargin;
+
+ // Use a default TableStyle with the div's background applied to body cells
+ var tableStyle = new CoreTableStyle
+ {
+ BodyCellBackgroundColor = string.IsNullOrEmpty(style.BackgroundColor) ? null : style.BackgroundColor
+ };
+
+ int textAreaTwips = (int)(fullTextAreaTwips * tableStyle.WidthPercent / 100.0);
+
+ var table = _body.AppendChild(new Table());
+ table.AppendChild(CreateTableProperties(tableStyle));
+ table.AppendChild(CreateTableGrid(tableData.ColumnCount, textAreaTwips));
+
+ foreach (var row in tableData.Rows)
+ {
+ table.AppendChild(CreateTableRow(row, tableData.ColumnCount, tableStyle, textAreaTwips));
+ }
+ }
+
+ ///
+ /// Creates an invisible spacer paragraph carrying a single separator border,
+ /// used when the first or last block in a fenced div is a table.
+ ///
+ private void AddDivBorderSpacer(FencedDivStyle style, bool addTop, bool addBottom, bool isFirst, bool isLast)
+ {
+ var paragraph = _body.AppendChild(new Paragraph());
+ var props = CreateDivParagraphProperties(style, addTop, addBottom, isFirst, isLast);
+ // Zero-height spacer: suppress spacing so the line appears flush with the table
+ props.AppendChild(new SpacingBetweenLines { Before = "0", After = "0" });
+ paragraph.AppendChild(props);
+ }
+
+ ///
+ /// Builds paragraph properties for a block inside a fenced div.
+ /// Applies background shading, optional separator borders, and spacing.
+ ///
+ private ParagraphProperties CreateDivParagraphProperties(
+ FencedDivStyle style,
+ bool addTopBorder,
+ bool addBottomBorder,
+ bool isFirst,
+ bool isLast)
+ {
+ var props = CreateBaseParagraphProperties();
+
+ // Separator borders (top and/or bottom separator lines delimiting the zone)
+ bool hasTopBorder = addTopBorder && !string.IsNullOrEmpty(style.BorderTopColor);
+ bool hasBottomBorder = addBottomBorder && !string.IsNullOrEmpty(style.BorderBottomColor);
+
+ if (hasTopBorder || hasBottomBorder)
+ {
+ var borders = new ParagraphBorders();
+ if (hasTopBorder)
+ borders.AppendChild(new TopBorder { Val = BorderValues.Single, Color = style.BorderTopColor, Size = style.BorderTopSize, Space = style.BorderSpace });
+ if (hasBottomBorder)
+ borders.AppendChild(new BottomBorder { Val = BorderValues.Single, Color = style.BorderBottomColor, Size = style.BorderBottomSize, Space = style.BorderSpace });
+ props.AppendChild(borders);
+ }
+
+ // Background shading
+ if (!string.IsNullOrEmpty(style.BackgroundColor))
+ props.AppendChild(CreateBackgroundShading(style.BackgroundColor));
+
+ // Line spacing
+ props.AppendChild(new SpacingBetweenLines
+ {
+ Before = isFirst ? style.SpaceBefore : "0",
+ After = isLast ? style.SpaceAfter : "0",
+ Line = style.LineSpacing,
+ LineRule = LineSpacingRuleValues.Auto
+ });
+
+ // Left indent
+ if (style.LeftIndent != "0" && !string.IsNullOrEmpty(style.LeftIndent))
+ props.AppendChild(new Indentation { Left = style.LeftIndent });
+
+ return props;
+ }
+
///
public void AddThematicBreak()
{
diff --git a/csharp-version/src/MarkdownToDocx.Styling/Interfaces/IStyleApplicator.cs b/csharp-version/src/MarkdownToDocx.Styling/Interfaces/IStyleApplicator.cs
index 88e163a..2129acc 100644
--- a/csharp-version/src/MarkdownToDocx.Styling/Interfaces/IStyleApplicator.cs
+++ b/csharp-version/src/MarkdownToDocx.Styling/Interfaces/IStyleApplicator.cs
@@ -73,4 +73,12 @@ public interface IStyleApplicator
/// CLI override for cover image path (implicitly enables title page)
/// Title page style
TitlePageStyle ApplyTitlePageStyle(ConversionConfiguration config, string inputFilePath, string? coverImageOverride = null);
+
+ ///
+ /// Apply style configuration for a fenced div block, inheriting font settings from the base paragraph style.
+ ///
+ /// Fenced div class configuration from YAML
+ /// Full style configuration (used to inherit font size, color, and line spacing)
+ /// Fenced div style
+ FencedDivStyle ApplyFencedDivStyle(FencedDivClassConfig divConfig, StyleConfiguration styles);
}
diff --git a/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs b/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs
index f818876..edb9340 100644
--- a/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs
+++ b/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs
@@ -5,6 +5,12 @@ namespace MarkdownToDocx.Styling.Models;
///
public sealed record StyleConfiguration
{
+ // Shared empty instance used as the default value for FencedDivs so that two
+ // `new StyleConfiguration()` instances compare equal (records use reference equality
+ // for Dictionary properties; sharing the same empty instance avoids false inequality).
+ private static readonly Dictionary EmptyFencedDivs = new();
+
+
///
/// Style configuration for H1 headings
///
@@ -64,6 +70,13 @@ public sealed record StyleConfiguration
/// Style configuration for tables
///
public TableStyleConfig Table { get; init; } = new();
+
+ ///
+ /// Per-class style configuration for fenced div blocks (:::classname ... :::).
+ /// Keys are class names (e.g., "try", "hint"); values define background and border styles.
+ /// Unrecognized class names are rendered without any visual treatment.
+ ///
+ public Dictionary FencedDivs { get; init; } = EmptyFencedDivs;
}
///
@@ -474,3 +487,54 @@ public sealed record ImageStyleConfig
///
public string Alignment { get; init; } = "center";
}
+
+///
+/// Per-class style configuration for a fenced div block (:::classname ... :::).
+///
+public sealed record FencedDivClassConfig
+{
+ ///
+ /// Background fill color in hex (e.g., "F2F2F2"). Empty = no shading.
+ ///
+ public string BackgroundColor { get; init; } = string.Empty;
+
+ ///
+ /// Top separator line color in hex (e.g., "AAAAAA"). Empty = no top border.
+ ///
+ public string BorderTopColor { get; init; } = string.Empty;
+
+ ///
+ /// Top separator line thickness in eighths of a point (default: 4 = 0.5pt).
+ ///
+ public uint BorderTopSize { get; init; } = 4;
+
+ ///
+ /// Bottom separator line color in hex (e.g., "AAAAAA"). Empty = no bottom border.
+ ///
+ public string BorderBottomColor { get; init; } = string.Empty;
+
+ ///
+ /// Bottom separator line thickness in eighths of a point (default: 4 = 0.5pt).
+ ///
+ public uint BorderBottomSize { get; init; } = 4;
+
+ ///
+ /// Space between separator line and text in points (default: 0).
+ ///
+ public uint BorderSpace { get; init; } = 0;
+
+ ///
+ /// Spacing before the div zone in twips.
+ ///
+ public string SpaceBefore { get; init; } = "0";
+
+ ///
+ /// Spacing after the div zone in twips.
+ ///
+ public string SpaceAfter { get; init; } = "0";
+
+ ///
+ /// Left indent for div paragraphs in twips.
+ ///
+ public string LeftIndent { get; init; } = "0";
+}
diff --git a/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs b/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs
index e39d8f6..822599c 100644
--- a/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs
+++ b/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs
@@ -183,6 +183,32 @@ public TableOfContentsStyle ApplyTableOfContentsStyle(ConversionConfiguration co
};
}
+ ///
+ public FencedDivStyle ApplyFencedDivStyle(FencedDivClassConfig divConfig, StyleConfiguration styles)
+ {
+ ArgumentNullException.ThrowIfNull(divConfig);
+ ArgumentNullException.ThrowIfNull(styles);
+
+ var para = styles.Paragraph;
+ return new FencedDivStyle
+ {
+ BackgroundColor = divConfig.BackgroundColor,
+ BorderTopColor = divConfig.BorderTopColor,
+ BorderTopSize = divConfig.BorderTopSize,
+ BorderBottomColor = divConfig.BorderBottomColor,
+ BorderBottomSize = divConfig.BorderBottomSize,
+ BorderSpace = divConfig.BorderSpace,
+ SpaceBefore = divConfig.SpaceBefore,
+ SpaceAfter = divConfig.SpaceAfter,
+ LeftIndent = divConfig.LeftIndent,
+ FontSize = para.Size * 2, // Convert pt to half-points
+ Color = para.Color,
+ LineSpacing = para.LineSpacing,
+ InlineCodeFontAscii = para.InlineCodeFontAscii,
+ InlineCodeFontEastAsia = para.InlineCodeFontEastAsia
+ };
+ }
+
///
/// Resolves a potentially relative path against the directory of a base file.
/// Absolute paths are returned unchanged.
diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
index 2ca4fcf..83d62f4 100644
--- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
+++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
@@ -2735,4 +2735,239 @@ public void AddTable_HeaderRow_ShouldHaveTableHeaderProperty()
SpaceBefore = "160",
SpaceAfter = "160"
};
+
+ // ── FencedDiv tests ──────────────────────────────────────────────────────
+
+ [Fact]
+ public void AddFencedDiv_WithParagraphOnly_ShouldApplyBackgroundAndBorders()
+ {
+ // Arrange
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var content = new FencedDivContent
+ {
+ Blocks =
+ [
+ new FencedDivParagraph
+ {
+ Runs = [new InlineRun { Text = "Try this exercise" }]
+ }
+ ]
+ };
+ var style = new FencedDivStyle
+ {
+ BackgroundColor = "F2F2F2",
+ BorderTopColor = "AAAAAA",
+ BorderTopSize = 4,
+ BorderBottomColor = "AAAAAA",
+ BorderBottomSize = 4,
+ FontSize = 22,
+ Color = "000000",
+ LineSpacing = "360"
+ };
+
+ // Act
+ builder.AddFencedDiv(content, style);
+ builder.Save();
+
+ // Assert: single paragraph with background shading, top border, and bottom border
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var paragraphs = doc.MainDocumentPart!.Document.Body!
+ .Descendants()
+ .Where(p => p.ParagraphProperties?.ParagraphBorders != null)
+ .ToList();
+
+ paragraphs.Should().HaveCount(1);
+ var borders = paragraphs[0].ParagraphProperties!.ParagraphBorders!;
+ borders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Single);
+ borders.GetFirstChild()!.Color!.Value.Should().Be("AAAAAA");
+ borders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Single);
+ borders.GetFirstChild()!.Color!.Value.Should().Be("AAAAAA");
+
+ var shading = paragraphs[0].ParagraphProperties!.GetFirstChild();
+ shading.Should().NotBeNull();
+ shading!.Fill!.Value.Should().Be("F2F2F2");
+ }
+
+ [Fact]
+ public void AddFencedDiv_WithMultipleParagraphs_TopBorderOnFirstBottomBorderOnLast()
+ {
+ // Arrange
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var content = new FencedDivContent
+ {
+ Blocks =
+ [
+ new FencedDivParagraph { Runs = [new InlineRun { Text = "First" }] },
+ new FencedDivParagraph { Runs = [new InlineRun { Text = "Middle" }] },
+ new FencedDivParagraph { Runs = [new InlineRun { Text = "Last" }] }
+ ]
+ };
+ var style = new FencedDivStyle
+ {
+ BackgroundColor = "F2F2F2",
+ BorderTopColor = "AAAAAA",
+ BorderTopSize = 4,
+ BorderBottomColor = "AAAAAA",
+ BorderBottomSize = 4,
+ FontSize = 22,
+ Color = "000000",
+ LineSpacing = "360"
+ };
+
+ // Act
+ builder.AddFencedDiv(content, style);
+ builder.Save();
+
+ // Assert: 3 paragraphs; first has top border, last has bottom border, middle has neither
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var paragraphs = doc.MainDocumentPart!.Document.Body!
+ .Descendants()
+ .ToList();
+
+ paragraphs.Should().HaveCount(3);
+
+ var firstBorders = paragraphs[0].ParagraphProperties?.ParagraphBorders;
+ firstBorders.Should().NotBeNull();
+ firstBorders!.GetFirstChild().Should().NotBeNull();
+ firstBorders.GetFirstChild().Should().BeNull();
+
+ var middleBorders = paragraphs[1].ParagraphProperties?.ParagraphBorders;
+ middleBorders.Should().BeNull();
+
+ var lastBorders = paragraphs[2].ParagraphProperties?.ParagraphBorders;
+ lastBorders.Should().NotBeNull();
+ lastBorders!.GetFirstChild().Should().NotBeNull();
+ lastBorders.GetFirstChild().Should().BeNull();
+ }
+
+ [Fact]
+ public void AddFencedDiv_WithNoBorderColors_ShouldApplyOnlyBackground()
+ {
+ // Arrange
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var content = new FencedDivContent
+ {
+ Blocks =
+ [
+ new FencedDivParagraph { Runs = [new InlineRun { Text = "Zone content" }] }
+ ]
+ };
+ var style = new FencedDivStyle
+ {
+ BackgroundColor = "EBF5FB",
+ FontSize = 22,
+ Color = "000000",
+ LineSpacing = "360"
+ // BorderTopColor and BorderBottomColor intentionally left empty
+ };
+
+ // Act
+ builder.AddFencedDiv(content, style);
+ builder.Save();
+
+ // Assert: background shading applied, no paragraph borders
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var paragraph = doc.MainDocumentPart!.Document.Body!
+ .Descendants()
+ .First();
+
+ paragraph.ParagraphProperties?.ParagraphBorders.Should().BeNull();
+ paragraph.ParagraphProperties?.GetFirstChild()!.Fill!.Value.Should().Be("EBF5FB");
+ }
+
+ [Fact]
+ public void AddFencedDiv_WithTableBodyCell_ShouldApplyBackgroundShading()
+ {
+ // Arrange
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var tableData = new TableData
+ {
+ ColumnCount = 2,
+ Rows =
+ [
+ new TableRowData
+ {
+ IsHeader = true,
+ Cells =
+ [
+ new TableCellData { Runs = [new InlineRun { Text = "Header 1" }] },
+ new TableCellData { Runs = [new InlineRun { Text = "Header 2" }] }
+ ]
+ },
+ new TableRowData
+ {
+ IsHeader = false,
+ Cells =
+ [
+ new TableCellData { Runs = [new InlineRun { Text = "Body A" }] },
+ new TableCellData { Runs = [new InlineRun { Text = "Body B" }] }
+ ]
+ }
+ ]
+ };
+ var content = new FencedDivContent
+ {
+ Blocks = [new FencedDivTable { Data = tableData }]
+ };
+ var style = new FencedDivStyle
+ {
+ BackgroundColor = "F2F2F2",
+ FontSize = 22,
+ Color = "000000",
+ LineSpacing = "360"
+ };
+
+ // Act
+ builder.AddFencedDiv(content, style);
+ builder.Save();
+
+ // Assert: body cells have F2F2F2 background; header cells retain default header color
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var rows = doc.MainDocumentPart!.Document.Body!
+ .Descendants()
+ .ToList();
+
+ rows.Should().HaveCount(2);
+
+ // Header row cells use the default header background (not div background)
+ var headerCells = rows[0].Descendants().ToList();
+ foreach (var cell in headerCells)
+ {
+ var shading = cell.GetFirstChild()?.GetFirstChild();
+ shading.Should().NotBeNull("header cell should always have shading");
+ shading!.Fill!.Value.Should().NotBe("F2F2F2", "header cell should use its own background, not the div background");
+ }
+
+ // Body row cells should have the div background applied
+ var bodyCells = rows[1].Descendants().ToList();
+ foreach (var cell in bodyCells)
+ {
+ var shading = cell.GetFirstChild()?.GetFirstChild();
+ shading.Should().NotBeNull("body cell should have div background shading");
+ shading!.Fill!.Value.Should().Be("F2F2F2");
+ }
+ }
+
+ [Fact]
+ public void AddFencedDiv_WithEmptyContent_ShouldAddNoParagraphs()
+ {
+ // Arrange
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var content = new FencedDivContent { Blocks = [] };
+ var style = new FencedDivStyle { BackgroundColor = "F2F2F2", FontSize = 22, Color = "000000", LineSpacing = "360" };
+
+ // Act
+ builder.AddFencedDiv(content, style);
+ builder.Save();
+
+ // Assert: no content paragraphs added
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var paragraphs = doc.MainDocumentPart!.Document.Body!.Descendants().ToList();
+ paragraphs.Should().BeEmpty();
+ }
}