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(); + } }