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