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