From 47f194c2352889ce22aad8cfc9d0ee47fb37a62c Mon Sep 17 00:00:00 2001 From: forest6511 <20209757+forest6511@users.noreply.github.com> Date: Wed, 6 May 2026 02:40:13 +0900 Subject: [PATCH] fix: collapse quote-table before-spacer against preceding heading/text (#78) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #76 / #77 only collapsed against empty spacer paragraphs, so the visible blank-line regression remained at the heading-single-paragraph → quote-table boundary (and at any text → quote-table boundary where the preceding paragraph already supplied SpaceAfter). Generalise AddCollapsingBeforeSpacer to fold the requested before-spacing into the previous paragraph's SpacingBetweenLines.After whenever that After is positive, regardless of whether the paragraph contains runs. This matches CSS-like margin-collapse semantics and uniformly covers the table-table, heading-table, and text-table boundaries. The IsBodySpacerParagraph helper becomes unnecessary and is removed. --- .../OpenXml/OpenXmlDocumentBuilder.cs | 49 +++++------ .../Unit/OpenXmlDocumentBuilderTests.cs | 87 +++++++++++++++++++ 2 files changed, 108 insertions(+), 28 deletions(-) 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() {