From bcecaf59bd45d14f57b6be2288b97c2b40526ffb Mon Sep 17 00:00:00 2001 From: forest6511 <20209757+forest6511@users.noreply.github.com> Date: Fri, 1 May 2026 09:51:22 +0900 Subject: [PATCH 1/2] feat: render shaded blockquotes as single-cell tables for true 4-sided padding Closes #72 Replaces the invisible-paragraph-border padding hack with w:tcMar on a borderless single-cell w:tbl when BackgroundColor + any padding is set. This eliminates the 31pt cap, removes the page-margin clipping risk on narrow LeftIndent values, and finally adds left-side padding (the bug reported in #72). Quote blocks without a background color, or without padding, keep the existing paragraph rendering path verbatim. API additions on QuoteStyle / QuoteStyleConfig: PaddingTop, PaddingRight, PaddingBottom, PaddingLeft (uint, twips) EffectivePadding{Top,Right,Bottom,Left} (per-side > 0 wins, else PaddingSpace*20) HasAnyPadding Backward compatibility: PaddingSpace (uint, points) retained as a documented deprecated alias filling all four sides via the EffectivePadding* getters. All 13 loadable presets convert successfully; business.yaml (the only preset using PaddingSpace) now correctly gets left padding too. See docs/decisions/ADR-0004-quote-block-padding.md for the full rationale, alternatives considered (LeftBorder hack, per-side LeftPaddingSpace, do nothing), and migration plan. Tests: 315 passing (4 new for cell-mode, 2 rewritten from old paragraph assertions; 2 new in StyleApplicator covering precedence). --- .../MarkdownToDocx.Core/Models/QuoteStyle.cs | 55 ++- .../OpenXml/OpenXmlDocumentBuilder.cs | 251 ++++++++++++ .../Models/StyleConfiguration.cs | 24 +- .../Styling/StyleApplicator.cs | 4 + .../Unit/OpenXmlDocumentBuilderTests.cs | 170 +++++++- .../Unit/StyleApplicatorTests.cs | 54 +++ .../decisions/ADR-0004-quote-block-padding.md | 364 ++++++++++++++++++ 7 files changed, 898 insertions(+), 24 deletions(-) create mode 100644 docs/decisions/ADR-0004-quote-block-padding.md diff --git a/csharp-version/src/MarkdownToDocx.Core/Models/QuoteStyle.cs b/csharp-version/src/MarkdownToDocx.Core/Models/QuoteStyle.cs index ff5d205..431302d 100644 --- a/csharp-version/src/MarkdownToDocx.Core/Models/QuoteStyle.cs +++ b/csharp-version/src/MarkdownToDocx.Core/Models/QuoteStyle.cs @@ -66,13 +66,62 @@ public sealed record QuoteStyle public string SpaceAfter { get; init; } = "120"; /// - /// Internal padding on top, right, and bottom in points. - /// Creates invisible borders matching the background color to produce - /// spacing between the background fill and the text content. + /// Top inner padding in twips (1/20 pt). Effective when BackgroundColor is set. + /// 0 means: fall back to (points * 20) if it is set. + /// + public uint PaddingTop { get; init; } = 0; + + /// + /// Right inner padding in twips (1/20 pt). See . + /// + public uint PaddingRight { get; init; } = 0; + + /// + /// Bottom inner padding in twips (1/20 pt). See . + /// + public uint PaddingBottom { get; init; } = 0; + + /// + /// Left inner padding in twips (1/20 pt). See . + /// + public uint PaddingLeft { get; init; } = 0; + + /// + /// Deprecated uniform padding in points. When any per-side + /// (, , , + /// ) is zero and this is non-zero, that side falls back + /// to PaddingSpace * 20 twips. Prefer the per-side properties for new presets. /// Only effective when BackgroundColor is set. /// public uint PaddingSpace { get; init; } = 0; + /// + /// True if any padding side or PaddingSpace is non-zero. + /// + public bool HasAnyPadding => + PaddingTop > 0 || PaddingRight > 0 || PaddingBottom > 0 + || PaddingLeft > 0 || PaddingSpace > 0; + + /// + /// Effective top padding in twips, with PaddingSpace fallback applied. + /// + public uint EffectivePaddingTop => PaddingTop > 0 ? PaddingTop : PaddingSpace * 20; + + /// + /// Effective right padding in twips, with PaddingSpace fallback applied. + /// + public uint EffectivePaddingRight => PaddingRight > 0 ? PaddingRight : PaddingSpace * 20; + + /// + /// Effective bottom padding in twips, with PaddingSpace fallback applied. + /// + public uint EffectivePaddingBottom => PaddingBottom > 0 ? PaddingBottom : PaddingSpace * 20; + + /// + /// Effective left padding in twips, with PaddingSpace fallback applied. + /// + public uint EffectivePaddingLeft => PaddingLeft > 0 ? PaddingLeft : PaddingSpace * 20; + /// /// Monospace font family for inline code (ASCII characters) /// diff --git a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs index afa6a16..d617f7d 100644 --- a/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs +++ b/csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs @@ -890,6 +890,16 @@ public void AddQuote(QuoteContent content, QuoteStyle style) ArgumentNullException.ThrowIfNull(content); ArgumentNullException.ThrowIfNull(style); + // Cell mode: when a background color is set together with any padding, + // wrap the content in a single-cell borderless table so w:tcMar can + // produce true 4-sided padding (see ADR-0004). + bool useCellMode = !string.IsNullOrEmpty(style.BackgroundColor) && style.HasAnyPadding; + if (useCellMode) + { + AddQuoteAsTable(content, style); + return; + } + foreach (var block in content.Blocks) { switch (block) @@ -905,6 +915,247 @@ public void AddQuote(QuoteContent content, QuoteStyle style) } } + /// + /// Renders a quote block as a single-cell borderless table with w:tcMar padding + /// and w:shd background. Used when BackgroundColor + any padding is set. + /// + private void AddQuoteAsTable(QuoteContent content, QuoteStyle style) + { + // Compute cell width: full text area minus LeftIndent (which positions the cell). + var pageConfig = _textDirection.GetPageConfiguration(); + int fullTextAreaTwips = (int)(uint)pageConfig.Width + - pageConfig.LeftMargin + - pageConfig.RightMargin + - pageConfig.GutterMargin; + int leftIndentTwips = ParseTwipsOrZero(style.LeftIndent); + int cellWidthTwips = Math.Max(0, fullTextAreaTwips - leftIndentTwips); + + // Spacer paragraph before the quote (mirrors AddTable spacing semantics). + AddTableSpacer(style.SpaceBefore); + + var table = _body.AppendChild(new Table()); + + // Table properties: width, fixed layout, indentation, borders. + var tableProps = new TableProperties(); + tableProps.AppendChild(new TableWidth + { + Type = TableWidthUnitValues.Dxa, + Width = cellWidthTwips.ToString(CultureInfo.InvariantCulture) + }); + tableProps.AppendChild(new TableLayout { Type = TableLayoutValues.Fixed }); + if (leftIndentTwips > 0) + { + tableProps.AppendChild(new TableIndentation + { + Width = leftIndentTwips, + Type = TableWidthUnitValues.Dxa + }); + } + tableProps.AppendChild(BuildQuoteCellTableBorders(style)); + table.AppendChild(tableProps); + + // Single-column grid spanning the full cell width. + var grid = table.AppendChild(new TableGrid()); + grid.AppendChild(new GridColumn + { + Width = cellWidthTwips.ToString(CultureInfo.InvariantCulture) + }); + + // Single row, single cell. + var row = table.AppendChild(new TableRow()); + var cell = row.AppendChild(new TableCell()); + + var cellProps = new TableCellProperties(); + cellProps.AppendChild(new TableCellWidth + { + Type = TableWidthUnitValues.Dxa, + Width = cellWidthTwips.ToString(CultureInfo.InvariantCulture) + }); + + // tcMar: true 4-sided padding in twips (sub-point precision, no 31pt cap). + // Order in the OOXML schema is top, left (start), bottom, right (end). + cellProps.AppendChild(new TableCellMargin( + new TopMargin + { + Width = style.EffectivePaddingTop.ToString(CultureInfo.InvariantCulture), + Type = TableWidthUnitValues.Dxa + }, + new StartMargin + { + Width = style.EffectivePaddingLeft.ToString(CultureInfo.InvariantCulture), + Type = TableWidthUnitValues.Dxa + }, + new BottomMargin + { + Width = style.EffectivePaddingBottom.ToString(CultureInfo.InvariantCulture), + Type = TableWidthUnitValues.Dxa + }, + new EndMargin + { + Width = style.EffectivePaddingRight.ToString(CultureInfo.InvariantCulture), + Type = TableWidthUnitValues.Dxa + } + )); + + // Cell shading fills to the cell border on all four sides. + if (!string.IsNullOrEmpty(style.BackgroundColor)) + { + cellProps.AppendChild(new Shading + { + Val = ShadingPatternValues.Clear, + Color = "auto", + Fill = style.BackgroundColor + }); + } + + cell.AppendChild(cellProps); + + // Render inner blocks as plain paragraphs inside the cell — visual chrome + // (background, borders, padding) is owned by the cell. + bool wroteAny = false; + foreach (var block in content.Blocks) + { + switch (block) + { + case QuoteParagraph p: + cell.AppendChild(BuildQuoteCellParagraph(p.Runs, style)); + wroteAny = true; + break; + + case QuoteList l: + foreach (var p in BuildQuoteCellListParagraphs(l, style)) + { + cell.AppendChild(p); + wroteAny = true; + } + break; + } + } + + // OOXML requires every cell to end with at least one paragraph. + if (!wroteAny) + { + cell.AppendChild(new Paragraph(CreateBaseParagraphProperties())); + } + + AddTableSpacer(style.SpaceAfter); + } + + /// + /// Builds table-level borders for the quote cell. Visible borders honour + /// and ; + /// remaining sides emit Nil borders so the cell stays visually clean. + /// + private static TableBorders BuildQuoteCellTableBorders(QuoteStyle style) + { + var top = new TopBorder { Val = BorderValues.Nil }; + var bottom = new BottomBorder { Val = BorderValues.Nil }; + var left = new LeftBorder { Val = BorderValues.Nil }; + var right = new RightBorder { Val = BorderValues.Nil }; + + if (style.ShowBorder) + { + var positions = style.BorderPosition + .ToLowerInvariant() + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); + foreach (var pos in positions) + { + switch (pos) + { + case "top": + top = new TopBorder { Val = BorderValues.Single, Color = style.BorderColor, Size = style.BorderSize, Space = 0U }; + break; + case "bottom": + bottom = new BottomBorder { Val = BorderValues.Single, Color = style.BorderColor, Size = style.BorderSize, Space = 0U }; + break; + case "right": + right = new RightBorder { Val = BorderValues.Single, Color = style.BorderColor, Size = style.BorderSize, Space = 0U }; + break; + case "left": + default: + left = new LeftBorder { Val = BorderValues.Single, Color = style.BorderColor, Size = style.BorderSize, Space = 0U }; + break; + } + } + } + + return new TableBorders( + top, left, bottom, right, + new InsideHorizontalBorder { Val = BorderValues.Nil }, + new InsideVerticalBorder { Val = BorderValues.Nil } + ); + } + + /// + /// Builds a paragraph for cell-mode quote rendering. Visual chrome lives on the + /// cell, so the paragraph carries only run-level styling (font/colour/italic) and + /// neutral spacing. + /// + private Paragraph BuildQuoteCellParagraph(IReadOnlyList runs, QuoteStyle style) + { + var paragraph = new Paragraph(); + var paragraphProps = CreateBaseParagraphProperties(); + paragraphProps.AppendChild(new SpacingBetweenLines { Before = "0", After = "0" }); + paragraph.AppendChild(paragraphProps); + + foreach (var inlineRun in runs) + { + var run = paragraph.AppendChild(new Run()); + var runProps = CreateBaseRunProperties( + style.FontSize, + style.Color, + bold: inlineRun.Bold, + italic: inlineRun.IsCode ? false : (style.Italic || inlineRun.Italic)); + + if (inlineRun.IsCode) + { + ApplyInlineCodeFont(runProps, style.InlineCodeFontAscii, style.InlineCodeFontEastAsia); + } + + run.AppendChild(runProps); + run.AppendChild(new Text(inlineRun.Text) { Space = SpaceProcessingModeValues.Preserve }); + } + + return paragraph; + } + + /// + /// Builds list-item paragraphs for cell-mode quote rendering. + /// + private IEnumerable BuildQuoteCellListParagraphs(QuoteList list, QuoteStyle style) + { + int itemNumber = list.StartNumber; + foreach (var item in list.Items) + { + var paragraph = new Paragraph(); + var paragraphProps = CreateBaseParagraphProperties(); + paragraphProps.AppendChild(new SpacingBetweenLines { Before = "0", After = "0" }); + paragraph.AppendChild(paragraphProps); + + var run = paragraph.AppendChild(new Run()); + var runProps = CreateBaseRunProperties( + style.FontSize, + style.Color, + italic: style.Italic); + run.AppendChild(runProps); + + string bullet = list.IsOrdered ? $"{itemNumber}. " : "• "; + run.AppendChild(new Text(bullet + item.Text) { Space = SpaceProcessingModeValues.Preserve }); + + if (list.IsOrdered) itemNumber++; + yield return paragraph; + } + } + + /// + /// Parses a twips string (e.g. "720") to an int, returning 0 on null/empty/invalid. + /// + private static int ParseTwipsOrZero(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return 0; + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) ? n : 0; + } + /// /// Renders a paragraph inside a blockquote with quote styling. /// diff --git a/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs b/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs index c86b2be..1811280 100644 --- a/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs +++ b/csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs @@ -396,7 +396,29 @@ public sealed record QuoteStyleConfig public string SpaceAfter { get; init; } = "120"; /// - /// Internal padding on top, right, and bottom in points (default: 0 = no padding). + /// Top inner padding in twips (1/20 pt). Only effective when BackgroundColor is set. + /// When 0, falls back to PaddingSpace * 20. + /// + public uint PaddingTop { get; init; } = 0; + + /// + /// Right inner padding in twips (1/20 pt). See PaddingTop. + /// + public uint PaddingRight { get; init; } = 0; + + /// + /// Bottom inner padding in twips (1/20 pt). See PaddingTop. + /// + public uint PaddingBottom { get; init; } = 0; + + /// + /// Left inner padding in twips (1/20 pt). See PaddingTop. + /// + public uint PaddingLeft { get; init; } = 0; + + /// + /// Deprecated uniform padding in points. Falls back to all four sides when no + /// per-side value is set. Prefer PaddingTop / PaddingRight / PaddingBottom / PaddingLeft. /// Only effective when BackgroundColor is set. /// public uint PaddingSpace { get; init; } = 0; diff --git a/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs b/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs index c771643..befc6d7 100644 --- a/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs +++ b/csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs @@ -127,6 +127,10 @@ public QuoteStyle ApplyQuoteStyle(StyleConfiguration config) LeftIndent = config.Quote.LeftIndent, SpaceBefore = config.Quote.SpaceBefore, SpaceAfter = config.Quote.SpaceAfter, + PaddingTop = config.Quote.PaddingTop, + PaddingRight = config.Quote.PaddingRight, + PaddingBottom = config.Quote.PaddingBottom, + PaddingLeft = config.Quote.PaddingLeft, PaddingSpace = config.Quote.PaddingSpace, InlineCodeFontAscii = config.Quote.InlineCodeFontAscii, InlineCodeFontEastAsia = config.Quote.InlineCodeFontEastAsia diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs index 4bd3665..27fe0ed 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs @@ -634,9 +634,10 @@ public void AddCodeBlock_WithZeroBorderSpace_ShouldNotAddIndentation() } [Fact] - public void AddQuote_WithPaddingSpaceAndBackground_ShouldAddRightIndentation() + public void AddQuote_WithPaddingSpaceAndBackground_ShouldRenderAsCellWithTableIndentation() { - // Arrange: PaddingSpace = 4 pt → right indent must be 4 * 20 = 80 twips + // Arrange: BG + PaddingSpace triggers cell mode (ADR-0004). Table is offset + // by LeftIndent via w:tblInd; padding is applied via w:tcMar (all 4 sides). using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); var style = CreateDefaultQuoteStyle() with { @@ -652,14 +653,21 @@ public void AddQuote_WithPaddingSpaceAndBackground_ShouldAddRightIndentation() // Assert _stream.Position = 0; using var doc = WordprocessingDocument.Open(_stream, false); - var paragraph = doc.MainDocumentPart!.Document.Body! - .Descendants() - .First(p => p.ParagraphProperties?.ParagraphBorders != null); + var table = doc.MainDocumentPart!.Document.Body!.Elements().FirstOrDefault(); + table.Should().NotBeNull("BG + padding should render as a single-cell table"); - var indent = paragraph.ParagraphProperties!.GetFirstChild(); - indent.Should().NotBeNull(); - indent!.Left?.Value.Should().Be("560"); - indent!.Right?.Value.Should().Be("80", "right indent = PaddingSpace * 20 twips prevents right border overflow"); + var tableProps = table!.GetFirstChild(); + var tableIndent = tableProps!.GetFirstChild(); + tableIndent.Should().NotBeNull(); + tableIndent!.Width!.Value.Should().Be(560, "table is offset from the left margin by LeftIndent"); + + var cell = table.Descendants().Single(); + var cellMargins = cell.TableCellProperties!.GetFirstChild(); + cellMargins.Should().NotBeNull(); + cellMargins!.TopMargin!.Width!.Value.Should().Be("80", "PaddingSpace=4pt → 80 twips on top"); + cellMargins.StartMargin!.Width!.Value.Should().Be("80", "left padding now matches other sides"); + cellMargins.BottomMargin!.Width!.Value.Should().Be("80"); + cellMargins.EndMargin!.Width!.Value.Should().Be("80"); } [Fact] @@ -1931,7 +1939,7 @@ public void AddQuote_WithCodeRun_ShouldRenderMonospaceFont() } [Fact] - public void AddQuote_WithPaddingSpaceAndBackground_ShouldRenderInvisiblePaddingBorders() + public void AddQuote_WithPaddingSpaceAndBackground_ShouldRenderAsCellWithShadingAndBorder() { // Arrange using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); @@ -1955,18 +1963,140 @@ public void AddQuote_WithPaddingSpaceAndBackground_ShouldRenderInvisiblePaddingB builder.AddQuote(ToQuoteContent("Padded quote"), style); builder.Save(); - // Assert: top/right/bottom invisible borders should be present with background color + // Assert: cell mode emits a single-cell table with shading + visible left border + tcMar. _stream.Position = 0; using var doc = WordprocessingDocument.Open(_stream, false); - var paragraph = doc.MainDocumentPart!.Document.Body!.Elements().First(); - var borders = paragraph.ParagraphProperties?.ParagraphBorders; - borders.Should().NotBeNull(); - borders!.GetFirstChild()!.Space!.Value.Should().Be(4U); - borders.GetFirstChild()!.Color!.Value.Should().Be("f0f4f8"); - borders.GetFirstChild()!.Space!.Value.Should().Be(4U); - borders.GetFirstChild()!.Space!.Value.Should().Be(4U); - // Left border should remain the visible border - borders.GetFirstChild()!.Color!.Value.Should().Be("3498db"); + var table = doc.MainDocumentPart!.Document.Body!.Elements
().Single(); + + var tableBorders = table.GetFirstChild()!.GetFirstChild(); + tableBorders.Should().NotBeNull(); + tableBorders!.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Single); + tableBorders.GetFirstChild()!.Color!.Value.Should().Be("3498db"); + tableBorders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Nil); + tableBorders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Nil); + tableBorders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Nil); + + var cell = table.Descendants().Single(); + var shading = cell.TableCellProperties!.GetFirstChild(); + shading.Should().NotBeNull(); + shading!.Fill!.Value.Should().Be("f0f4f8"); + + var cellMargins = cell.TableCellProperties.GetFirstChild(); + cellMargins!.TopMargin!.Width!.Value.Should().Be("80"); + cellMargins.StartMargin!.Width!.Value.Should().Be("80", "left side now has padding (the Issue #72 fix)"); + cellMargins.BottomMargin!.Width!.Value.Should().Be("80"); + cellMargins.EndMargin!.Width!.Value.Should().Be("80"); + } + + [Fact] + public void AddQuote_WithPerSidePaddingAndBackground_ShouldUseExactPerSideValues() + { + // Arrange: per-side padding overrides PaddingSpace fallback. Values are twips. + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "ffefe6", + PaddingTop = 60, + PaddingRight = 200, + PaddingBottom = 60, + PaddingLeft = 240, + PaddingSpace = 4 // ignored — per-side wins + }; + + // Act + builder.AddQuote(ToQuoteContent("custom"), style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var cellMargins = doc.MainDocumentPart!.Document.Body! + .Descendants().Single() + .TableCellProperties! + .GetFirstChild(); + + cellMargins!.TopMargin!.Width!.Value.Should().Be("60"); + cellMargins.EndMargin!.Width!.Value.Should().Be("200"); + cellMargins.BottomMargin!.Width!.Value.Should().Be("60"); + cellMargins.StartMargin!.Width!.Value.Should().Be("240"); + } + + [Fact] + public void AddQuote_WithVerticalTextAndCellMode_ShouldEmitTableSuccessfully() + { + // Arrange: ADR-0004 claims vertical-text + cell-mode compatibility because + // Word auto-orients table cells via w:textDirection. Verify the call path + // produces a valid DOCX (no exceptions, table emitted, padding applied). + using var builder = new OpenXmlDocumentBuilder(_stream, _verticalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "f0f0f0", + PaddingSpace = 4 + }; + + // Act + builder.AddQuote(ToQuoteContent("vertical quote"), style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var table = doc.MainDocumentPart!.Document.Body!.Elements
().Single(); + var cellMargins = table.Descendants().Single() + .TableCellProperties!.GetFirstChild(); + cellMargins!.TopMargin!.Width!.Value.Should().Be("80"); + cellMargins.StartMargin!.Width!.Value.Should().Be("80"); + } + + [Fact] + public void AddQuote_EmptyContentInCellMode_ShouldEmitCellWithFallbackParagraph() + { + // Arrange: OOXML requires every cell to contain at least one paragraph. + // The cell-mode renderer must emit an empty paragraph when no blocks exist. + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "f0f0f0", + PaddingSpace = 4 + }; + var emptyContent = new QuoteContent { Blocks = Array.Empty() }; + + // Act + builder.AddQuote(emptyContent, style); + builder.Save(); + + // Assert: cell exists and contains exactly one (fallback) paragraph + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var cell = doc.MainDocumentPart!.Document.Body!.Descendants().Single(); + cell.Elements().Should().HaveCount(1, "OOXML schema requires >= 1 paragraph per cell"); + } + + [Fact] + public void AddQuote_WithBackgroundButZeroPadding_ShouldUseParagraphMode() + { + // Arrange: BG without padding stays in the lightweight paragraph path. + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "f0f0f0", + PaddingSpace = 0, + PaddingTop = 0, + PaddingRight = 0, + PaddingBottom = 0, + PaddingLeft = 0 + }; + + // Act + builder.AddQuote(ToQuoteContent("test"), style); + builder.Save(); + + // Assert: no Table emitted; paragraph still carries shading. + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var body = doc.MainDocumentPart!.Document.Body!; + body.Elements
().Should().BeEmpty("paragraph mode is preserved when no padding is requested"); + body.Elements().First().ParagraphProperties!.Shading.Should().NotBeNull(); } [Fact] diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs index 9fadbdc..60e85e8 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs @@ -703,6 +703,60 @@ public void ApplyQuoteStyle_ShouldMapInlineCodeFont() style.InlineCodeFontEastAsia.Should().Be("MS Gothic"); } + [Fact] + public void ApplyQuoteStyle_ShouldMapPerSidePadding() + { + // Arrange: per-side padding fields should round-trip from config to domain model. + var config = new StyleConfiguration + { + Quote = new QuoteStyleConfig + { + Size = 11, + PaddingTop = 60, + PaddingRight = 200, + PaddingBottom = 60, + PaddingLeft = 240, + PaddingSpace = 4 + } + }; + + // Act + var style = _applicator.ApplyQuoteStyle(config); + + // Assert + style.PaddingTop.Should().Be(60u); + style.PaddingRight.Should().Be(200u); + style.PaddingBottom.Should().Be(60u); + style.PaddingLeft.Should().Be(240u); + style.PaddingSpace.Should().Be(4u); + style.EffectivePaddingTop.Should().Be(60u, "per-side wins over PaddingSpace fallback"); + style.HasAnyPadding.Should().BeTrue(); + } + + [Fact] + public void ApplyQuoteStyle_PaddingSpaceOnly_ShouldFallBackToAllFourSides() + { + // Arrange: legacy PaddingSpace alone should populate all sides via EffectivePadding*. + var config = new StyleConfiguration + { + Quote = new QuoteStyleConfig + { + Size = 11, + PaddingSpace = 4 // 4pt → 80 twips fallback + } + }; + + // Act + var style = _applicator.ApplyQuoteStyle(config); + + // Assert + style.PaddingTop.Should().Be(0u); + style.EffectivePaddingTop.Should().Be(80u); + style.EffectivePaddingRight.Should().Be(80u); + style.EffectivePaddingBottom.Should().Be(80u); + style.EffectivePaddingLeft.Should().Be(80u, "Issue #72: left now gets padding via PaddingSpace fallback"); + } + [Fact] public void StyleConfigurationRecords_WithSameProperties_ShouldBeEqual() { diff --git a/docs/decisions/ADR-0004-quote-block-padding.md b/docs/decisions/ADR-0004-quote-block-padding.md new file mode 100644 index 0000000..f785046 --- /dev/null +++ b/docs/decisions/ADR-0004-quote-block-padding.md @@ -0,0 +1,364 @@ +# ADR-0004: Quote Block Padding via Single-Cell Table + +**Date**: 2026-05-01 +**Status**: Proposed +**Decided by**: forest6511 + +--- + +## Context + +**Problem Statement**: + +- `QuoteStyle.PaddingSpace` only emits invisible `w:pBdr` borders on **top/right/bottom**; + the **left** side has no padding, so when `BackgroundColor` is set without a visible + left border the text touches the colored fill edge (Issue #72). +- Extending the existing technique to the left side (Option 1 in Issue #72) is structurally + fragile: the OOXML `w:pBdr/@w:space` attribute is `ST_PointMeasure` (whole points, + range 0–31), and on the left/right axes a border's `space` only displaces the rendered + border line — it does **not** reflow the run content. A separate `w:ind/@w:left` + compensation is required, which interacts with author-set indentation and can clip at + the page margin when `LeftIndent` is small. + +**Current Situation**: + +- `OpenXmlDocumentBuilder.CreateQuoteParagraphProperties` (`OpenXmlDocumentBuilder.cs:993-1002`) + emits Top/Right/Bottom `w:pBdr` borders only. +- `Indentation.Right = PaddingSpace * 20` compensates for the right border that extends + into the page margin (`OpenXmlDocumentBuilder.cs:1015-1019`). +- 20 existing presets in `csharp-version/config/` use `padding_space` (`uint`, points). +- Quote rendering is paragraph-based: `AddQuoteParagraph` and `AddQuoteList` each emit + one `w:p` per source block. + +**Constraints**: + +- YAML schema must remain backward-compatible (CLAUDE.md, 🔴 critical rule). +- Existing 20 presets must keep producing equivalent or better output. +- Vertical text mode (`VerticalTextProvider`) must continue to work. +- Fenced div rendering (`AddFencedDiv*`) is on a separate code path and is out of scope here. + +--- + +## Decision + +Render quote blocks that have a **background color** with non-zero padding inside a +**single-cell, borderless `w:tbl`** that uses `w:tcMar` for true 4-sided padding and +`w:shd` on `w:tcPr` for the background fill. + +Quote blocks without a background color keep the current paragraph-based rendering. + +**Key Points**: + +- Add four new properties to `QuoteStyle` and `QuoteStyleConfig`: `PaddingTop`, + `PaddingRight`, `PaddingBottom`, `PaddingLeft` — all `uint` in **twips**, matching + the existing `TableStyle.CellPadding{Top,Right,Bottom,Left}` convention. +- `PaddingSpace` (uint, points) is retained as a **deprecated alias** that + populates all four sides (`twips = PaddingSpace * 20`) when no explicit + per-side value is provided. +- A new private renderer `AddQuoteAsTable` emits the cell-wrapped form when + `BackgroundColor` is set and any padding side is positive. +- The existing paragraph rendering path is preserved verbatim for the + no-background / no-padding case (zero behavior change for those presets). + +**Scope**: + +- Covers: `QuoteStyle` padding semantics, blockquote rendering (`AddQuote` and helpers), + YAML schema additions for `padding`. +- Not covered: Fenced div rendering (separate code path, separate decision if needed), + table cell padding (already uses `w:tcMar` correctly), heading/paragraph padding. + +--- + +## Consequences + +### Positive + +- ✅ True 4-sided padding with twip precision (no 31 pt cap). +- ✅ Background shading fills cleanly to the cell edge on all four sides. +- ✅ No collision with page margins on small `LeftIndent` values. +- ✅ Existing 20 presets unchanged (no `padding` field added; `PaddingSpace` keeps working). +- ✅ Aligns with the canonical Word approach used by Aspose.Words / docx4j for + "block with padding" semantics. + +### Negative + +- ❌ Cell rendering is structurally heavier than a paragraph (more XML). +- ❌ A single-cell table introduces a `w:tbl` element where users may previously have + expected a flat paragraph stream — affects `Find/Replace` selection across boundaries + and some Word UI behaviors. +- ❌ Long quotes spanning a page break behave slightly differently in cell form + (Word splits the cell unless `w:cantSplit` is set; we keep the default split-allowed + behavior to match prior paragraph behavior). + +### Neutral + +- ℹ️ `Padding` model is reusable; future work may apply it to fenced div / heading + styles, but those migrations are explicitly out of scope here. +- ℹ️ Vertical text mode continues to work because Word auto-orients table cell content + via `w:textDirection`; no provider changes needed. + +--- + +## Alternatives Considered + +### Alternative 1: Add `LeftBorder` to the existing `w:pBdr` hack + +**Description**: Mirror the Top/Right/Bottom invisible-border emission to the Left side +and extend the indentation compensation to `Indentation.Left += PaddingSpace * 20`. +This is Option 1 from Issue #72. + +**Pros**: + +- Minimal diff (~10 LOC). +- Single-property API preserved (no new model). +- Keeps the flat paragraph structure. + +**Cons**: + +- Inherits the 0–31 pt `ST_PointMeasure` cap on padding. +- Requires careful arithmetic against author-set `LeftIndent`; presets with + `LeftIndent=0` would either clip into the page margin or need a per-preset patch. +- Does not solve the "between border" merging behavior between adjacent same-styled + paragraphs (ECMA-376 §17.3.1.7). +- Conflicts with our roadmap of adding richer padding control (per-side values, + sub-point precision). + +**Why rejected**: Solves the visible left-padding gap but leaves the underlying +structural fragility in place. The OOXML `w:pBdr` mechanism is fundamentally a +border-spec, not a content-padding spec. + +--- + +### Alternative 2: Add `LeftPaddingSpace` (and friends) keeping `w:pBdr` + +**Description**: Extend `QuoteStyle` with `Padding{Top,Right,Bottom,Left}Space` fields, +keep emitting the four invisible `w:pBdr` borders. + +**Pros**: + +- 4-sided control without introducing tables. +- Backward compatible (`PaddingSpace` kept as alias for all four). + +**Cons**: + +- Same 31 pt cap and integer-points precision as Alternative 1. +- Same indent-arithmetic / page-margin clipping issues on the left/right axes. +- Doubles the API surface without solving the root cause. + +**Why rejected**: Better API ergonomics than Alternative 1, but does not address the +fundamental OOXML semantics issue (borders ≠ padding on the horizontal axis). + +--- + +### Alternative 3: Document the limitation; do nothing + +**Description**: Note in the preset reference that `PaddingSpace` does not affect the +left side; users should use `LeftIndent` instead. + +**Pros**: + +- Zero code change. + +**Cons**: + +- `LeftIndent` shifts the entire box (including background shading) inward; it cannot + produce padding **between** the colored fill and the text. +- Issue #72 use case (KDP A5 fenced-div decorative blocks with uniform inner margin) + remains unsolvable. + +**Why rejected**: The stated user need cannot be met by `LeftIndent`; this would +ship a known incorrect-by-design feature. + +--- + +## Implementation + +**Files affected**: + +- `csharp-version/src/MarkdownToDocx.Core/Models/QuoteStyle.cs` — add + `PaddingTop/Right/Bottom/Left` (uint, twips); keep `PaddingSpace` (uint, points) + as documented deprecated alias. +- `csharp-version/src/MarkdownToDocx.Styling/Models/StyleConfiguration.cs` — + mirror the new properties on `QuoteStyleConfig`. +- `csharp-version/src/MarkdownToDocx.Styling/Styling/StyleApplicator.cs` — + `ApplyQuoteStyle` passes per-side and `PaddingSpace` through unchanged; the + fallback (per-side > 0 wins, else `PaddingSpace * 20`) lives in the + `EffectivePadding{Top,Right,Bottom,Left}` getters on `QuoteStyle` so the + domain model is self-contained. +- `csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs` — + `AddQuote` dispatches to `AddQuoteAsTable` when `BackgroundColor` set and any + `Padding{Top,Right,Bottom,Left}` is positive; `AddQuoteAsTable` builds a + single-cell `w:tbl` with `w:tblBorders` all `nil`, `w:tcMar`, `w:shd` on `w:tcPr`, + and renders inner blocks via existing helpers. Paragraph path unchanged. +- `csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs` — + extend with cell-mode rendering assertions. +- `csharp-version/tests/MarkdownToDocx.Tests/Unit/StyleApplicatorTests.cs` — + cover the precedence rule. + +**Code references**: + +```csharp +// QuoteStyle.cs (proposed additions) +public sealed record QuoteStyle +{ + /// Top inner padding in twips (1/20 pt). 0 = use PaddingSpace fallback. + public uint PaddingTop { get; init; } = 0; + public uint PaddingRight { get; init; } = 0; + public uint PaddingBottom { get; init; } = 0; + public uint PaddingLeft { get; init; } = 0; + + /// + /// Deprecated. Uniform padding in points; converted to twips and applied to all + /// four sides if no explicit per-side padding is set. Prefer PaddingTop/...Right/Bottom/Left. + /// + public uint PaddingSpace { get; init; } = 0; + + public bool HasAnyPadding => PaddingTop > 0 || PaddingRight > 0 + || PaddingBottom > 0 || PaddingLeft > 0 + || PaddingSpace > 0; +} + +// QuoteStyle.cs (model-level fallback) +public uint EffectivePaddingTop => PaddingTop > 0 ? PaddingTop : PaddingSpace * 20; +public uint EffectivePaddingRight => PaddingRight > 0 ? PaddingRight : PaddingSpace * 20; +public uint EffectivePaddingBottom => PaddingBottom > 0 ? PaddingBottom : PaddingSpace * 20; +public uint EffectivePaddingLeft => PaddingLeft > 0 ? PaddingLeft : PaddingSpace * 20; + +// StyleApplicator.cs simply passes values through +return new QuoteStyle +{ + PaddingTop = config.Quote.PaddingTop, + PaddingRight = config.Quote.PaddingRight, + PaddingBottom = config.Quote.PaddingBottom, + PaddingLeft = config.Quote.PaddingLeft, + PaddingSpace = config.Quote.PaddingSpace, + // ... other fields +}; + +// OpenXmlDocumentBuilder.AddQuote dispatch +public void AddQuote(QuoteContent content, QuoteStyle style) +{ + bool useCellMode = !string.IsNullOrEmpty(style.BackgroundColor) && style.HasAnyPadding; + if (useCellMode) AddQuoteAsTable(content, style); + else AddQuoteAsParagraphs(content, style); // current implementation +} +``` + +**Migration path** (no breaking changes for existing users): + +1. Existing presets using `PaddingSpace: 8` continue to load. Visual output changes: + `business.yaml` (the only existing preset using this) gains a left padding it + previously lacked — this is the bug fix Issue #72 requests. +2. New presets can use per-side fields: `PaddingTop: 80`, `PaddingLeft: 160`, etc. (twips). +3. README + preset docs add a "Padding" section noting `PaddingSpace` is deprecated + but supported indefinitely. +4. A future major version may remove `PaddingSpace` (separate ADR required). + +**Rollback plan**: + +If cell-based rendering causes regressions (e.g., page-break artifacts in printed books): + +1. `AddQuote` dispatch becomes `if (useCellMode && !DisableQuoteCellMode)` guarded by + a YAML flag `quote.rendering: paragraph|cell|auto` (default `auto` = current decision). +2. Users can pin `rendering: paragraph` to revert behavior per preset. +3. Worst case: revert `AddQuote` dispatch, keep `Padding` model as additive metadata. + +--- + +## Validation + +**Success Criteria**: + +- [ ] `PaddingTop/Right/Bottom/Left` deserialize from YAML and override `PaddingSpace` per-side +- [ ] `PaddingSpace: 8` (existing presets) still produces a working DOCX with non-zero padding on all four sides via the new cell renderer when `BackgroundColor` is set +- [ ] Quote with no `BackgroundColor` produces byte-identical paragraph output to pre-change behavior +- [ ] Quote with nested list (PR #70 scenario) renders correctly inside the cell +- [ ] Vertical text mode integration test produces a valid DOCX +- [ ] All 20 existing presets convert sample MDs without errors +- [ ] Test coverage for `MarkdownToDocx.Core` does not regress below 90% + +**Testing Strategy**: + +- Unit: `Padding` shorthand parsing, `PaddingSpace` alias round-trip, `useCellMode` + dispatch logic. +- Integration: real Markdown → DOCX with `background_color` + `padding` / + `padding_space`, with horizontal and vertical text providers. +- Visual: manual diff of Word-rendered output for one preset per category + (blockquote with bg, blockquote without bg, blockquote with nested list, + vertical-text blockquote). + +**Monitoring** (post-implementation): + +- Issue tracker for regressions on existing presets. +- User reports of page-break behavior changes in printed-book workflows. + +--- + +## Related Decisions + +**Supersedes**: None. + +**Related to**: + +- ADR-0002: YAML Schema Design — extends schema with new `padding` field; backward + compat path defined here. +- ADR-0003: Vertical Text Implementation — confirmed compatible (Word auto-orients + table cells via `w:textDirection`). + +**Depends on**: None. + +--- + +## References + +**Specification**: + +- ECMA-376 Part 1, §17.3.1.7 (Paragraph Borders) +- ECMA-376 Part 1, §17.4.43 (`w:tcMar`) +- ECMA-376 Part 1, `ST_PointMeasure` (border `space` units) + +**Authoritative sources**: + +- [LeftBorder Class — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.wordprocessing.leftborder) +- [officeopenxml.com — Paragraph Borders](http://officeopenxml.com/WPborders.php) +- [officeopenxml.com — Table Cell Margins](http://officeopenxml.com/WPtableCellMargins.php) +- [Aspose.Words `Cell.CellFormat` padding properties](https://reference.aspose.com/words/net/aspose.words.cellformat/) + +**Prior-art implementations**: + +- Pandoc — `src/Text/Pandoc/Writers/Docx/OpenXML.hs` BlockQuote handler (style delegation, no padding emulation). +- docx4j — single-cell tables for "padded boxed" content. + +**Issues/PRs**: + +- Issue #72: Quote: PaddingSpace cannot express left-side padding + +--- + +## Timeline + +| Date | Event | +|------|-------| +| 2026-05-01 | Issue #72 filed, research conducted, ADR proposed | +| TBD | Approved by forest6511 | +| TBD | Implementation started on `feature/quote-cell-padding` | +| TBD | Tests + Codex review complete | +| TBD | PR merged | + +--- + +## Notes + +The research that drove this decision is summarized in +`claudedocs/research_quote_padding_20260501.md` (gitignored Claude scratch). +Key insight: industrial OpenXML libraries (Aspose.Words, docx4j) treat **paragraph +borders** and **cell padding** as orthogonal concerns and expose both. The "invisible +border as padding" trick popular in early DOCX generators is an upper-half-plane +technique that does not generalize cleanly to the horizontal axis. Migrating to cell +margins for the shaded case aligns md2docx with the established library convention +without losing the lightweight paragraph path for the no-background case. + +--- + +**Last Updated**: 2026-05-01 +**Next Review**: After PR merge (validate success criteria) From 62c66b106264778a30ff1e8e9cc15db65da467d0 Mon Sep 17 00:00:00 2001 From: forest6511 <20209757+forest6511@users.noreply.github.com> Date: Fri, 1 May 2026 09:54:23 +0900 Subject: [PATCH 2/2] test: cover cell-mode list, multi-side borders, inline code paths Address Codecov patch coverage gap on PR #73: - list rendering inside a padded shaded blockquote - BorderPosition with multiple non-default sides ("top,bottom,right") - inline code run inside cell-mode quote paragraph (ApplyInlineCodeFont) Brings coverage of new lines in OpenXmlDocumentBuilder.cs to 100%. --- .../Unit/OpenXmlDocumentBuilderTests.cs | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs index 27fe0ed..e50f49b 100644 --- a/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs +++ b/csharp-version/tests/MarkdownToDocx.Tests/Unit/OpenXmlDocumentBuilderTests.cs @@ -2048,6 +2048,114 @@ public void AddQuote_WithVerticalTextAndCellMode_ShouldEmitTableSuccessfully() cellMargins.StartMargin!.Width!.Value.Should().Be("80"); } + [Fact] + public void AddQuote_WithListInCellMode_ShouldRenderListItemsInsideCell() + { + // Arrange + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "f0f0f0", + PaddingSpace = 4 + }; + var content = new QuoteContent + { + Blocks = [ + new QuoteList + { + IsOrdered = false, + Items = [ + new CoreListItem { Text = "first" }, + new CoreListItem { Text = "second" } + ] + } + ] + }; + + // Act + builder.AddQuote(content, style); + builder.Save(); + + // Assert: cell contains one paragraph per list item, each with bullet prefix. + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var cell = doc.MainDocumentPart!.Document.Body!.Descendants().Single(); + var paragraphs = cell.Elements().ToList(); + paragraphs.Should().HaveCount(2); + paragraphs[0].InnerText.Should().StartWith("•").And.Contain("first"); + paragraphs[1].InnerText.Should().StartWith("•").And.Contain("second"); + } + + [Fact] + public void AddQuote_CellModeWithMultipleBorderPositions_ShouldEmitVisibleBordersOnEachSide() + { + // Arrange: BorderPosition = "top,bottom,right" exercises all three non-left + // branches in BuildQuoteCellTableBorders. + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "f0f0f0", + PaddingSpace = 4, + ShowBorder = true, + BorderPosition = "top,bottom,right", + BorderColor = "ff0000", + BorderSize = 12 + }; + + // Act + builder.AddQuote(ToQuoteContent("test"), style); + builder.Save(); + + // Assert + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var borders = doc.MainDocumentPart!.Document.Body! + .Elements
().Single() + .GetFirstChild()! + .GetFirstChild(); + borders!.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Single); + borders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Single); + borders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Single); + borders.GetFirstChild()!.Val!.Value.Should().Be(BorderValues.Nil, "left was not requested"); + } + + [Fact] + public void AddQuote_CellModeWithInlineCode_ShouldApplyMonospaceFont() + { + // Arrange: code runs inside a cell-mode quote paragraph must apply the + // configured monospace fonts (ApplyInlineCodeFont branch). + using var builder = new OpenXmlDocumentBuilder(_stream, _horizontalProvider); + var style = CreateDefaultQuoteStyle() with + { + BackgroundColor = "f0f0f0", + PaddingSpace = 4, + InlineCodeFontAscii = "Courier New", + InlineCodeFontEastAsia = "Noto Sans Mono CJK JP" + }; + var runs = new List + { + new InlineRun { Text = "Use " }, + new InlineRun { Text = "exit()", IsCode = true }, + new InlineRun { Text = " to leave." } + }; + + // Act + builder.AddQuote(ToQuoteContent(runs), style); + builder.Save(); + + // Assert: the middle run carries Courier New; surrounding text runs do not. + _stream.Position = 0; + using var doc = WordprocessingDocument.Open(_stream, false); + var paragraph = doc.MainDocumentPart!.Document.Body! + .Descendants().Single() + .Elements().Single(); + var runElems = paragraph.Elements().ToList(); + runElems.Should().HaveCount(3); + runElems[1].RunProperties!.RunFonts!.Ascii!.Value.Should().Be("Courier New"); + runElems[0].RunProperties?.RunFonts.Should().BeNull(); + runElems[2].RunProperties?.RunFonts.Should().BeNull(); + } + [Fact] public void AddQuote_EmptyContentInCellMode_ShouldEmitCellWithFallbackParagraph() {