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
17 changes: 14 additions & 3 deletions src/MiniPdf/DocxToPdfConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@
// Calibri widths can under-wrap and reduce page count.
if (!string.IsNullOrWhiteSpace(docxDoc.DefaultFontName))
{
options.UseCalibriWidths = docxDoc.DefaultFontName.Contains("Calibri", StringComparison.OrdinalIgnoreCase);

Check warning on line 176 in src/MiniPdf/DocxToPdfConverter.cs

View workflow job for this annotation

GitHub Actions / build

Possible null reference argument for parameter 's' in 'bool NetFxPolyfills.Contains(string s, string value, StringComparison comparison)'.
s_serifFont = IsSerifFont(docxDoc.DefaultFontName);
s_defaultFontName = docxDoc.DefaultFontName;
}
Expand Down Expand Up @@ -3362,9 +3362,20 @@
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 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)
{
if (!(state.ColumnCount > 1 && state.AdvanceToNextColumn()))
state.ForceNewPage();
}

var x = state.Options.MarginLeft;
if (image.IsWrapTopBottom)
Expand Down
135 changes: 135 additions & 0 deletions tests/MiniPdf.Tests/DocxToPdfConverterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -408,6 +446,103 @@ private static MemoryStream CreateDocxWithPngImage(
return ms;
}

/// <summary>
/// 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.
/// </summary>
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 => $"<w:p><w:r><w:t>Filler line {i}</w:t></w:r></w:p>"));

using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
AddEntry(archive, "[Content_Types].xml",
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>
""");

AddEntry(archive, "_rels/.rels",
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>
""");

AddEntry(archive, "word/_rels/document.xml.rels",
"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId10" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>
</Relationships>
""");

AddEntry(archive, "word/document.xml",
$$"""
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
<w:body>
{{filler}}
<w:p>
<w:r>
<w:drawing>
<wp:inline distT="0" distB="0" distL="0" distR="0">
<wp:extent cx="{{imageCxEmu}}" cy="{{imageCyEmu}}"/>
<wp:docPr id="1" name="Picture 1"/>
<a:graphic>
<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
<pic:pic>
<pic:nvPicPr>
<pic:cNvPr id="1" name="image1.png"/>
<pic:cNvPicPr/>
</pic:nvPicPr>
<pic:blipFill>
<a:blip r:embed="rId10"/>
<a:stretch><a:fillRect/></a:stretch>
</pic:blipFill>
<pic:spPr>
<a:xfrm>
<a:off x="0" y="0"/>
<a:ext cx="{{imageCxEmu}}" cy="{{imageCyEmu}}"/>
</a:xfrm>
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
</pic:spPr>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>
</w:body>
</w:document>
""");

var imgEntry = archive.CreateEntry("word/media/image1.png");
using (var imgStream = imgEntry.Open())
imgStream.Write(pngBytes, 0, pngBytes.Length);
}

ms.Position = 0;
return ms;
}

/// <summary>Creates a minimal valid RGBA PNG file (with alpha channel).</summary>
private static byte[] CreateMinimalRgbaPng(int width, int height)
{
Expand Down
Loading