diff --git a/src/MiniPdf/DocxReader.cs b/src/MiniPdf/DocxReader.cs
index 26a6a2d2..da795e8d 100644
--- a/src/MiniPdf/DocxReader.cs
+++ b/src/MiniPdf/DocxReader.cs
@@ -1767,7 +1767,10 @@ private static void LinearizeOmmlCore(XElement el, System.Text.StringBuilder sb)
var color = !string.IsNullOrEmpty(colorHex) && colorHex != "auto"
? PdfColor.FromHex(colorHex)
: new PdfColor(0, 0, 0);
- return new DocxBorderEdge(Math.Max(0.5f, width), color);
+ var space = float.TryParse(el.Attribute(W + "space")?.Value, out var parsedSpace)
+ ? parsedSpace
+ : 0f;
+ return new DocxBorderEdge(Math.Max(0.5f, width), color, space);
}
/// Like but returns
@@ -5083,7 +5086,8 @@ internal sealed record DocxParagraph(
/// explicit OOXML "nil"/"none" border that suppresses inheritance.
internal sealed record DocxBorderEdge(
float Width, // in points; 0 means explicit "nil" (suppress inherited border)
- PdfColor Color
+ PdfColor Color,
+ float Space = 0
)
{
public static readonly DocxBorderEdge Nil = new(0f, new PdfColor(0, 0, 0));
diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs
index 2c2f5ed5..bbb51e9f 100644
--- a/src/MiniPdf/DocxToPdfConverter.cs
+++ b/src/MiniPdf/DocxToPdfConverter.cs
@@ -1074,11 +1074,15 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
|| (isVeryFirstParagraph && paragraph.SpacingBeforeExplicit)))
{
var extraBefore = options.CollapseParagraphSpacing
+ && !(paragraph.Runs.Count == 0 && paragraph.Images.Count == 0 && paragraph.Borders != null)
? spacingBefore - state.LastSpacingAfter
: spacingBefore;
if (extraBefore > 0)
{
- state.AdvanceY(extraBefore);
+ if (isVeryFirstParagraph)
+ state.CurrentY -= extraBefore;
+ else
+ state.AdvanceY(extraBefore);
}
}
@@ -1135,7 +1139,8 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
if (baselineToTopOffset < 0) baselineToTopOffset = 0;
state.LastParagraphStartY = state.CurrentY + baselineToTopOffset;
- var totalEmptyAdvance = lineHeight;
+ var emptyBorderWidth = paragraph.Borders?.Bottom?.Width ?? 0f;
+ var totalEmptyAdvance = Math.Max(0f, lineHeight - emptyBorderWidth);
var spacingAfterEmpty = paragraph.SpacingAfter >= 0 ? paragraph.SpacingAfter : 0f;
totalEmptyAdvance += spacingAfterEmpty;
@@ -1153,6 +1158,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
: fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options));
state.AdvanceY(emptyAscentOffset);
}
+ RenderParagraphBorders(state, paragraph, state.CurrentY, state.CurrentY, isEmptyParagraph: true);
state.AdvanceY(totalEmptyAdvance);
// If the empty paragraph pushed past the bottom margin, accumulate
// the overflow as pending vertical space for the next page so that
@@ -1538,7 +1544,10 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
deferredImagesTotalHeight += imgH + 1f;
continue;
}
- RenderImage(state, image, paragraph.Alignment);
+ var inlineImageOffset = paragraph.Runs.Count == 0 && !image.IsAnchor
+ ? fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options))
+ : 0f;
+ RenderImage(state, image, paragraph.Alignment, inlineImageOffset);
}
// Proactive page break: if text + deferred wrapTopAndBottom images
@@ -1901,17 +1910,19 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
if (paragraph.Alignment == "both" && renderMaxWidth == null)
renderMaxWidth = lineW;
+ var renderY = state.CurrentY + GetHeadingBaselineOffset(paragraph, runFontName, runBold);
+
if (runShading != null)
{
var shadingWidth = textWidth + wordSpacing * line.Count(c => c == ' ');
if (renderMaxWidth.HasValue)
shadingWidth = Math.Min(shadingWidth, renderMaxWidth.Value);
var padX = Math.Max(0.7f, runFontSize * 0.08f);
- state.CurrentPage!.AddRectangle(renderX - padX, state.CurrentY - runFontSize * 0.24f,
+ state.CurrentPage!.AddRectangle(renderX - padX, renderY - runFontSize * 0.24f,
shadingWidth + padX * 2, runFontSize * 1.18f, runShading);
}
- state.CurrentPage!.AddText(line, renderX, state.CurrentY, runFontSize, runColor, maxWidth: renderMaxWidth, bold: runBold, italic: runItalic, underline: runUnderline, charSpacing: runCharSpacing, wordSpacing: wordSpacing, preferredFontName: runFontName);
+ state.CurrentPage!.AddText(line, renderX, renderY, runFontSize, runColor, maxWidth: renderMaxWidth, bold: runBold, italic: runItalic, underline: runUnderline, charSpacing: runCharSpacing, wordSpacing: wordSpacing, preferredFontName: runFontName);
state.AdvanceY(lineHeight);
}
s_overrideWidths = null;
@@ -1924,24 +1935,7 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
// wrapping is now governed by the right-edge check during run rendering,
// and the overflow-tolerance heuristic prevents spurious multi-line wraps
// for short equation/overlay paragraphs.)
- // Render paragraph borders
- if (paragraph.Borders != null && state.CurrentPage != null)
- {
- var bdr = paragraph.Borders;
- var paraLeft = options.MarginLeft + paragraph.IndentLeft;
- var paraRight = options.MarginLeft + state.UsableWidth - paragraph.IndentRight;
- var paraTop = paragraphStartY;
- var paraBottom = state.CurrentY;
-
- if (bdr.Top != null)
- state.CurrentPage.AddLine(paraLeft, paraTop, paraRight, paraTop, bdr.Top.Color, bdr.Top.Width);
- if (bdr.Bottom != null)
- state.CurrentPage.AddLine(paraLeft, paraBottom, paraRight, paraBottom, bdr.Bottom.Color, bdr.Bottom.Width);
- if (bdr.Left != null)
- state.CurrentPage.AddLine(paraLeft, paraTop, paraLeft, paraBottom, bdr.Left.Color, bdr.Left.Width);
- if (bdr.Right != null)
- state.CurrentPage.AddLine(paraRight, paraTop, paraRight, paraBottom, bdr.Right.Color, bdr.Right.Width);
- }
+ RenderParagraphBorders(state, paragraph, paragraphStartY, state.CurrentY);
// Render text box border (outline rectangle around text box content)
if (paragraph.TextBoxBorder is { } tb && state.CurrentPage != null)
@@ -2003,6 +1997,32 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph,
}
+ private static void RenderParagraphBorders(RenderState state, DocxParagraph paragraph, float paragraphTop, float paragraphBottom,
+ bool isEmptyParagraph = false)
+ {
+ if (paragraph.Borders == null || state.CurrentPage == null)
+ return;
+
+ var borders = paragraph.Borders;
+ var paragraphLeft = state.Options.MarginLeft + paragraph.IndentLeft;
+ var paragraphRight = state.Options.MarginLeft + state.UsableWidth - paragraph.IndentRight;
+ var topSpace = isEmptyParagraph ? 0f : borders.Top?.Space ?? 0f;
+ var bottomSpace = isEmptyParagraph ? 0f : borders.Bottom?.Space ?? 0f;
+
+ if (borders.Top != null)
+ state.CurrentPage.AddLine(paragraphLeft - borders.Top.Space, paragraphTop + topSpace,
+ paragraphRight + borders.Top.Space, paragraphTop + topSpace, borders.Top.Color, borders.Top.Width);
+ if (borders.Bottom != null)
+ state.CurrentPage.AddLine(paragraphLeft - borders.Bottom.Space, paragraphBottom - bottomSpace,
+ paragraphRight + borders.Bottom.Space, paragraphBottom - bottomSpace, borders.Bottom.Color, borders.Bottom.Width);
+ if (borders.Left != null)
+ state.CurrentPage.AddLine(paragraphLeft - borders.Left.Space, paragraphTop + borders.Left.Space,
+ paragraphLeft - borders.Left.Space, paragraphBottom - borders.Left.Space, borders.Left.Color, borders.Left.Width);
+ if (borders.Right != null)
+ state.CurrentPage.AddLine(paragraphRight + borders.Right.Space, paragraphTop + borders.Right.Space,
+ paragraphRight + borders.Right.Space, paragraphBottom - borders.Right.Space, borders.Right.Color, borders.Right.Width);
+ }
+
///
/// Renders floating text boxes (wrapNone) at their absolute page positions.
/// These text boxes do not affect the normal document flow.
@@ -3304,7 +3324,7 @@ private static void RenderShapeGeometry(PdfPage page, float x, float y, float wi
// ── Image rendering ─────────────────────────────────────────────────
- private static void RenderImage(RenderState state, DocxImage image, string alignment = "left")
+ private static void RenderImage(RenderState state, DocxImage image, string alignment = "left", float inlineVerticalOffset = 0)
{
const float emuPerPoint = 914400f / 72f;
@@ -3392,7 +3412,10 @@ private static void RenderImage(RenderState state, DocxImage image, string align
x = state.Options.MarginLeft + (state.UsableWidth - width) / 2;
else if (alignment == "right")
x = state.Options.MarginLeft + state.UsableWidth - width;
- var y = state.CurrentY - height;
+ var y = state.CurrentY - height + inlineVerticalOffset;
+ var maximumY = state.Options.PageHeight - state.Options.MarginTop - height;
+ if (y > maximumY)
+ y = maximumY;
state.CurrentPage!.AddImage(image.Data, format, x, y, width, height);
state.AdvanceY(height + 1f); // 1pt gap after image
@@ -4775,6 +4798,15 @@ private static bool IsLargeCjkTitle(DocxParagraph paragraph)
return false;
}
+ private static float GetHeadingBaselineOffset(DocxParagraph paragraph, string? fontName, bool bold)
+ {
+ if (!bold || string.IsNullOrEmpty(fontName)
+ || !fontName.Contains("Calibri", StringComparison.OrdinalIgnoreCase))
+ return 0f;
+
+ return paragraph.StyleId is "Heading1" or "Heading2" ? 1.6f : 0f;
+ }
+
private static bool ContainsCjk(string text)
{
foreach (var ch in text)
diff --git a/src/MiniPdf/PdfWriter.cs b/src/MiniPdf/PdfWriter.cs
index 2f3291a0..1c3976bd 100644
--- a/src/MiniPdf/PdfWriter.cs
+++ b/src/MiniPdf/PdfWriter.cs
@@ -96,8 +96,7 @@ internal void Write(PdfDocument document)
// Exclude common Latin fonts (Calibri, Arial, etc.) that are adequately
// substituted by the built-in Helvetica — keeping those in the WinAnsi
// (direct ASCII) encoding path so text content remains inspectable.
- if (!blockNeedsUnicode && !string.IsNullOrWhiteSpace(block.PreferredFontName)
- && !_latinFontSubstitutes.Contains(block.PreferredFontName!)
+ if (!blockNeedsUnicode && ShouldEmbedPreferredFont(block)
&& FindSystemFontByPreferredName(block.PreferredFontName!) != null)
blockNeedsUnicode = true;
if (blockNeedsUnicode)
@@ -132,7 +131,7 @@ internal void Write(PdfDocument document)
foreach (var block in page.TextBlocks)
{
if (string.IsNullOrWhiteSpace(block.PreferredFontName)) continue;
- if (_latinFontSubstitutes.Contains(block.PreferredFontName!)
+ if (!ShouldEmbedPreferredFont(block)
&& !cpsByPreferredFont.ContainsKey(block.PreferredFontName!)) continue;
bool allWinAnsi = true;
foreach (var ch in block.Text)
@@ -2478,6 +2477,15 @@ private static List FindSystemFontCandidates()
"Segoe UI", "Segoe UI Light", "Segoe UI Semibold",
};
+ private static bool ShouldEmbedPreferredFont(PdfTextBlock block)
+ {
+ if (string.IsNullOrWhiteSpace(block.PreferredFontName))
+ return false;
+
+ return !_latinFontSubstitutes.Contains(block.PreferredFontName)
+ || (block.Bold && string.Equals(block.PreferredFontName, "Calibri", StringComparison.OrdinalIgnoreCase));
+ }
+
///
/// Lazily-built cache: normalized font name → system font file path.
/// Built on first access by scanning the system fonts directory and reading
@@ -2981,6 +2989,7 @@ private static (string family, string fullName) ReadFontNames(byte[] ttf, int ba
{
["Times New Roman"] = ["times.ttf"],
["Calibri"] = ["calibri.ttf"],
+ ["Calibri Bold"] = ["calibrib.ttf"],
["Cambria"] = ["cambria.ttc"],
["Courier New"] = ["cour.ttf"],
["Verdana"] = ["verdana.ttf"],
diff --git a/tests/MiniPdf.Benchmark/reports_docx/comparison_report.json b/tests/MiniPdf.Benchmark/reports_docx/comparison_report.json
index 41a900ca..a548a46d 100644
--- a/tests/MiniPdf.Benchmark/reports_docx/comparison_report.json
+++ b/tests/MiniPdf.Benchmark/reports_docx/comparison_report.json
@@ -987,20 +987,20 @@
"name": "docx_classic34_employee_directory_with_photo",
"minipdf_exists": true,
"reference_exists": true,
- "minipdf_size": 422597,
+ "minipdf_size": 975599,
"reference_size": 99625,
"pdf_valid": true,
- "minipdf_pages": 1,
+ "minipdf_pages": 2,
"reference_pages": 2,
"text_similarity": 1.0,
"flat_text_similarity": 1.0,
"word_text_similarity": 1.0,
- "text_diff": "--- minipdf/docx_classic34_employee_directory_with_photo.pdf\n+++ reference/docx_classic34_employee_directory_with_photo.pdf\n@@ -10,4 +10,5 @@\n Carol Williams\n\n UX Designer\n\n Email: carol@company.com\n\n+---PAGE---\n\n Department: Design",
+ "text_diff": "(identical)",
"visual_scores": [
- 0.9758,
- 0.0
+ 0.995,
+ 0.9987
],
- "visual_avg": 0.4879,
+ "visual_avg": 0.9969,
"diff_images": [
{
"page": 1,
@@ -1009,11 +1009,11 @@
},
{
"page": 2,
- "minipdf_img": null,
+ "minipdf_img": "docx_classic34_employee_directory_with_photo_p2_minipdf.png",
"reference_img": "docx_classic34_employee_directory_with_photo_p2_reference.png"
}
],
- "overall_score": 0.6952
+ "overall_score": 0.9988
},
{
"name": "docx_classic34_paragraph_borders",
diff --git a/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p1_minipdf.png b/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p1_minipdf.png
index 7b8069b3..33bcdfb6 100644
Binary files a/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p1_minipdf.png and b/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p1_minipdf.png differ
diff --git a/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p2_minipdf.png b/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p2_minipdf.png
new file mode 100644
index 00000000..2d201c79
Binary files /dev/null and b/tests/MiniPdf.Benchmark/reports_docx/images/docx_classic34_employee_directory_with_photo_p2_minipdf.png differ
diff --git a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs
index 8fa8f511..8c1f92dd 100644
--- a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs
+++ b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs
@@ -88,6 +88,41 @@ public void Convert_ManyParagraphs_CreatesMultiplePages()
Assert.True(doc.Pages.Count >= 2, $"Expected at least 2 pages, got {doc.Pages.Count}");
}
+ [Fact]
+ public void Convert_FirstParagraphSpacingBefore_PreservesFullOffset()
+ {
+ using var unspacedStream = CreateDocxWithFirstParagraphSpacingBefore(0);
+ using var spacedStream = CreateDocxWithFirstParagraphSpacingBefore(480);
+
+ var unspaced = DocxToPdfConverter.Convert(unspacedStream);
+ var spaced = DocxToPdfConverter.Convert(spacedStream);
+ var unspacedY = Assert.Single(unspaced.Pages[0].TextBlocks).Y;
+ var spacedY = Assert.Single(spaced.Pages[0].TextBlocks).Y;
+
+ Assert.InRange(unspacedY - spacedY, 23.9f, 24.1f);
+ }
+
+ [Fact]
+ public void Convert_EmptyBorderedParagraph_PreservesItsSpacingBefore()
+ {
+ using var unspacedStream = CreateDocxWithEmptyBorderedParagraph(0);
+ using var spacedStream = CreateDocxWithEmptyBorderedParagraph(120);
+
+ var unspaced = DocxToPdfConverter.Convert(unspacedStream);
+ var spaced = DocxToPdfConverter.Convert(spacedStream);
+ var unspacedAfterY = unspaced.Pages[0].TextBlocks.Single(block => block.Text == "After").Y;
+ var spacedAfterY = spaced.Pages[0].TextBlocks.Single(block => block.Text == "After").Y;
+
+ Assert.InRange(unspacedAfterY - spacedAfterY, 5.9f, 6.1f);
+ var spacedLine = Assert.Single(spaced.Pages[0].LineBlocks);
+
+ using var zeroBorderSpaceStream = CreateDocxWithEmptyBorderedParagraph(120, borderSpacePt: 0);
+ var zeroBorderSpace = DocxToPdfConverter.Convert(zeroBorderSpaceStream);
+ var zeroBorderSpaceLine = Assert.Single(zeroBorderSpace.Pages[0].LineBlocks);
+ Assert.InRange(spacedLine.Y1 - zeroBorderSpaceLine.Y1, -0.1f, 0.1f);
+ Assert.InRange(zeroBorderSpaceLine.X1 - spacedLine.X1, 0.9f, 1.1f);
+ }
+
[Fact]
public void Convert_WithTable_RendersCellText()
{
@@ -264,6 +299,20 @@ public void Convert_DocxWithPngImage_ProducesValidXrefOffsets()
AssertXrefOffsetsAreCorrect(bytes);
}
+ [Fact]
+ public void Convert_ImageOnlyParagraph_AlignsImageToParagraphBaseline()
+ {
+ using var docxStream = CreateDocxWithPngImage();
+
+ var doc = DocxToPdfConverter.Convert(docxStream);
+ var page = Assert.Single(doc.Pages);
+ var beforeImage = page.TextBlocks.Single(block => block.Text == "Before image");
+ var image = Assert.Single(page.ImageBlocks);
+ var imageTop = image.Y + image.RenderHeight;
+
+ Assert.InRange(beforeImage.Y - imageTop, 2.9f, 3.1f);
+ }
+
[Fact]
public void Convert_DocxWithRootRelativeImageRelationship_RendersImage()
{
@@ -681,6 +730,104 @@ private static MemoryStream CreateSimpleDocx(params string[] paragraphs)
return ms;
}
+ private static MemoryStream CreateDocxWithFirstParagraphSpacingBefore(int beforeTwips)
+ {
+ var ms = new MemoryStream();
+
+ using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ AddEntry(archive, "[Content_Types].xml",
+ """
+
+
+
+
+
+
+ """);
+
+ AddEntry(archive, "_rels/.rels",
+ """
+
+
+
+
+ """);
+
+ AddEntry(archive, "word/document.xml",
+ $$"""
+
+
+
+
+
+ First paragraph
+
+
+
+ """);
+ }
+
+ ms.Position = 0;
+ return ms;
+ }
+
+ private static MemoryStream CreateDocxWithEmptyBorderedParagraph(int beforeTwips, int lineTwips = 276, int borderSpacePt = 1)
+ {
+ var ms = new MemoryStream();
+
+ using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
+ {
+ AddEntry(archive, "[Content_Types].xml",
+ """
+
+
+
+
+
+
+ """);
+
+ AddEntry(archive, "_rels/.rels",
+ """
+
+
+
+
+ """);
+
+ AddEntry(archive, "word/document.xml",
+ $$"""
+
+
+
+ Before
+
+
+
+
+
+
+ After
+
+
+ """);
+
+ AddEntry(archive, "word/styles.xml",
+ $$"""
+
+
+
+
+
+
+ """);
+ }
+
+ ms.Position = 0;
+ return ms;
+ }
+
private static MemoryStream CreateDocxWithTable(params string[][] rows)
{
var ms = new MemoryStream();