From 447d2ca5cae61e96ba1d29b1ffdcc29e48d02278 Mon Sep 17 00:00:00 2001
From: forest6511 <20209757+forest6511@users.noreply.github.com>
Date: Wed, 6 May 2026 02:23:04 +0900
Subject: [PATCH] fix: collapse adjacent quote-table spacers to a single
paragraph (#76)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adjacent ::: note blocks rendered as single-cell tables (bcecaf5) accumulated
two empty spacer paragraphs between every pair — the prior table's after-spacer
plus the next table's before-spacer — producing ~2 lines of unintended whitespace.
Make the before-spacer collapse against an immediately preceding spacer
paragraph using max(prev.SpaceAfter, next.SpaceBefore), mirroring CSS
margin-collapse semantics. Applied to both AddQuoteAsTable and AddTable so
consecutive plain tables benefit from the same fix.
---
.../OpenXml/OpenXmlDocumentBuilder.cs | 54 +++++++-
.../Unit/OpenXmlDocumentBuilderTests.cs | 121 ++++++++++++++++++
2 files changed, 172 insertions(+), 3 deletions(-)
diff --git a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
index b879a0a..baa37d3 100644
--- a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
+++ b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
@@ -941,7 +941,11 @@ private void AddQuoteAsTable(QuoteContent content, QuoteStyle style)
int cellWidthTwips = Math.Max(0, fullTextAreaTwips - leftIndentTwips);
// Spacer paragraph before the quote (mirrors AddTable spacing semantics).
- AddTableSpacer(style.SpaceBefore);
+ // Collapsing semantics: when the previous body element is already an empty
+ // spacer paragraph (e.g., the after-spacer of an immediately preceding
+ // quote-table or table), do not stack a second spacer — keep the larger
+ // gap of the two so adjacent ::: note blocks don't accumulate whitespace.
+ AddCollapsingBeforeSpacer(style.SpaceBefore);
var table = _body.AppendChild(new Table());
@@ -1305,8 +1309,9 @@ public void AddTable(TableData tableData, CoreTableStyle style)
- pageConfig.GutterMargin;
int textAreaTwips = (int)(fullTextAreaTwips * style.WidthPercent / 100.0);
- // Spacer paragraph before table
- AddTableSpacer(style.SpaceBefore);
+ // Spacer paragraph before table (collapses with a preceding spacer paragraph
+ // — see AddCollapsingBeforeSpacer for the margin-collapse rationale).
+ AddCollapsingBeforeSpacer(style.SpaceBefore);
var table = _body.AppendChild(new Table());
table.AppendChild(CreateTableProperties(style));
@@ -1334,6 +1339,49 @@ private void AddTableSpacer(string spacing)
spacer.AppendChild(spacerProps);
}
+ ///
+ /// Adds a "before" spacer in front of a block element (table or quote-table)
+ /// while collapsing against an immediately preceding spacer paragraph. When
+ /// the previous body element is already an empty spacer paragraph (typically
+ /// the after-spacer of a sibling table/quote-table), the larger of the two
+ /// requested gaps is preserved instead of stacking two paragraphs. This
+ /// prevents the visible whitespace doubling reported when adjacent ::: note
+ /// blocks render as single-cell tables.
+ ///
+ private void AddCollapsingBeforeSpacer(string spacing)
+ {
+ if (string.IsNullOrEmpty(spacing) || spacing == "0") return;
+
+ if (_body.LastChild is Paragraph prev && IsBodySpacerParagraph(prev))
+ {
+ var existingSpacing = prev.ParagraphProperties!.GetFirstChild()!;
+ int existing = ParseTwipsOrZero(existingSpacing.After?.Value);
+ int desired = ParseTwipsOrZero(spacing);
+ if (desired > existing)
+ {
+ existingSpacing.After = desired.ToString(CultureInfo.InvariantCulture);
+ }
+ return;
+ }
+
+ AddTableSpacer(spacing);
+ }
+
+ ///
+ /// Returns true if the paragraph is one of our body-level spacer paragraphs:
+ /// no runs/hyperlinks and a SpacingBetweenLines element with Before="0" and a
+ /// non-empty After value. Heading after-spacers also match this shape, which
+ /// is intentional — collapsing against them preserves the larger gap.
+ ///
+ private static bool IsBodySpacerParagraph(Paragraph p)
+ {
+ if (p.Elements().Any()) return false;
+ if (p.Elements().Any()) return false;
+ var spacing = p.ParagraphProperties?.GetFirstChild();
+ if (spacing == null) return false;
+ return spacing.Before?.Value == "0" && !string.IsNullOrEmpty(spacing.After?.Value);
+ }
+
///
/// Creates TableProperties with percentage-based width (overflow safety net) and
/// fixed layout so Word honours the tblGrid column widths.
diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
index e50f49b..a0b68ff 100644
--- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
+++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
@@ -747,6 +747,127 @@ public void AddQuote_WithValidParameters_ShouldAddQuote()
textContent.Should().Contain("This is a quoted text.");
}
+ [Fact]
+ public void AddQuote_AdjacentTableQuotes_ShouldCollapseSpacersToSingleParagraph()
+ {
+ // Arrange: BG + PaddingSpace forces table-mode (issue #76 path).
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var style = CreateDefaultQuoteStyle() with
+ {
+ BackgroundColor = "fdf0ed",
+ PaddingSpace = 4,
+ SpaceBefore = "160",
+ SpaceAfter = "160"
+ };
+
+ // Act: render three adjacent ::: note blocks back-to-back.
+ builder.AddQuote(ToQuoteContent("first"), style);
+ builder.AddQuote(ToQuoteContent("second"), style);
+ builder.AddQuote(ToQuoteContent("third"), style);
+ builder.Save();
+
+ // Assert: between each pair of there is exactly one empty
+ // spacer paragraph (the previous after-spacer), not two.
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var bodyChildren = doc.MainDocumentPart!.Document.Body!.ChildElements.ToList();
+
+ var tableIndices = bodyChildren
+ .Select((el, i) => (el, i))
+ .Where(t => t.el is Table)
+ .Select(t => t.i)
+ .ToList();
+
+ tableIndices.Count.Should().Be(3, "three quote-tables were added");
+
+ // Between consecutive tables, only one body element (the spacer) should sit.
+ for (int k = 0; k < tableIndices.Count - 1; k++)
+ {
+ int gap = tableIndices[k + 1] - tableIndices[k] - 1;
+ gap.Should().Be(1,
+ $"adjacent quote-tables should have exactly one spacer paragraph between them, but found {gap}");
+ }
+ }
+
+ [Fact]
+ public void AddQuote_AdjacentTableQuotes_ShouldKeepLargerCollapsedSpacing()
+ {
+ // Arrange: differing SpaceAfter / SpaceBefore values should collapse to max,
+ // matching CSS-like margin-collapse semantics.
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var firstStyle = CreateDefaultQuoteStyle() with
+ {
+ BackgroundColor = "fdf0ed",
+ PaddingSpace = 4,
+ SpaceBefore = "100",
+ SpaceAfter = "100"
+ };
+ var secondStyle = CreateDefaultQuoteStyle() with
+ {
+ BackgroundColor = "fdf0ed",
+ PaddingSpace = 4,
+ SpaceBefore = "400",
+ SpaceAfter = "100"
+ };
+
+ // Act
+ builder.AddQuote(ToQuoteContent("first"), firstStyle);
+ builder.AddQuote(ToQuoteContent("second"), secondStyle);
+ builder.Save();
+
+ // Assert
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var bodyChildren = doc.MainDocumentPart!.Document.Body!.ChildElements.ToList();
+
+ var firstTableIdx = bodyChildren.FindIndex(c => c is Table);
+ var secondTableIdx = bodyChildren.FindIndex(firstTableIdx + 1, c => c is Table);
+
+ secondTableIdx.Should().Be(firstTableIdx + 2, "exactly one collapsed spacer between tables");
+
+ var spacer = (Paragraph)bodyChildren[firstTableIdx + 1];
+ var spacing = spacer.ParagraphProperties!.GetFirstChild()!;
+ spacing.After!.Value.Should().Be("400",
+ "the gap should collapse to max(prev.SpaceAfter, next.SpaceBefore)");
+ }
+
+ [Fact]
+ public void AddTable_AdjacentTables_ShouldCollapseSpacersToSingleParagraph()
+ {
+ // Arrange: same collapse rule applies to plain Markdown tables — guard so
+ // the rare case of consecutive tables doesn't regress either.
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var tableData = new TableData
+ {
+ ColumnCount = 1,
+ Rows =
+ [
+ new TableRowData
+ {
+ IsHeader = false,
+ Cells = [new TableCellData { Runs = [new InlineRun { Text = "x" }], Alignment = "left" }]
+ }
+ ]
+ };
+ var style = CreateDefaultTableStyle();
+
+ // Act
+ builder.AddTable(tableData, style);
+ builder.AddTable(tableData, style);
+ builder.Save();
+
+ // Assert
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var bodyChildren = doc.MainDocumentPart!.Document.Body!.ChildElements.ToList();
+
+ var firstTableIdx = bodyChildren.FindIndex(c => c is Table);
+ var secondTableIdx = bodyChildren.FindIndex(firstTableIdx + 1, c => c is Table);
+
+ (secondTableIdx - firstTableIdx).Should().Be(2,
+ "exactly one spacer paragraph should sit between adjacent tables");
+ }
+
[Fact]
public void AddThematicBreak_ShouldAddHorizontalLine()
{