diff --git a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
index baa37d3..ff1bb7d 100644
--- a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
+++ b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs
@@ -1341,47 +1341,40 @@ private void AddTableSpacer(string spacing)
///
/// 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.
+ /// while collapsing against the immediately preceding paragraph's SpaceAfter.
+ /// CSS-like margin-collapse: if the previous body element is any paragraph
+ /// that already supplies a positive trailing gap (the after-spacer of a
+ /// sibling table/quote-table, a heading rendered as a single paragraph, or
+ /// regular body text with SpaceAfter), the requested before-spacing is
+ /// folded into that paragraph as max(existing, desired) rather than stacked
+ /// as a second paragraph. This prevents an extra blank line between a
+ /// heading/text and a following table/quote-table.
///
private void AddCollapsingBeforeSpacer(string spacing)
{
if (string.IsNullOrEmpty(spacing) || spacing == "0") return;
- if (_body.LastChild is Paragraph prev && IsBodySpacerParagraph(prev))
+ if (_body.LastChild is Paragraph prev)
{
- var existingSpacing = prev.ParagraphProperties!.GetFirstChild()!;
- int existing = ParseTwipsOrZero(existingSpacing.After?.Value);
- int desired = ParseTwipsOrZero(spacing);
- if (desired > existing)
+ var existingSpacing = prev.ParagraphProperties?.GetFirstChild();
+ if (existingSpacing != null)
{
- existingSpacing.After = desired.ToString(CultureInfo.InvariantCulture);
+ int existingAfter = ParseTwipsOrZero(existingSpacing.After?.Value);
+ if (existingAfter > 0)
+ {
+ int desired = ParseTwipsOrZero(spacing);
+ if (desired > existingAfter)
+ {
+ existingSpacing.After = desired.ToString(CultureInfo.InvariantCulture);
+ }
+ return;
+ }
}
- 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 a0b68ff..fca05ae 100644
--- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
+++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs
@@ -831,6 +831,93 @@ public void AddQuote_AdjacentTableQuotes_ShouldKeepLargerCollapsedSpacing()
"the gap should collapse to max(prev.SpaceAfter, next.SpaceBefore)");
}
+ [Fact]
+ public void AddQuote_AfterHeading_ShouldCollapseAgainstHeadingSpaceAfter()
+ {
+ // Arrange: H2 single-paragraph (default ShowBorder=false → AddHeadingSingleParagraph)
+ // followed by a table-rendered quote (BG + PaddingSpace). Issue #78: heading's
+ // SpaceAfter and quote's SpaceBefore must collapse so no extra blank paragraph
+ // sits between them.
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var headingStyle = CreateDefaultHeadingStyle() with
+ {
+ SpaceBefore = "480",
+ SpaceAfter = "280"
+ };
+ var quoteStyle = CreateDefaultQuoteStyle() with
+ {
+ BackgroundColor = "fdf0ed",
+ PaddingSpace = 6,
+ SpaceBefore = "160",
+ SpaceAfter = "160"
+ };
+
+ // Act
+ builder.AddHeading(2, "Summary", headingStyle);
+ builder.AddQuote(ToQuoteContent("note"), quoteStyle);
+ builder.Save();
+
+ // Assert: body sequence is heading-paragraph, then table — no spurious spacer.
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var bodyChildren = doc.MainDocumentPart!.Document.Body!.ChildElements.ToList();
+
+ var headingIdx = bodyChildren.FindIndex(c =>
+ c is Paragraph p && p.Descendants().Any(t => t.Text == "Summary"));
+ headingIdx.Should().BeGreaterOrEqualTo(0, "the heading paragraph should be in the body");
+
+ var tableIdx = bodyChildren.FindIndex(headingIdx + 1, c => c is Table);
+ tableIdx.Should().Be(headingIdx + 1,
+ "no spacer paragraph should be inserted between the heading and the quote-table");
+
+ // The heading's SpaceAfter must have absorbed (max) the quote's SpaceBefore.
+ var headingParagraph = (Paragraph)bodyChildren[headingIdx];
+ var headingSpacing = headingParagraph.ParagraphProperties!.GetFirstChild()!;
+ headingSpacing.After!.Value.Should().Be("280",
+ "max(headingSpaceAfter=280, quoteSpaceBefore=160) = 280");
+ }
+
+ [Fact]
+ public void AddQuote_AfterHeading_ShouldGrowHeadingSpaceAfterWhenQuoteWantsLargerGap()
+ {
+ // Arrange: quote demands a larger gap than the heading's own SpaceAfter — the
+ // heading paragraph's After should grow (not stack a new spacer).
+ using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider);
+ var headingStyle = CreateDefaultHeadingStyle() with
+ {
+ SpaceBefore = "240",
+ SpaceAfter = "100"
+ };
+ var quoteStyle = CreateDefaultQuoteStyle() with
+ {
+ BackgroundColor = "fdf0ed",
+ PaddingSpace = 6,
+ SpaceBefore = "320",
+ SpaceAfter = "160"
+ };
+
+ // Act
+ builder.AddHeading(3, "Section", headingStyle);
+ builder.AddQuote(ToQuoteContent("body"), quoteStyle);
+ builder.Save();
+
+ // Assert
+ _stream.Position = 0;
+ using var doc = WordprocessingDocument.Open(_stream, false);
+ var bodyChildren = doc.MainDocumentPart!.Document.Body!.ChildElements.ToList();
+
+ var headingIdx = bodyChildren.FindIndex(c =>
+ c is Paragraph p && p.Descendants().Any(t => t.Text == "Section"));
+ var tableIdx = bodyChildren.FindIndex(headingIdx + 1, c => c is Table);
+
+ tableIdx.Should().Be(headingIdx + 1, "still no spacer paragraph between");
+
+ var headingSpacing = ((Paragraph)bodyChildren[headingIdx])
+ .ParagraphProperties!.GetFirstChild()!;
+ headingSpacing.After!.Value.Should().Be("320",
+ "max(100, 320) = 320 — heading's After is grown to satisfy the quote's request");
+ }
+
[Fact]
public void AddTable_AdjacentTables_ShouldCollapseSpacersToSingleParagraph()
{