diff --git a/src/MiniPdf/DocxReader.cs b/src/MiniPdf/DocxReader.cs index 31d9c9a8..cd2f28ed 100644 --- a/src/MiniPdf/DocxReader.cs +++ b/src/MiniPdf/DocxReader.cs @@ -4030,12 +4030,32 @@ private static List ReadHeaderFooterElementsFromSectPr( } } + // Text area of the box: extent minus bodyPr insets (OOXML default 0.1in left/right). + const float emuPerPt = 12700f; + var bodyPr = anchor.Descendants(WPS + "bodyPr").FirstOrDefault(); + var lIns = (long.TryParse(bodyPr?.Attribute("lIns")?.Value, out var li) ? li : 91440) / emuPerPt; + var rIns = (long.TryParse(bodyPr?.Attribute("rIns")?.Value, out var ri) ? ri : 91440) / emuPerPt; + var boxWidth = long.TryParse(anchor.Element(WP + "extent")?.Attribute("cx")?.Value, out var cx) ? cx / emuPerPt : 0f; + // Only column/margin-relative offsets map onto the margin-based header layout; + // page-relative and aligned boxes keep the inferred-alignment path above. + long offEmu = 0; + var hasMarginOffset = alignment == null + && posH?.Attribute("relativeFrom")?.Value is "column" or "margin" + && long.TryParse(posH.Element(WP + "posOffset")?.Value, out offEmu); + var boxOffset = offEmu / emuPerPt; + foreach (var txbxP in txbxContent.Elements(W + "p")) { var txbxPara = ReadParagraph(txbxP, styles, numbering, hfRels, archive, null, defaultFontName, defaultEastAsiaFontName); if (txbxPara == null) continue; if (alignment != null) txbxPara = txbxPara with { Alignment = alignment }; + if (hasMarginOffset && boxWidth > lIns + rIns) + txbxPara = txbxPara with + { + IndentLeft = boxOffset + lIns + txbxPara.IndentLeft, + TextBoxWidth = boxWidth - lIns - rIns - txbxPara.IndentLeft - txbxPara.IndentRight, + }; elements.Add(txbxPara); } } diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs index 25f8a22d..d1c7d402 100644 --- a/src/MiniPdf/DocxToPdfConverter.cs +++ b/src/MiniPdf/DocxToPdfConverter.cs @@ -255,7 +255,7 @@ internal static PdfDocument Convert(Stream docxStream, ConversionOptions? option // Adjust top margin when header content is taller than the default header area if (!options.MarginTopOverride.HasValue && docxDoc.HeaderElements is { Count: > 0 }) { - var headerContentHeight = EstimateElementsHeight(TrimTrailingEmptyParagraphs(docxDoc.HeaderElements), options); + var headerContentHeight = EstimateElementsHeight(TrimTrailingEmptyParagraphs(docxDoc.HeaderElements), options, headerFooter: true); var headerAreaHeight = options.MarginTop - options.HeaderMargin; if (headerAreaHeight < 0) headerAreaHeight = 0; // header starts below marginTop if (headerContentHeight > headerAreaHeight) @@ -270,7 +270,7 @@ internal static PdfDocument Convert(Stream docxStream, ConversionOptions? option // Adjust bottom margin when footer content is taller than the default footer area if (!options.MarginBottomOverride.HasValue && docxDoc.FooterElements is { Count: > 0 }) { - var footerContentHeight = EstimateElementsHeight(TrimTrailingEmptyParagraphs(docxDoc.FooterElements), options); + var footerContentHeight = EstimateElementsHeight(TrimTrailingEmptyParagraphs(docxDoc.FooterElements), options, headerFooter: true); var footerTopFromBottom = options.FooterMargin + footerContentHeight; if (footerTopFromBottom > options.MarginBottom) options.MarginBottom = footerTopFromBottom; @@ -535,7 +535,7 @@ internal static PdfDocument Convert(Stream docxStream, ConversionOptions? option || (e is DocxParagraph pp && (pp.Runs.Any(r => !string.IsNullOrWhiteSpace(r.Text)) || pp.Images.Count > 0)))) { var trimmedFooter = TrimTrailingEmptyParagraphs(pageFooterElements); - var footerContentHeight = EstimateElementsHeight(trimmedFooter, options); + var footerContentHeight = EstimateElementsHeight(trimmedFooter, options, headerFooter: true); var footerStartY = options.FooterMargin + footerContentHeight; RenderHeaderFooterElementsOnPage(page, options, trimmedFooter, footerStartY, pi, totalPages, sectionPageNum); } @@ -3530,7 +3530,7 @@ private static List TrimTrailingEmptyParagraphs(List e /// Estimates the total height of a list of elements (paragraphs + tables) /// for header/footer sizing calculations. /// - private static float EstimateElementsHeight(List elements, ConversionOptions options) + private static float EstimateElementsHeight(List elements, ConversionOptions options, bool headerFooter = false) { const float emuPerPt = 914400f / 72f; float totalHeight = 0; @@ -3550,8 +3550,11 @@ private static float EstimateElementsHeight(List elements, Conversi totalHeight += gap; } - var fontSize = para.FontSize > 0 ? para.FontSize : options.FontSize; - var estFont = para.Runs.FirstOrDefault(r => !string.IsNullOrEmpty(r.FontName))?.FontName; + var firstRun = headerFooter ? para.Runs.FirstOrDefault(r => !string.IsNullOrEmpty(r.Text)) : null; + var fontSize = firstRun?.FontSize > 0 ? firstRun.FontSize + : para.FontSize > 0 ? para.FontSize : options.FontSize; + var estFont = headerFooter ? firstRun?.FontName + : para.Runs.FirstOrDefault(r => !string.IsNullOrEmpty(r.FontName))?.FontName; var lineHeight = ApplyLineSpacingRule( para, fontSize * GetFontMetricsFactor(estFont) * GetLineSpacingMultiple(para, options.LineSpacing)); @@ -3566,7 +3569,16 @@ private static float EstimateElementsHeight(List elements, Conversi } var text = string.Concat(para.Runs.Select(r => r.Text)); if (!string.IsNullOrEmpty(text)) - totalHeight += lineHeight; + { + // Header/footer paragraphs wrap at render time; reserve every line. + // Floating text-box paragraphs (TextBoxWidth > 0) overlay the page and + // must not grow the reserved area, so they keep the one-line estimate. + // Page numbers are unknown here, so placeholders resolve as page 1 of 1. + var lineCount = headerFooter && para.TextBoxWidth <= 0 + ? WrapHeaderFooterText(para, ResolvePagePlaceholders(text, 1, 1), options, usableW).Count + : 1; + totalHeight += lineHeight * lineCount; + } else if (para.Images.Count == 0) totalHeight += lineHeight; @@ -3605,6 +3617,52 @@ private static float EstimateElementsHeight(List elements, Conversi return totalHeight; } + /// + /// Splits a header/footer paragraph into the lines drawn by + /// . Text that fits is kept verbatim + /// because WordWrap normalizes spacing. + /// + private static List WrapHeaderFooterText(DocxParagraph para, string text, ConversionOptions options, float areaWidth) + { + var firstRun = para.Runs.FirstOrDefault(r => !string.IsNullOrEmpty(r.Text)); + var runFontSize = firstRun?.FontSize > 0 ? firstRun.FontSize : (para.FontSize > 0 ? para.FontSize : options.FontSize); + var bold = firstRun?.Bold ?? false; + var useCalibri = options.UseCalibriWidths + && (string.IsNullOrEmpty(firstRun?.FontName) || firstRun.FontName.Contains("Calibri", StringComparison.OrdinalIgnoreCase)); + s_overrideWidths = GetFontOverrideWidths(firstRun?.FontName); + var lines = EstimateWrapTextWidth(text, runFontSize, bold, 0, useCalibri) <= areaWidth + ? [text] + : WordWrap(text, areaWidth, areaWidth, runFontSize, para.TabStops, bold, useCalibriWidths: useCalibri); + var result = new List(); + foreach (var line in lines) + { + if (EstimateWrapTextWidth(line, runFontSize, bold, 0, useCalibri) <= areaWidth) + { + result.Add(line); + continue; + } + + var segment = ""; + var elements = System.Globalization.StringInfo.GetTextElementEnumerator(line); + while (elements.MoveNext()) + { + var next = elements.GetTextElement(); + if (segment.Length > 0 && Math.Max( + EstimateWrapTextWidth(segment + next, runFontSize, bold, 0, useCalibri), + EstimateTextWidth(segment + next, runFontSize)) > areaWidth) + { + result.Add(segment); + segment = ""; + } + segment += next; + } + if (segment.Length > 0) + result.Add(segment); + } + s_overrideWidths = null; + return result; + } + /// /// Renders header/footer elements (paragraphs + tables) directly on a page /// at the specified starting Y position, flowing downward. @@ -3692,17 +3750,25 @@ private static void RenderHeaderFooterElementsOnPage(PdfPage page, ConversionOpt para, runFontSize * GetFontMetricsFactor(firstRun?.FontName) * GetLineSpacingMultiple(para, options.LineSpacing)); - var textWidth = EstimateTextWidth(text, runFontSize); - var textX = para.Alignment switch + // Text-box paragraphs carry their own text area; others use the margins. + var areaLeft = options.MarginLeft + (para.TextBoxWidth > 0 ? para.IndentLeft : 0); + var areaWidth = para.TextBoxWidth > 0 ? para.TextBoxWidth : usableW; + var bold = firstRun?.Bold ?? false; + foreach (var line in WrapHeaderFooterText(para, text, options, areaWidth)) { - "center" => options.MarginLeft + (usableW - textWidth) / 2, - "right" => options.MarginLeft + usableW - textWidth, - _ => options.MarginLeft - }; - y -= runFontSize; - page.AddText(text, textX, y, runFontSize, firstRun?.Color ?? para.Color, - bold: firstRun?.Bold ?? false, italic: firstRun?.Italic ?? false, preferredFontName: firstRun?.FontName); - y -= lineHeight - runFontSize; + var textWidth = EstimateTextWidth(line, runFontSize); + var textX = para.Alignment switch + { + "center" => areaLeft + (areaWidth - textWidth) / 2, + "right" => areaLeft + areaWidth - textWidth, + _ => areaLeft + }; + y -= runFontSize; + page.AddText(line, textX, y, runFontSize, firstRun?.Color ?? para.Color, + maxWidth: para.TextBoxWidth > 0 ? areaWidth : null, + bold: bold, italic: firstRun?.Italic ?? false, preferredFontName: firstRun?.FontName); + y -= lineHeight - runFontSize; + } } lastSpacingAfter = para.SpacingAfter >= 0 ? para.SpacingAfter : 0f; isFirst = false; diff --git a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs index 8c1f92dd..8fbadb97 100644 --- a/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs +++ b/tests/MiniPdf.Tests/DocxToPdfConverterTests.cs @@ -679,6 +679,210 @@ private static uint ComputeCrc32(byte[] data) return crc ^ 0xFFFFFFFF; } + [Fact] + public void Convert_HeaderTextBox_WrapsTextWithinBox() + { + var longText = string.Concat(Enumerable.Repeat("個資蒐集處理及使用聲明", 20)); + using var docxStream = CreateDocxWithHeaderTextBox(longText, offsetEmu: -457200, widthEmu: 5080000); + + var doc = DocxToPdfConverter.Convert(docxStream); + var lines = doc.Pages[0].TextBlocks.Where(b => b.Text != "Body").ToList(); + + // 400pt box minus 7.2pt insets, 72pt margin, -36pt column offset + Assert.True(lines.Count > 1, "header text box paragraph should wrap"); + Assert.Equal(longText, string.Concat(lines.Select(l => l.Text))); + Assert.All(lines, l => Assert.Equal(72f - 36f + 7.2f, l.X, 0.01f)); + Assert.All(lines, l => Assert.True(l.Text.Length * l.FontSize <= 400f - 14.4f + 0.01f)); + } + + [Fact] + public void Convert_HeaderTextBox_SplitsOversizedUnbreakableWord() + { + var longText = new string('W', 180); + using var docxStream = CreateDocxWithHeaderTextBox(longText, offsetEmu: -457200, widthEmu: 5080000); + + var doc = DocxToPdfConverter.Convert(docxStream); + var lines = doc.Pages[0].TextBlocks.Where(b => b.Text != "Body").ToList(); + + Assert.True(lines.Count > 1, "oversized word should wrap"); + Assert.Equal(longText, string.Concat(lines.Select(l => l.Text))); + Assert.All(lines, l => Assert.True(l.Text.Length * l.FontSize * 0.9f <= 385.6f)); + Assert.All(lines, l => Assert.True(l.MaxWidth.HasValue && Math.Abs(l.MaxWidth.Value - 385.6f) < 0.01f)); + } + + [Fact] + public void Convert_WrappedFooterParagraph_StaysAboveFooterMargin() + { + var longText = string.Join(" ", Enumerable.Repeat("Footer notice text", 30)); + using var docxStream = CreateDocxWithFooter(longText, footerTwips: 360); + + var doc = DocxToPdfConverter.Convert(docxStream); + var lines = doc.Pages[0].TextBlocks.Where(b => b.Text != "Body").ToList(); + + // Every wrapped line must fit in the reserved footer area, above the 18pt footer margin. + Assert.True(lines.Count > 1, "footer paragraph should wrap"); + Assert.All(lines, l => Assert.True(l.Y >= 18f, $"footer line at y={l.Y} is below the footer margin")); + } + + [Fact] + public void Convert_WrappedFooterWithRunFontSize_StaysAboveFooterMargin() + { + var longText = string.Join(" ", Enumerable.Repeat("Footer notice text", 30)); + using var docxStream = CreateDocxWithFooter(longText, footerTwips: 360, runHalfPoints: 36); + + var doc = DocxToPdfConverter.Convert(docxStream); + var lines = doc.Pages[0].TextBlocks.Where(b => b.Text != "Body").ToList(); + + Assert.True(lines.Count > 1, "footer paragraph should wrap"); + Assert.All(lines, l => Assert.True(l.Y >= 18f, $"footer line at y={l.Y} is below the footer margin")); + } + + private static MemoryStream CreateDocxWithFooter(string text, int footerTwips, int? runHalfPoints = null) + { + var ms = new MemoryStream(); + var runProperties = runHalfPoints.HasValue ? $"" : ""; + + 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", + $""" + + + + Body + + + + + + + + """); + + AddEntry(archive, "word/footer1.xml", + $""" + + + {runProperties}{EscapeXml(text)} + + """); + } + + ms.Position = 0; + return ms; + } + + private static MemoryStream CreateDocxWithHeaderTextBox(string text, long offsetEmu, long widthEmu) + { + 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/_rels/document.xml.rels", + """ + + + + + """); + + AddEntry(archive, "word/document.xml", + """ + + + + Body + + + + + + + + """); + + AddEntry(archive, "word/header1.xml", + $""" + + + + + + {offsetEmu} + 0 + + + + + + + + {EscapeXml(text)} + + + + + + + + """); + } + + ms.Position = 0; + return ms; + } + private static MemoryStream CreateSimpleDocx(params string[] paragraphs) { var ms = new MemoryStream();