From 52d821d4625a35825de8d627b0405fbcac3a6eef Mon Sep 17 00:00:00 2001 From: forest6511 <20209757+forest6511@users.noreply.github.com> Date: Mon, 16 Mar 2026 16:42:03 +0900 Subject: [PATCH] feat: support lists inside blockquotes (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace flat InlineRun-based quote extraction with structured QuoteContent model (QuoteParagraph + QuoteList), following the same polymorphic pattern used by FencedDivContent. Each list item renders as a separate paragraph with full quote styling (border, background, indentation). - Add QuoteContent/QuoteContentBlock/QuoteParagraph/QuoteList models - Refactor Helpers.GetQuoteRuns → GetQuoteContent with ListBlock handling - Update IDocumentBuilder.AddQuote signature to accept QuoteContent - Add AddQuoteParagraph/AddQuoteList private render methods - Add 7 new tests (309 total, all passing) --- .../src/MarkdownToDocx.CLI/Helpers.cs | 28 ++- .../src/MarkdownToDocx.CLI/Program.cs | 4 +- .../Interfaces/IDocumentBuilder.cs | 7 +- .../Models/QuoteContent.cs | 50 ++++++ .../OpenXml/OpenXmlDocumentBuilder.cs | 50 +++++- .../Unit/MarkdigParserTests.cs | 49 ++++++ .../Unit/OpenXmlDocumentBuilderTests.cs | 162 ++++++++++++++++-- 7 files changed, 319 insertions(+), 31 deletions(-) create mode 100644 csharp-version/src/MarkdownToDocx.Core/Models/QuoteContent.cs diff --git a/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs b/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs index 3641f26..de84e99 100644 --- a/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs +++ b/csharp-version/src/MarkdownToDocx.CLI/Helpers.cs @@ -139,24 +139,34 @@ public static IReadOnlyList GetParagraphRuns(ParagraphBlock paragraph return runs; } - public static IReadOnlyList GetQuoteRuns(QuoteBlock block) + public static QuoteContent GetQuoteContent(QuoteBlock block) { - var runs = new List(); - bool firstParagraph = true; + var blocks = new List(); foreach (var child in block) { - if (child is ParagraphBlock paragraph && paragraph.Inline != null) + switch (child) { - if (!firstParagraph) - runs.Add(new InlineRun { Text = " " }); + 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 QuoteParagraph { Runs = runs }); + break; - ExtractInlineRuns(paragraph.Inline, runs, bold: false, italic: false); - firstParagraph = false; + 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 QuoteList { Items = items, IsOrdered = list.IsOrdered, StartNumber = startNumber }); + } + break; } } - return runs; + return new QuoteContent { Blocks = blocks }; } /// diff --git a/csharp-version/src/MarkdownToDocx.CLI/Program.cs b/csharp-version/src/MarkdownToDocx.CLI/Program.cs index 67726e6..e2e3107 100644 --- a/csharp-version/src/MarkdownToDocx.CLI/Program.cs +++ b/csharp-version/src/MarkdownToDocx.CLI/Program.cs @@ -125,9 +125,9 @@ break; case QuoteBlock quote: - var quoteRuns = Helpers.GetQuoteRuns(quote); + var quoteContent = Helpers.GetQuoteContent(quote); var quoteStyle = styleApplicator.ApplyQuoteStyle(config.Styles); - builder.AddQuote(quoteRuns, quoteStyle); + builder.AddQuote(quoteContent, quoteStyle); break; case Table tableBlock: diff --git a/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs index 5136cb8..39f1524 100644 --- a/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs +++ b/csharp-version/src/MarkdownToDocx.Core/Interfaces/IDocumentBuilder.cs @@ -40,11 +40,12 @@ public interface IDocumentBuilder : IDisposable void AddCodeBlock(string code, string? language, CodeBlockStyle style); /// - /// Adds a quote block to the document with inline formatting support + /// Adds a quote block to the document with structured content support. + /// Renders paragraphs and lists within the blockquote with quote styling. /// - /// Structured inline runs (bold, italic, code) extracted from the quote block + /// Structured child blocks extracted from the blockquote /// Quote style configuration - void AddQuote(IReadOnlyList runs, QuoteStyle style); + void AddQuote(QuoteContent content, QuoteStyle style); /// /// Adds a title page with a cover image to the document. diff --git a/csharp-version/src/MarkdownToDocx.Core/Models/QuoteContent.cs b/csharp-version/src/MarkdownToDocx.Core/Models/QuoteContent.cs new file mode 100644 index 0000000..e1c1b65 --- /dev/null +++ b/csharp-version/src/MarkdownToDocx.Core/Models/QuoteContent.cs @@ -0,0 +1,50 @@ +namespace MarkdownToDocx.Core.Models; + +/// +/// Structured content extracted from a blockquote. +/// Contains an ordered list of typed child blocks ready for rendering. +/// +public sealed class QuoteContent +{ + /// + /// Ordered sequence of child blocks within the blockquote. + /// + public IReadOnlyList Blocks { get; init; } = []; +} + +/// +/// Base type for all child blocks within a blockquote. +/// +public abstract class QuoteContentBlock { } + +/// +/// A paragraph (with inline formatting) inside a blockquote. +/// +public sealed class QuoteParagraph : QuoteContentBlock +{ + /// + /// Structured inline runs with bold/italic/code formatting. + /// + public IReadOnlyList Runs { get; init; } = []; +} + +/// +/// A list (ordered or unordered) inside a blockquote. +/// +public sealed class QuoteList : QuoteContentBlock +{ + /// + /// 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/OpenXml/OpenXmlDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs index e741203..afa6a16 100644 --- a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs +++ b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs @@ -885,11 +885,31 @@ private ParagraphProperties CreateCodeBlockParagraphProperties(CodeBlockStyle st } /// - public void AddQuote(IReadOnlyList runs, QuoteStyle style) + public void AddQuote(QuoteContent content, QuoteStyle style) { - ArgumentNullException.ThrowIfNull(runs); + ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(style); + foreach (var block in content.Blocks) + { + switch (block) + { + case QuoteParagraph p: + AddQuoteParagraph(p.Runs, style); + break; + + case QuoteList l: + AddQuoteList(l, style); + break; + } + } + } + + /// + /// Renders a paragraph inside a blockquote with quote styling. + /// + private void AddQuoteParagraph(IReadOnlyList runs, QuoteStyle style) + { var paragraph = _body.AppendChild(new Paragraph()); var paragraphProps = CreateQuoteParagraphProperties(style); paragraph.AppendChild(paragraphProps); @@ -913,6 +933,32 @@ public void AddQuote(IReadOnlyList runs, QuoteStyle style) } } + /// + /// Renders a list inside a blockquote with quote styling on each item. + /// + private void AddQuoteList(QuoteList list, QuoteStyle style) + { + int itemNumber = list.StartNumber; + foreach (var item in list.Items) + { + var paragraph = _body.AppendChild(new Paragraph()); + var paragraphProps = CreateQuoteParagraphProperties(style); + paragraph.AppendChild(paragraphProps); + + var run = paragraph.AppendChild(new Run()); + var runProps = CreateBaseRunProperties( + style.FontSize, + style.Color, + italic: style.Italic); + run.AppendChild(runProps); + + string bullet = list.IsOrdered ? $"{itemNumber}. " : "\u2022 "; + run.AppendChild(new Text(bullet + item.Text) { Space = SpaceProcessingModeValues.Preserve }); + + if (list.IsOrdered) itemNumber++; + } + } + /// /// Creates paragraph properties for quote blocks /// diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/MarkdigParserTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/MarkdigParserTests.cs index a31774d..25a3d22 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/MarkdigParserTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/MarkdigParserTests.cs @@ -181,4 +181,53 @@ This is a paragraph. result.Should().Contain(b => b is FencedCodeBlock); result.Should().Contain(b => b is QuoteBlock); } + + [Fact] + public void Parse_WithListInsideQuote_ShouldContainListBlockChild() + { + // Arrange — the exact pattern from issue #70 + var markdown = "> - Item 1\n> - Item 2\n> - Item 3"; + + // Act + var result = _parser.Parse(markdown); + + // Assert + result.Should().HaveCount(1); + var quoteBlock = result[0].Should().BeOfType().Subject; + quoteBlock.Should().Contain(child => child is ListBlock); + var list = quoteBlock.OfType().Single(); + list.Count.Should().Be(3); + list.IsOrdered.Should().BeFalse(); + } + + [Fact] + public void Parse_WithParagraphAndListInsideQuote_ShouldContainBoth() + { + // Arrange + var markdown = "> Summary text\n>\n> - Point A\n> - Point B"; + + // Act + var result = _parser.Parse(markdown); + + // Assert + var quoteBlock = result.OfType().Single(); + quoteBlock.Should().Contain(child => child is ParagraphBlock); + quoteBlock.Should().Contain(child => child is ListBlock); + } + + [Fact] + public void Parse_WithOrderedListInsideQuote_ShouldPreserveOrdering() + { + // Arrange + var markdown = "> 1. First\n> 2. Second"; + + // Act + var result = _parser.Parse(markdown); + + // Assert + var quoteBlock = result.OfType().Single(); + var list = quoteBlock.OfType().Single(); + list.IsOrdered.Should().BeTrue(); + list.Count.Should().Be(2); + } } diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs index d4cba83..4bd3665 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs @@ -646,7 +646,7 @@ public void AddQuote_WithPaddingSpaceAndBackground_ShouldAddRightIndentation() }; // Act - builder.AddQuote(ToRuns("test"), style); + builder.AddQuote(ToQuoteContent("test"), style); builder.Save(); // Assert @@ -675,7 +675,7 @@ public void AddQuote_WithoutPaddingSpace_ShouldNotAddRightIndentation() }; // Act - builder.AddQuote(ToRuns("test"), style); + builder.AddQuote(ToQuoteContent("test"), style); builder.Save(); // Assert @@ -691,7 +691,7 @@ public void AddQuote_WithoutPaddingSpace_ShouldNotAddRightIndentation() } [Fact] - public void AddQuote_WithNullRuns_ShouldThrowArgumentNullException() + public void AddQuote_WithNullContent_ShouldThrowArgumentNullException() { // Arrange using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); @@ -701,7 +701,7 @@ public void AddQuote_WithNullRuns_ShouldThrowArgumentNullException() // Assert act.Should().Throw() - .WithParameterName("runs"); + .WithParameterName("content"); } [Fact] @@ -711,7 +711,7 @@ public void AddQuote_WithNullStyle_ShouldThrowArgumentNullException() using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); // Act - Action act = () => builder.AddQuote(ToRuns("Test quote"), null!); + Action act = () => builder.AddQuote(ToQuoteContent("Test quote"), null!); // Assert act.Should().Throw() @@ -726,7 +726,7 @@ public void AddQuote_WithValidParameters_ShouldAddQuote() var style = CreateDefaultQuoteStyle(); // Act - builder.AddQuote(ToRuns("This is a quoted text."), style); + builder.AddQuote(ToQuoteContent("This is a quoted text."), style); builder.Save(); // Assert @@ -932,7 +932,7 @@ public void ComplexDocument_WithAllElements_ShouldBuildSuccessfully() new CoreListItem { Text = "Point 2" } }, false, CreateDefaultListStyle()); builder.AddCodeBlock("var example = true;", "csharp", CreateDefaultCodeBlockStyle()); - builder.AddQuote(ToRuns("Important note"), CreateDefaultQuoteStyle()); + builder.AddQuote(ToQuoteContent("Important note"), CreateDefaultQuoteStyle()); builder.AddThematicBreak(); builder.Save(); @@ -1797,7 +1797,7 @@ public void AddQuote_WithShowBorderFalse_ShouldNotRenderBorder() }; // Act - builder.AddQuote(ToRuns("Quote without border"), style); + builder.AddQuote(ToQuoteContent("Quote without border"), style); builder.Save(); // Assert @@ -1829,7 +1829,7 @@ public void AddQuote_WithBackgroundColor_ShouldRenderShading() }; // Act - builder.AddQuote(ToRuns("Quote with background"), style); + builder.AddQuote(ToQuoteContent("Quote with background"), style); builder.Save(); // Assert @@ -1859,7 +1859,7 @@ public void AddQuote_WithNoBorderAndNoBackground_ShouldRenderMinimalProperties() }; // Act - builder.AddQuote(ToRuns("Minimal quote"), style); + builder.AddQuote(ToQuoteContent("Minimal quote"), style); builder.Save(); // Assert @@ -1886,7 +1886,7 @@ public void AddQuote_WithBoldRun_ShouldRenderBoldText() }; // Act - builder.AddQuote(runs, style); + builder.AddQuote(ToQuoteContent(runs), style); builder.Save(); // Assert @@ -1916,7 +1916,7 @@ public void AddQuote_WithCodeRun_ShouldRenderMonospaceFont() }; // Act - builder.AddQuote(runs, style); + builder.AddQuote(ToQuoteContent(runs), style); builder.Save(); // Assert @@ -1952,7 +1952,7 @@ public void AddQuote_WithPaddingSpaceAndBackground_ShouldRenderInvisiblePaddingB }; // Act - builder.AddQuote(ToRuns("Padded quote"), style); + builder.AddQuote(ToQuoteContent("Padded quote"), style); builder.Save(); // Assert: top/right/bottom invisible borders should be present with background color @@ -1987,7 +1987,7 @@ public void AddQuote_WithPaddingSpaceButNoBackground_ShouldNotRenderPaddingBorde }; // Act - builder.AddQuote(ToRuns("No padding without background"), style); + builder.AddQuote(ToQuoteContent("No padding without background"), style); builder.Save(); // Assert: no borders rendered when ShowBorder=false and no BackgroundColor @@ -1997,6 +1997,132 @@ public void AddQuote_WithPaddingSpaceButNoBackground_ShouldNotRenderPaddingBorde paragraph.ParagraphProperties?.ParagraphBorders.Should().BeNull(); } + [Fact] + public void AddQuote_WithUnorderedList_ShouldRenderBulletItems() + { + // Arrange + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle(); + var content = new QuoteContent + { + Blocks = + [ + new QuoteList + { + Items = [new CoreListItem { Text = "Item 1" }, new CoreListItem { Text = "Item 2" }], + IsOrdered = false + } + ] + }; + + // Act + builder.AddQuote(content, style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var paragraphs = doc.MainDocumentPart!.Document.Body!.Elements().ToList(); + paragraphs.Should().HaveCount(2); + var texts = paragraphs.Select(p => string.Join("", p.Descendants().Select(t => t.Text))).ToList(); + texts[0].Should().Contain("\u2022 Item 1"); + texts[1].Should().Contain("\u2022 Item 2"); + + // Each item should have quote styling (border, indentation) + foreach (var p in paragraphs) + { + p.ParagraphProperties?.ParagraphBorders.Should().NotBeNull(); + p.ParagraphProperties?.GetFirstChild()?.Left?.Value.Should().Be("720"); + } + } + + [Fact] + public void AddQuote_WithOrderedList_ShouldRenderNumberedItems() + { + // Arrange + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle(); + var content = new QuoteContent + { + Blocks = + [ + new QuoteList + { + Items = [new CoreListItem { Text = "First" }, new CoreListItem { Text = "Second" }], + IsOrdered = true, + StartNumber = 1 + } + ] + }; + + // Act + builder.AddQuote(content, style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var texts = doc.MainDocumentPart!.Document.Body! + .Elements() + .Select(p => string.Join("", p.Descendants().Select(t => t.Text))) + .ToList(); + texts[0].Should().Contain("1. First"); + texts[1].Should().Contain("2. Second"); + } + + [Fact] + public void AddQuote_WithMixedParagraphAndList_ShouldRenderBoth() + { + // Arrange + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle(); + var content = new QuoteContent + { + Blocks = + [ + new QuoteParagraph { Runs = ToRuns("Summary:") }, + new QuoteList + { + Items = [new CoreListItem { Text = "Point A" }, new CoreListItem { Text = "Point B" }], + IsOrdered = false + } + ] + }; + + // Act + builder.AddQuote(content, style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var paragraphs = doc.MainDocumentPart!.Document.Body!.Elements().ToList(); + paragraphs.Should().HaveCount(3); + var allText = string.Join(" ", paragraphs.Select(p => string.Join("", p.Descendants().Select(t => t.Text)))); + allText.Should().Contain("Summary:"); + allText.Should().Contain("Point A"); + allText.Should().Contain("Point B"); + } + + [Fact] + public void AddQuote_WithEmptyContent_ShouldNotAddParagraphs() + { + // Arrange + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle(); + var content = new QuoteContent { Blocks = [] }; + + // Act + builder.AddQuote(content, style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var paragraphs = doc.MainDocumentPart!.Document.Body!.Elements().ToList(); + paragraphs.Should().BeEmpty(); + } + [Fact] public void AddHeading_WithLineSpacing_ShouldRenderExactSpacing() { @@ -2163,7 +2289,7 @@ public void AddQuote_WithCodeRun_ShouldUseConfiguredFont() }; // Act - builder.AddQuote(runs, style); + builder.AddQuote(ToQuoteContent(runs), style); builder.Save(); // Assert @@ -2563,6 +2689,12 @@ public void Dispose() private static List ToRuns(string text) => new List { new InlineRun { Text = text } }; + private static QuoteContent ToQuoteContent(IReadOnlyList runs) => + new QuoteContent { Blocks = [new QuoteParagraph { Runs = runs }] }; + + private static QuoteContent ToQuoteContent(string text) => + ToQuoteContent(ToRuns(text)); + // ── Table tests ──────────────────────────────────────────────────────── [Fact]