Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions csharp-version/src/MarkdownToDocx.Core/Models/QuoteStyle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,62 @@ public sealed record QuoteStyle
public string SpaceAfter { get; init; } = "120";

/// <summary>
/// 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 <see cref="PaddingSpace"/> (points * 20) if it is set.
/// </summary>
public uint PaddingTop { get; init; } = 0;

/// <summary>
/// Right inner padding in twips (1/20 pt). See <see cref="PaddingTop"/>.
/// </summary>
public uint PaddingRight { get; init; } = 0;

/// <summary>
/// Bottom inner padding in twips (1/20 pt). See <see cref="PaddingTop"/>.
/// </summary>
public uint PaddingBottom { get; init; } = 0;

/// <summary>
/// Left inner padding in twips (1/20 pt). See <see cref="PaddingTop"/>.
/// </summary>
public uint PaddingLeft { get; init; } = 0;

/// <summary>
/// Deprecated uniform padding in points. When any per-side
/// (<see cref="PaddingTop"/>, <see cref="PaddingRight"/>, <see cref="PaddingBottom"/>,
/// <see cref="PaddingLeft"/>) is zero and this is non-zero, that side falls back
/// to <c>PaddingSpace * 20</c> twips. Prefer the per-side properties for new presets.
/// Only effective when BackgroundColor is set.
/// </summary>
public uint PaddingSpace { get; init; } = 0;

/// <summary>
/// True if any padding side or PaddingSpace is non-zero.
/// </summary>
public bool HasAnyPadding =>
PaddingTop > 0 || PaddingRight > 0 || PaddingBottom > 0
|| PaddingLeft > 0 || PaddingSpace > 0;

/// <summary>
/// Effective top padding in twips, with PaddingSpace fallback applied.
/// </summary>
public uint EffectivePaddingTop => PaddingTop > 0 ? PaddingTop : PaddingSpace * 20;

/// <summary>
/// Effective right padding in twips, with PaddingSpace fallback applied.
/// </summary>
public uint EffectivePaddingRight => PaddingRight > 0 ? PaddingRight : PaddingSpace * 20;

/// <summary>
/// Effective bottom padding in twips, with PaddingSpace fallback applied.
/// </summary>
public uint EffectivePaddingBottom => PaddingBottom > 0 ? PaddingBottom : PaddingSpace * 20;

/// <summary>
/// Effective left padding in twips, with PaddingSpace fallback applied.
/// </summary>
public uint EffectivePaddingLeft => PaddingLeft > 0 ? PaddingLeft : PaddingSpace * 20;

/// <summary>
/// Monospace font family for inline code (ASCII characters)
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@
// Adding indent equal to BorderSpace * 20 twips anchors the border at the margin boundary.
if (style.BorderSpace > 0)
{
string indentTwips = (style.BorderSpace * 20).ToString();

Check warning on line 865 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateCodeBlockParagraphProperties(CodeBlockStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 865 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateCodeBlockParagraphProperties(CodeBlockStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
props.AppendChild(new Indentation { Left = indentTwips, Right = indentTwips });
}

Expand Down Expand Up @@ -890,6 +890,16 @@
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)
Expand All @@ -905,6 +915,247 @@
}
}

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// Builds table-level borders for the quote cell. Visible borders honour
/// <see cref="QuoteStyle.ShowBorder"/> and <see cref="QuoteStyle.BorderPosition"/>;
/// remaining sides emit Nil borders so the cell stays visually clean.
/// </summary>
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 }
);
}

/// <summary>
/// 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.
/// </summary>
private Paragraph BuildQuoteCellParagraph(IReadOnlyList<InlineRun> 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;
}

/// <summary>
/// Builds list-item paragraphs for cell-mode quote rendering.
/// </summary>
private IEnumerable<Paragraph> 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;
}
}

/// <summary>
/// Parses a twips string (e.g. "720") to an int, returning 0 on null/empty/invalid.
/// </summary>
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;
}

/// <summary>
/// Renders a paragraph inside a blockquote with quote styling.
/// </summary>
Expand Down Expand Up @@ -1015,7 +1266,7 @@
var indentation = new Indentation { Left = style.LeftIndent };
if (hasPadding && style.PaddingSpace > 0)
{
indentation.Right = (style.PaddingSpace * 20).ToString();

Check warning on line 1269 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateQuoteParagraphProperties(QuoteStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)

Check warning on line 1269 in csharp-version/src/MarkdownToDocx.Core/OpenXml/OpenXmlDocumentBuilder.cs

View workflow job for this annotation

GitHub Actions / Build and Test

The behavior of 'uint.ToString()' could vary based on the current user's locale settings. Replace this call in 'OpenXmlDocumentBuilder.CreateQuoteParagraphProperties(QuoteStyle)' with a call to 'uint.ToString(IFormatProvider)'. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1305)
}
props.AppendChild(indentation);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,29 @@ public sealed record QuoteStyleConfig
public string SpaceAfter { get; init; } = "120";

/// <summary>
/// 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.
/// </summary>
public uint PaddingTop { get; init; } = 0;

/// <summary>
/// Right inner padding in twips (1/20 pt). See PaddingTop.
/// </summary>
public uint PaddingRight { get; init; } = 0;

/// <summary>
/// Bottom inner padding in twips (1/20 pt). See PaddingTop.
/// </summary>
public uint PaddingBottom { get; init; } = 0;

/// <summary>
/// Left inner padding in twips (1/20 pt). See PaddingTop.
/// </summary>
public uint PaddingLeft { get; init; } = 0;

/// <summary>
/// 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.
/// </summary>
public uint PaddingSpace { get; init; } = 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading