From a647db4797e6ab26a94fc66858267d0d510c7087 Mon Sep 17 00:00:00 2001 From: Michael Chang Date: Sat, 5 Sep 2026 20:26:19 +0800 Subject: [PATCH 1/3] fix: move oversized inline image to next page in DOCX rendering An inline image that did not fit in the remaining page space was clipped at the page bottom and lost, because RenderImage called EnsurePage(), whose guard only adds a page once the cursor is already past the bottom margin. Force the break when the image would cross the margin so the whole image moves to the next page, matching Word/LibreOffice. Skip when already at the top of a fresh page so an image taller than the usable area still overflows instead of emitting a blank page. Benchmark: docx_classic30_comprehensive_report overall 0.9086 -> 0.9888, visual 0.7759 -> 0.9765 (page 3). Full .NET suite 185/185; XLSX+DOCX visual regression gate 0 regressions. --- src/MiniPdf/DocxToPdfConverter.cs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs index 27cc55ca..ca14b50e 100644 --- a/src/MiniPdf/DocxToPdfConverter.cs +++ b/src/MiniPdf/DocxToPdfConverter.cs @@ -3362,9 +3362,15 @@ private static void RenderImage(RenderState state, DocxImage image, string align height *= scale; } - // Check if image fits on current page - if (state.CurrentY - height < state.Options.MarginBottom) - state.EnsurePage(); + // Move the whole image to the next page when it does not fit in the + // remaining space. Word keeps an inline image on a single page rather + // than clipping it at the page bottom. EnsurePage only adds a page once + // the cursor is already past the bottom margin, which leaves a partial + // image clipped, so force the break here instead. Skip when already at + // the top of a fresh page, otherwise an image taller than the usable + // area would push out a blank page ahead of it (Word overflows there). + if (state.CurrentY - height < state.Options.MarginBottom && !state.IsTopOfPage) + state.ForceNewPage(); var x = state.Options.MarginLeft; if (image.IsWrapTopBottom) From 2453813d24742390d72c577e8ef2f86c09036113 Mon Sep 17 00:00:00 2001 From: Michael Chang Date: Sat, 5 Sep 2026 21:07:40 +0800 Subject: [PATCH 2/3] test: cover inline image page-break in DOCX rendering Add a regression test asserting an inline image that does not fit in the remaining page space moves wholesale to the next page instead of being clipped at the page bottom. Fails against the pre-fix EnsurePage() behavior. --- .../MiniPdf.Tests/DocxToPdfConverterTests.cs | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs index d117dd5e..8fa8f511 100644 --- a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs +++ b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs @@ -274,6 +274,44 @@ public void Convert_DocxWithRootRelativeImageRelationship_RendersImage() Assert.Contains(doc.Pages, page => page.ImageBlocks.Count > 0); } + [Fact] + public void Convert_InlineImageThatDoesNotFit_MovesToNextPageWithoutClipping() + { + // Small page so a few filler lines plus a moderate image cannot share one page. + var options = new DocxToPdfConverter.ConversionOptions + { + PageWidth = 400, + PageHeight = 400, + MarginTop = 40, + MarginBottom = 40, + MarginLeft = 40, + MarginRight = 40, + }; + // ~260pt image: fits on a fresh page (usable height 320) but not once the + // filler lines have consumed most of page 1. + const long imageEmu = 260L * 12700L; + using var docxStream = CreateDocxWithFillerThenTallImage( + fillerParagraphs: 10, imageCxEmu: imageEmu, imageCyEmu: imageEmu); + + var doc = DocxToPdfConverter.Convert(docxStream, options); + + var placed = doc.Pages + .Select((page, index) => (page, index)) + .SelectMany(entry => entry.page.ImageBlocks.Select(block => (entry.index, block))) + .ToList(); + Assert.Single(placed); + var (pageIndex, image) = placed[0]; + + // The whole image must move to a later page rather than being clipped at the + // bottom of page 1 (the behavior this fix restores). + Assert.True(pageIndex >= 1, $"Expected the image on a page after the first; got page {pageIndex + 1}."); + // And it must sit fully inside the printable area, not off the bottom edge. + Assert.True(image.Y >= options.MarginBottom, + $"Image bottom {image.Y} is below the bottom margin {options.MarginBottom} (clipped)."); + Assert.True(image.Y + image.RenderHeight <= options.PageHeight - options.MarginTop + 0.5f, + $"Image top {image.Y + image.RenderHeight} exceeds the printable area."); + } + private static void AssertXrefOffsetsAreCorrect(byte[] pdfBytes) { var text = Encoding.GetEncoding("iso-8859-1").GetString(pdfBytes); @@ -408,6 +446,103 @@ private static MemoryStream CreateDocxWithPngImage( return ms; } + /// + /// Builds a DOCX with a run of filler paragraphs followed by a single inline + /// image of the given EMU size, used to exercise page-break handling when the + /// image cannot fit in the remaining space on the current page. + /// + private static MemoryStream CreateDocxWithFillerThenTallImage( + int fillerParagraphs, long imageCxEmu, long imageCyEmu) + { + var ms = new MemoryStream(); + var pngBytes = CreateMinimalRgbaPng(4, 4); + + var filler = string.Concat(Enumerable.Range(0, fillerParagraphs) + .Select(i => $"Filler line {i}")); + + using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "[Content_Types].xml", + """ + + + + + + + + """); + + AddEntry(archive, "_rels/.rels", + """ + + + + + """); + + AddEntry(archive, "word/_rels/document.xml.rels", + """ + + + + + """); + + AddEntry(archive, "word/document.xml", + $$""" + + + + {{filler}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """); + + var imgEntry = archive.CreateEntry("word/media/image1.png"); + using (var imgStream = imgEntry.Open()) + imgStream.Write(pngBytes, 0, pngBytes.Length); + } + + ms.Position = 0; + return ms; + } + /// Creates a minimal valid RGBA PNG file (with alpha channel). private static byte[] CreateMinimalRgbaPng(int width, int height) { From 3da0da2d71f1ab55ef7c7f1c9bcb7b994e396514 Mon Sep 17 00:00:00 2001 From: Michael Chang Date: Sat, 5 Sep 2026 21:38:17 +0800 Subject: [PATCH 3/3] fix: flow inline image to next column before forcing a page break The previous fix always called ForceNewPage() when an inline image did not fit in the remaining space. In a multi-column section that skipped the remaining columns and broke the page prematurely. Mirror EnsurePage()'s column handling: advance to the next column first, and only force a new page when no column remains. --- src/MiniPdf/DocxToPdfConverter.cs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs index ca14b50e..2c2f5ed5 100644 --- a/src/MiniPdf/DocxToPdfConverter.cs +++ b/src/MiniPdf/DocxToPdfConverter.cs @@ -3362,15 +3362,20 @@ private static void RenderImage(RenderState state, DocxImage image, string align height *= scale; } - // Move the whole image to the next page when it does not fit in the - // remaining space. Word keeps an inline image on a single page rather - // than clipping it at the page bottom. EnsurePage only adds a page once - // the cursor is already past the bottom margin, which leaves a partial - // image clipped, so force the break here instead. Skip when already at - // the top of a fresh page, otherwise an image taller than the usable - // area would push out a blank page ahead of it (Word overflows there). + // Move the whole image to the next column/page when it does not fit in + // the remaining space. Word keeps an inline image intact rather than + // clipping it at the bottom. EnsurePage only adds a page once the cursor + // is already past the bottom margin, which leaves a partial image + // clipped, so handle the break here. In a multi-column section flow to + // the next column first (as EnsurePage does); only force a new page when + // no column remains. Skip entirely when already at the top of a fresh + // column/page, otherwise an image taller than the usable area would push + // out a blank column/page ahead of it (Word overflows there). if (state.CurrentY - height < state.Options.MarginBottom && !state.IsTopOfPage) - state.ForceNewPage(); + { + if (!(state.ColumnCount > 1 && state.AdvanceToNextColumn())) + state.ForceNewPage(); + } var x = state.Options.MarginLeft; if (image.IsWrapTopBottom)