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() {