diff --git a/src/MiniPdf/DocxReader.cs b/src/MiniPdf/DocxReader.cs index f4469d3e..31d9c9a8 100644 --- a/src/MiniPdf/DocxReader.cs +++ b/src/MiniPdf/DocxReader.cs @@ -255,6 +255,11 @@ internal static DocxDocument Read(Stream stream) } } + /// + /// Reads the opened DOCX package (relationships, styles, numbering, theme + /// colours, footnotes, settings and the body elements) into a + /// . + /// private static DocxDocument ReadCore(ZipArchive archive) { // Read relationships to resolve image references @@ -478,7 +483,22 @@ private static DocxDocument ReadCore(ZipArchive archive) leftInsetPt = lIns / 914400f * 72f; } - if (isWrapNone) + // A wrapTopAndBottom text box whose vertical anchor is relative to the + // page or margin sits at an absolute page position (LibreOffice maps + // relativeFrom="page" to RelOrientation::PAGE_FRAME in + // writerfilter/dmapper/GraphicHelpers.cxx and wrapTopAndBottom to + // WrapTextMode_NONE in writerfilter/dmapper/GraphicImport.cxx). Its + // posOffset is a page coordinate, not a gap before the box, so emitting + // the content as flow paragraphs would insert that offset as bogus + // spacing. Render it as a floating box instead; the converter resumes + // the text flow below the box (see RenderFloatingTextBoxes). + // Every ST_RelFromV value other than paragraph and line (page, margin, + // topMargin, bottomMargin, insideMargin, outsideMargin) is a page-based + // reference; the floating renderer already positions those. + bool isAbsoluteWrapTopBottom = isWrapTopBottom + && vRelativeFrom != "paragraph" && vRelativeFrom != "line"; + + if (isWrapNone || isAbsoluteWrapTopBottom) { // wrapNone text boxes are positioned absolutely and do not // consume space in the main flow per the OOXML spec. @@ -522,7 +542,7 @@ private static DocxDocument ReadCore(ZipArchive archive) if (floatingParas.Count > 0) { floatingTextBoxes ??= new List(); - floatingTextBoxes.Add(new DocxFloatingTextBox(anchorXPt, anchorOffsetPt, extentWidthPt, extentHeightPt, floatingParas, textBoxBorder, hRelativeFrom, vRelativeFrom, textBoxFillColor, topInsetPt, leftInsetPt, hAlign, vAlign)); + floatingTextBoxes.Add(new DocxFloatingTextBox(anchorXPt, anchorOffsetPt, extentWidthPt, extentHeightPt, floatingParas, textBoxBorder, hRelativeFrom, vRelativeFrom, textBoxFillColor, topInsetPt, leftInsetPt, hAlign, vAlign, IsWrapTopBottom: isWrapTopBottom)); } } else @@ -5191,7 +5211,10 @@ internal sealed record DocxConnectorLine( string VRelativeFrom = "paragraph" ); -/// Represents a floating text box (wrapNone) with absolute position. +/// +/// Represents a floating text box with an absolute position: a wrapNone box, or a +/// wrapTopAndBottom box whose vertical anchor is relative to the page or margin. +/// internal sealed record DocxFloatingTextBox( float XPt, float YPt, @@ -5206,7 +5229,10 @@ internal sealed record DocxFloatingTextBox( float LeftInsetPt = 7.2f, string? HAlign = null, string? VAlign = null, - bool AnchorAtParagraphTop = false + bool AnchorAtParagraphTop = false, + // True for a wrapTopAndBottom anchor rendered as a floating box: text may not + // flow beside it, so the converter resumes the flow below the box bottom. + bool IsWrapTopBottom = false ); /// Represents a text box outline border (rectangle drawn around text box content). diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs index bbb51e9f..b2bd0551 100644 --- a/src/MiniPdf/DocxToPdfConverter.cs +++ b/src/MiniPdf/DocxToPdfConverter.cs @@ -637,7 +637,60 @@ private sealed class RenderState /// extra page breaks in long multi-section documents like CCU_article). /// public bool HasRenderedAnyContent { get; set; } - public float LastParagraphStartY { get; set; } + private float _lastParagraphStartY; + /// + /// Start position of the most recent paragraph. Setting it also records + /// , the page that paragraph started on, so + /// floating boxes anchored to a paragraph that ends with a page break are + /// still placed on the page containing the anchor. + /// + public float LastParagraphStartY + { + get => _lastParagraphStartY; + set { _lastParagraphStartY = value; LastParagraphPage = CurrentPage; } + } + public PdfPage? LastParagraphPage { get; private set; } + /// + /// Vertical bands (PDF coordinates, top above bottom) that text may not enter + /// on a page: wrapTopAndBottom boxes. Text resumes below them. + /// + public List<(PdfPage Page, float TopY, float BottomY)>? WrapObstacles { get; set; } + + /// + /// Moves (the next baseline) below every obstacle band on + /// the current page that a line with the given ascent and descent would intersect. + /// + public bool AvoidWrapObstacles(float ascent, float descent) + { + if (WrapObstacles == null || CurrentPage == null) return false; + + var originalPage = CurrentPage; + var originalColumn = CurrentColumn; + while (true) + { + var moved = false; + foreach (var (page, topY, bottomY) in WrapObstacles) + { + if (page != CurrentPage) continue; + if (CurrentY + ascent > bottomY && CurrentY - descent < topY) + { + CurrentY = bottomY - ascent; + IsTopOfPage = false; + moved = true; + } + } + + if (!moved) break; + if (CurrentY - descent >= Options.MarginBottom) continue; + + if (ColumnCount <= 1 || !AdvanceToNextColumn()) + ForceNewPage(); + EnsurePage(); + AdvanceY(ascent); + } + + return CurrentPage != originalPage || CurrentColumn != originalColumn; + } /// /// Captures the paragraph's line-box TOP (before ascent advance) at the /// start of each paragraph. Used as the anchor base for floating images @@ -1158,6 +1211,10 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, : fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)); state.AdvanceY(emptyAscentOffset); } + var emptyLineAscent = options.GridLinePitch > 0 && paragraph.SnapToGrid + ? GetGridAscentOffset(lineHeight, fontSize, paraFontName) + : fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)); + state.AvoidWrapObstacles(emptyLineAscent, Math.Max(0f, totalEmptyAdvance - emptyLineAscent)); RenderParagraphBorders(state, paragraph, state.CurrentY, state.CurrentY, isEmptyParagraph: true); state.AdvanceY(totalEmptyAdvance); // If the empty paragraph pushed past the bottom margin, accumulate @@ -1360,6 +1417,19 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, state.AdvanceY((lineHeight - state.LastLineHeight) * 0.541f); } + // Keep the first line out of any wrapTopAndBottom band on this page. Move the + // captured paragraph top by the same amount so paragraph-relative anchors + // stay attached to the line that actually starts the paragraph. + var firstLineAscent = currentGridAscent > 0 + ? currentGridAscent + : fontSize * GetTopOfPageAscentRatio(paraFontName, ResolveLineSpacingMul(paragraph, options)); + var yBeforeObstacles = state.CurrentY; + var changedFlowContext = state.AvoidWrapObstacles( + firstLineAscent, Math.Max(0f, lineHeight - firstLineAscent)); + state.CurrentParagraphTopY = changedFlowContext + ? state.CurrentY + firstLineAscent + : state.CurrentParagraphTopY - (yBeforeObstacles - state.CurrentY); + // Track paragraph start position for borders and floating textboxes var paragraphStartY = state.CurrentY; state.LastParagraphStartY = paragraphStartY; @@ -1600,10 +1670,13 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, // Paragraphs whose only visual content is wrapNone floating textboxes and/or // connector lines (anchored shapes that are absolutely positioned overlays) // should not consume a line height in the main flow, matching Word's behaviour. + // A wrapTopAndBottom box is not an overlay: its empty host paragraph still + // occupies its own line above the box. var isFloatingAnchorOnlyParagraph = paragraph.Runs.Count == 0 && paragraph.Images.Count == 0 && (paragraph.Shapes is null || paragraph.Shapes.Count == 0) + && paragraph.FloatingTextBoxes?.Any(box => box.IsWrapTopBottom) != true && ((paragraph.FloatingTextBoxes is { Count: > 0 }) || (paragraph.ConnectorLines is { Count: > 0 })); // Paragraphs whose only visual content is wrapNone anchor images @@ -1827,13 +1900,13 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, state.ForceNewPage(); } state.EnsurePage(); + var lineAscentOffset = options.GridLinePitch > 0 && paragraph.SnapToGrid + ? GetGridAscentOffset(lineHeight, runFontSize, runFontName) + : runFontSize * GetTopOfPageAscentRatio(runFontName, ResolveLineSpacingMul(paragraph, options)); if (state.IsTopOfPage) - { - var lineAscentOffset = options.GridLinePitch > 0 && paragraph.SnapToGrid - ? GetGridAscentOffset(lineHeight, runFontSize, runFontName) - : runFontSize * GetTopOfPageAscentRatio(runFontName, ResolveLineSpacingMul(paragraph, options)); state.AdvanceY(lineAscentOffset); - } + // Wrapped lines must also stay out of wrapTopAndBottom bands. + state.AvoidWrapObstacles(lineAscentOffset, Math.Max(0f, lineHeight - lineAscentOffset)); var line = lines[i]; var lineX = i == 0 ? firstLineX : x; @@ -1997,6 +2070,11 @@ private static void RenderParagraph(RenderState state, DocxParagraph paragraph, } + /// + /// Draws the paragraph's w:pBdr borders around the box that spans + /// to on the + /// current page. Empty paragraphs drop the top and bottom border spacing. + /// private static void RenderParagraphBorders(RenderState state, DocxParagraph paragraph, float paragraphTop, float paragraphBottom, bool isEmptyParagraph = false) { @@ -2024,13 +2102,17 @@ private static void RenderParagraphBorders(RenderState state, DocxParagraph para } /// - /// Renders floating text boxes (wrapNone) at their absolute page positions. - /// These text boxes do not affect the normal document flow. + /// Renders floating text boxes at their absolute positions on the page the host + /// paragraph started on. wrapNone boxes do not affect the normal document flow; + /// page- or margin-anchored wrapTopAndBottom boxes register their band as a + /// entry so the flow resumes below the box. /// private static void RenderFloatingTextBoxes(RenderState state, List boxes, DocxParagraph hostParagraph, float paragraphY) { - var page = state.CurrentPage; + // A host paragraph that ends with a page break has already moved the flow to + // the next page; anchor the boxes to the page the paragraph started on. + var page = state.LastParagraphPage ?? state.CurrentPage; if (page == null) return; var options = state.Options; var hostPageIdx = -1; @@ -2116,6 +2198,24 @@ private static void RenderFloatingTextBoxes(RenderState state, List(); + state.WrapObstacles.Add((targetPage, boxTop, boxTop - box.HeightPt)); + var nextLineHeight = state.LastLineHeight > 0 + ? state.LastLineHeight + : hostFontSize * GetFontMetricsFactor(hostFontName); + var nextAscent = options.GridLinePitch > 0 && hostParagraph.SnapToGrid + ? GetGridAscentOffset(nextLineHeight, hostFontSize, hostFontName) + : hostFontSize * GetTopOfPageAscentRatio(hostFontName, ResolveLineSpacingMul(hostParagraph, options)); + state.AvoidWrapObstacles(nextAscent, Math.Max(0f, nextLineHeight - nextAscent)); + } + // Render fill background if present if (box.FillColor is { } fill) { @@ -2235,7 +2335,7 @@ private static void RenderFloatingTextBoxes(RenderState state, List 0 && paragraph.SnapToGrid + ? (lineHeight + fontSize) / 2f + : fontSize * GetTopOfPageAscentRatio(fontName, ResolveLineSpacingMul(paragraph, state.Options)); + if (state.IsTopOfPage) + state.AdvanceY(ascent); + state.AvoidWrapObstacles(ascent, Math.Max(0f, lineHeight - ascent)); + } + // For center/right alignment, pre-calculate total line width of all runs // and offset the starting X position. // Use Helvetica widths (useCalibri=false) because the multi-format path @@ -2771,18 +2887,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC // The line ending at this is the last visual line // of the preceding segment, so it must not be justified. FlushLineEntries(isLastLine: true); - if (!state.IsTopOfPage && state.CurrentY - runFs * (GetFontMetricsFactor(run.FontName) - 1f) < state.Options.MarginBottom) - state.ForceNewPage(); - else - state.AdvanceY(lineHeight); - state.EnsurePage(); - if (state.IsTopOfPage) - { - var hardBrAscentOffset = state.Options.GridLinePitch > 0 && paragraph.SnapToGrid - ? (lineHeight + runFs) / 2f - : runFs * GetTopOfPageAscentRatio(run.FontName, ResolveLineSpacingMul(paragraph, state.Options)); - state.AdvanceY(hardBrAscentOffset); - } + AdvanceToContinuationLine(runFs, run.FontName); currentX = baseX; isFirstLine = false; } @@ -3037,18 +3142,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC } FlushLineEntries(); // Wrap to next line - if (!state.IsTopOfPage && state.CurrentY - runFs * (GetFontMetricsFactor(run.FontName) - 1f) < state.Options.MarginBottom) - state.ForceNewPage(); - else - state.AdvanceY(lineHeight); - state.EnsurePage(); - if (state.IsTopOfPage) - { - var wrapAscentOffset = state.Options.GridLinePitch > 0 && paragraph.SnapToGrid - ? (lineHeight + runFs) / 2f - : runFs * GetTopOfPageAscentRatio(run.FontName, ResolveLineSpacingMul(paragraph, state.Options)); - state.AdvanceY(wrapAscentOffset); - } + AdvanceToContinuationLine(runFs, run.FontName); currentX = baseX; pendingX = currentX; isFirstLine = false; @@ -3109,18 +3203,7 @@ static float WrapEntryWidth((string Text, float X, float Y, float FontSize, PdfC BufferOrEmit(pendingText[..breakAt], pendingX, state.CurrentY + run.VerticalPosition, runFs, runColor, run.Bold, run.Italic, run.Underline, run.CharSpacing, run.FontName, cjkBrkMaxW > 0 ? cjkBrkMaxW : (float?)null, null, run.Shading); FlushLineEntries(); pendingText = pendingText[breakAt..]; - if (!state.IsTopOfPage && state.CurrentY - runFs * (GetFontMetricsFactor(run.FontName) - 1f) < state.Options.MarginBottom) - state.ForceNewPage(); - else - state.AdvanceY(lineHeight); - state.EnsurePage(); - if (state.IsTopOfPage) - { - var cjkBrkAscentOffset = state.Options.GridLinePitch > 0 && paragraph.SnapToGrid - ? (lineHeight + runFs) / 2f - : runFs * GetTopOfPageAscentRatio(run.FontName, ResolveLineSpacingMul(paragraph, state.Options)); - state.AdvanceY(cjkBrkAscentOffset); - } + AdvanceToContinuationLine(runFs, run.FontName); currentX = baseX + EstimateWrapTextWidth(pendingText, runFs, run.Bold, run.CharSpacing, useCalibri) * nonCalibriWidthFactor; pendingX = baseX; isFirstLine = false; diff --git a/tests/MiniPdf.Tests/DocxDrawingTests.cs b/tests/MiniPdf.Tests/DocxDrawingTests.cs index a62bf1b9..c40ed74c 100644 --- a/tests/MiniPdf.Tests/DocxDrawingTests.cs +++ b/tests/MiniPdf.Tests/DocxDrawingTests.cs @@ -28,6 +28,237 @@ public void Read_MultipleDrawingsInTextBoxHostRun_ReadsEachTopLevelDrawing() Assert.Contains(shapes, shape => shape.FillColor.B > 0.99f && shape.FillColor.R < 0.01f); } + /// + /// A wrapTopAndBottom text box anchored to the page or to the margin must be exposed as a + /// floating box on its host paragraph. Its offset is a page coordinate, not spacing, so + /// neither the box content nor the host paragraph may be pushed down the flow by it. + /// + [Theory] + [InlineData("page", 1333500, 105f)] + [InlineData("margin", 419100, 33f)] + public void Read_AbsoluteWrapTopAndBottomTextBox_IsFloatingBox(string relativeFrom, int posOffsetEmu, float expectedYPt) + { + using var stream = CreateDocxWithAbsoluteWrapTopAndBottomTextBox(relativeFrom, posOffsetEmu); + + var document = DocxReader.Read(stream); + var paragraphs = document.Elements.OfType().ToArray(); + + Assert.DoesNotContain(paragraphs, paragraph => paragraph.Runs.Any(run => run.Text == "Boxed")); + var host = Assert.Single(paragraphs, paragraph => paragraph.Runs.Any(run => run.Text == "Host")); + var box = Assert.Single(host.FloatingTextBoxes ?? []); + Assert.True(box.IsWrapTopBottom); + Assert.Equal(relativeFrom, box.VRelativeFrom); + Assert.InRange(box.YPt, expectedYPt - 0.1f, expectedYPt + 0.1f); + Assert.InRange(host.SpacingBefore, -0.01f, 0.01f); + } + + /// + /// The text flow may not run beside a wrapTopAndBottom box: the host line stays above the + /// box, the box text renders inside the box band, and the next paragraph resumes below it. + /// Both anchors put the box 105pt below the page top (the margin case is 33pt below the + /// 72pt top margin). + /// + [Theory] + [InlineData("page", 1333500)] + [InlineData("margin", 419100)] + public void Convert_AbsoluteWrapTopAndBottomTextBox_ResumesFlowBelowBox(string relativeFrom, int posOffsetEmu) + { + using var stream = CreateDocxWithAbsoluteWrapTopAndBottomTextBox(relativeFrom, posOffsetEmu); + + var document = DocxToPdfConverter.Convert(stream); + + var page = Assert.Single(document.Pages); + var host = Assert.Single(page.TextBlocks, block => block.Text == "Host"); + var boxed = Assert.Single(page.TextBlocks, block => block.Text == "Boxed"); + var after = Assert.Single(page.TextBlocks, block => block.Text == "After"); + + // The box occupies 105pt to 145pt from the top of the 792pt page. + const float bandTop = 792f - 105f; + const float bandBottom = 792f - 145f; + Assert.InRange(boxed.Y, bandBottom, bandTop); + Assert.True(host.Y > bandTop, $"Host baseline {host.Y} must stay above the box top {bandTop}."); + Assert.True(after.Y < bandBottom, $"Following baseline {after.Y} must resume below the box bottom {bandBottom}."); + } + + [Fact] + public void Convert_AbsoluteWrapTopAndBottomTextBox_MixedFormatContinuationLinesAvoidBox() + { + var continuationText = string.Join(" ", Enumerable.Repeat("continuation", 40)); + var afterParagraph = $""" + + After + {continuationText} + + """; + using var stream = CreateDocxWithAbsoluteWrapTopAndBottomTextBox( + "page", 1841500, afterParagraph); + + var document = DocxToPdfConverter.Convert(stream); + + var page = Assert.Single(document.Pages); + var bodyBlocks = page.TextBlocks.Where(block => block.Text != "Boxed").ToArray(); + const float bandTop = 792f - 145f; + const float bandBottom = 792f - 185f; + Assert.Contains(bodyBlocks, block => block.Text.Contains("continuation") && block.Y < bandBottom); + Assert.DoesNotContain(bodyBlocks, block => block.Y < bandTop && block.Y > bandBottom); + } + + [Fact] + public void Convert_OverlappingWrapTopAndBottomTextBoxes_RescansEarlierObstacles() + { + var afterParagraphs = string.Join("", Enumerable.Range(1, 12) + .Select(index => $"After {index}")); + using var stream = CreateDocxWithAbsoluteWrapTopAndBottomTextBox( + "page", 2222500, afterParagraphs, secondPosOffsetEmu: 1841500); + + var document = DocxToPdfConverter.Convert(stream); + + var page = Assert.Single(document.Pages); + var bodyBlocks = page.TextBlocks + .Where(block => !block.Text.StartsWith("Boxed", StringComparison.Ordinal)) + .ToArray(); + const float upperBandTop = 792f - 145f; + const float lowerBandBottom = 792f - 215f; + Assert.Contains(bodyBlocks, + block => block.Text.StartsWith("After", StringComparison.Ordinal) && block.Y < lowerBandBottom); + Assert.DoesNotContain(bodyBlocks, block => block.Y < upperBandTop && block.Y > lowerBandBottom); + } + + [Fact] + public void Convert_WrapTopAndBottomTextBoxBelowMargin_MovesContinuationToNextPage() + { + var continuationText = string.Join(" ", Enumerable.Repeat("continuation", 400)); + var afterParagraph = $""" + + After + {continuationText} + + """; + using var stream = CreateDocxWithAbsoluteWrapTopAndBottomTextBox( + "page", 8255000, afterParagraph, boxHeightEmu: 1270000); + + var document = DocxToPdfConverter.Convert(stream); + + Assert.True(document.Pages.Count >= 2); + Assert.DoesNotContain(document.Pages[0].TextBlocks, + block => block.Text != "Boxed" && block.Y < 72f); + Assert.Contains(document.Pages.Skip(1).SelectMany(page => page.TextBlocks), + block => block.Text.Contains("continuation")); + } + + /// + /// Creates a minimal DOCX with three paragraphs; the second hosts a wrapTopAndBottom text + /// box whose vertical anchor uses the given relativeFrom value and EMU offset. + /// + private static MemoryStream CreateDocxWithAbsoluteWrapTopAndBottomTextBox( + string relativeFrom, int posOffsetEmu, + string afterParagraph = "After", + int boxHeightEmu = 508000, int? secondPosOffsetEmu = null) + { + var secondTextBoxRun = secondPosOffsetEmu.HasValue + ? $""" + + + + + 0 + {secondPosOffsetEmu.Value} + + + + + + + + + + + + Boxed2 + + + + + + + + """ + : ""; + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "[Content_Types].xml", + """ + + + + + + + """); + AddEntry(archive, "_rels/.rels", + """ + + + + + """); + AddEntry(archive, "word/document.xml", + $""" + + + + Intro + + Host + + + + + 0 + {posOffsetEmu} + + + + + + + + + + + + + + Boxed + + + + + + + + + + {secondTextBoxRun} + + {afterParagraph} + + + + + + + """); + } + + stream.Position = 0; + return stream; + } + /// /// Creates a minimal DOCX whose single run has a simple shape followed by a grouped shape /// with an empty text box.