diff --git a/src/MiniPdf/DocxReader.cs b/src/MiniPdf/DocxReader.cs index cd2f28ed..f99f7700 100644 --- a/src/MiniPdf/DocxReader.cs +++ b/src/MiniPdf/DocxReader.cs @@ -28,6 +28,7 @@ internal static class DocxReader private static readonly XNamespace REL = "http://schemas.openxmlformats.org/package/2006/relationships"; private static readonly XNamespace WPS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"; private static readonly XNamespace WPG = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"; + private static readonly XNamespace WP14 = "http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"; private static readonly XNamespace V = "urn:schemas-microsoft-com:vml"; private static readonly XNamespace MC = "http://schemas.openxmlformats.org/markup-compatibility/2006"; private static readonly XNamespace M = "http://schemas.openxmlformats.org/officeDocument/2006/math"; @@ -396,33 +397,14 @@ private static DocxDocument ReadCore(ZipArchive archive) continue; // skip duplicate text box at same position } - // Read anchor position - float anchorOffsetPt = 0; - string vRelativeFrom = "paragraph"; - string? vAlign = null; - var posV = anchor.Element(WP + "positionV"); - if (posV != null) - { - vRelativeFrom = posV.Attribute("relativeFrom")?.Value ?? "paragraph"; - var off = posV.Element(WP + "posOffset"); - if (off != null && long.TryParse(off.Value, out var emu)) - anchorOffsetPt = emu / 914400f * 72f; - var alignEl = posV.Element(WP + "align"); - if (alignEl != null) vAlign = alignEl.Value; - } - float anchorXPt = 0; - string hRelativeFrom = "column"; - string? hAlign = null; - var posH = anchor.Element(WP + "positionH"); - if (posH != null) - { - hRelativeFrom = posH.Attribute("relativeFrom")?.Value ?? "column"; - var off = posH.Element(WP + "posOffset"); - if (off != null && long.TryParse(off.Value, out var hEmu)) - anchorXPt = hEmu / 914400f * 72f; - var alignEl = posH.Element(WP + "align"); - if (alignEl != null) hAlign = alignEl.Value; - } + // Read anchor position (positionH/V can be wrapped in mc:AlternateContent) + var tbPlacement = ReadAnchorPlacement(anchor, "column", "paragraph"); + float anchorOffsetPt = tbPlacement.OffY / 914400f * 72f; + string vRelativeFrom = tbPlacement.VRel; + string? vAlign = tbPlacement.VAlign; + float anchorXPt = tbPlacement.OffX / 914400f * 72f; + string hRelativeFrom = tbPlacement.HRel; + string? hAlign = tbPlacement.HAlign; float extentHeightPt = 0; float extentWidthPt = 0; var extent = anchor.Element(WP + "extent"); @@ -434,6 +416,20 @@ private static DocxDocument ReadCore(ZipArchive archive) extentWidthPt = cx / 914400f * 72f; } + // A text box inside a wpg group is placed by its own rectangle inside the group frame, + // not by the whole group frame. + var txbxWsp = txbx.Ancestors(WPS + "wsp").FirstOrDefault(); + float groupFrameWidthPt = 0, groupFrameHeightPt = 0, childOffsetXPt = 0, childOffsetYPt = 0; + if (txbxWsp != null && TryGetGroupChildRect(txbxWsp, out var gcX, out var gcY, out var gcW, out var gcH)) + { + groupFrameWidthPt = extentWidthPt; + groupFrameHeightPt = extentHeightPt; + childOffsetXPt = gcX / 914400f * 72f; + childOffsetYPt = gcY / 914400f * 72f; + extentWidthPt = gcW / 914400f * 72f; + extentHeightPt = gcH / 914400f * 72f; + } + // Read text box outline (border) and fill from shape properties DocxTextBoxBorder? textBoxBorder = null; PdfColor? textBoxFillColor = null; @@ -442,7 +438,7 @@ private static DocxDocument ReadCore(ZipArchive archive) // ProcessGroupChildren. Extracting fill from the wsp here would // duplicate the background rectangle and paint over child shapes. bool anchorHasGroupShape = anchor.Descendants(WPG + "wgp").Any(); - var wsp = anchor.Descendants(WPS + "wsp").FirstOrDefault(); + var wsp = txbxWsp ?? anchor.Descendants(WPS + "wsp").FirstOrDefault(); if (wsp != null) { var spPr = wsp.Element(WPS + "spPr") ?? wsp.Element(A + "spPr"); @@ -474,6 +470,7 @@ private static DocxDocument ReadCore(ZipArchive archive) // Parse bodyPr text insets (defaults: lIns=91440, tIns=45720 EMU) float topInsetPt = 3.6f; // 45720 EMU default float leftInsetPt = 7.2f; // 91440 EMU default + float? rightInsetPt = null; var bodyPr = wsp?.Element(WPS + "bodyPr"); if (bodyPr != null) { @@ -481,7 +478,12 @@ private static DocxDocument ReadCore(ZipArchive archive) topInsetPt = tIns / 914400f * 72f; if (long.TryParse(bodyPr.Attribute("lIns")?.Value, out var lIns)) leftInsetPt = lIns / 914400f * 72f; + if (long.TryParse(bodyPr.Attribute("rIns")?.Value, out var rIns)) + rightInsetPt = rIns / 914400f * 72f; } + float textRectTrimPt = wsp?.Element(WPS + "spPr")?.Element(A + "prstGeom")?.Attribute("prst")?.Value == "homePlate" + ? Math.Min(extentWidthPt, extentHeightPt) * 0.25f + : 0f; // A wrapTopAndBottom text box whose vertical anchor is relative to the // page or margin sits at an absolute page position (LibreOffice maps @@ -536,13 +538,30 @@ private static DocxDocument ReadCore(ZipArchive archive) firstContent = false; // Also collect for floating overlay - floatingParas.Add(tbPara with { IsTextBoxFlow = false }); + var overlayPara = tbPara with { IsTextBoxFlow = false }; + // Uncoloured runs take the shape style's fontRef colour. + var fontRef = wsp?.Element(WPS + "style")?.Element(A + "fontRef"); + var fontRefColor = fontRef?.Element(A + "schemeClr") ?? fontRef?.Element(A + "srgbClr"); + if (fontRefColor != null) + { + var (styleTextColor, _) = ResolveSolidFill(new XElement(A + "solidFill", fontRefColor), themeColors); + if (styleTextColor != null) + overlayPara = overlayPara with + { + Runs = overlayPara.Runs.Select(r => r.Color == null ? r with { Color = styleTextColor } : r).ToList() + }; + } + floatingParas.Add(overlayPara); } } if (floatingParas.Count > 0) { floatingTextBoxes ??= new List(); - floatingTextBoxes.Add(new DocxFloatingTextBox(anchorXPt, anchorOffsetPt, extentWidthPt, extentHeightPt, floatingParas, textBoxBorder, hRelativeFrom, vRelativeFrom, textBoxFillColor, topInsetPt, leftInsetPt, hAlign, vAlign, IsWrapTopBottom: isWrapTopBottom)); + floatingTextBoxes.Add(new DocxFloatingTextBox(anchorXPt, anchorOffsetPt, extentWidthPt, extentHeightPt, floatingParas, textBoxBorder, hRelativeFrom, vRelativeFrom, textBoxFillColor, topInsetPt, leftInsetPt, hAlign, vAlign, IsWrapTopBottom: isWrapTopBottom, + HPct: tbPlacement.HPct, VPct: tbPlacement.VPct, + AnchorWidthPt: groupFrameWidthPt, AnchorHeightPt: groupFrameHeightPt, + ChildOffsetXPt: childOffsetXPt, ChildOffsetYPt: childOffsetYPt, + RightInsetPt: rightInsetPt, TextRectTrimPt: textRectTrimPt)); } } else @@ -2607,6 +2626,45 @@ private static string GetRelationshipsPath(string partPath) } } + /// Resolved anchor position: reference frame, alignment, absolute offset and wp14 percentage offset. + private sealed record AnchorPlacement( + string HRel, string VRel, string? HAlign, string? VAlign, + long OffX, long OffY, double? HPct, double? VPct); + + /// + /// Reads positionH/positionV. They may be wrapped in mc:AlternateContent (the wp14 percentage form puts + /// pctPos*Offset in the Choice branch and a posOffset fallback beside it), so search descendants. + /// + private static AnchorPlacement ReadAnchorPlacement(XElement anchor, string defaultHRel, string defaultVRel) + { + var posH = anchor.Descendants(WP + "positionH").FirstOrDefault(); + var posV = anchor.Descendants(WP + "positionV").FirstOrDefault(); + long offX = 0, offY = 0; + double? hPct = null, vPct = null; + string? hAlign = null, vAlign = null; + var hRel = defaultHRel; + var vRel = defaultVRel; + if (posH != null) + { + hRel = posH.Attribute("relativeFrom")?.Value ?? defaultHRel; + if (long.TryParse(posH.Element(WP + "posOffset")?.Value, out var hv)) offX = hv; + if (double.TryParse(posH.Element(WP14 + "pctPosHOffset")?.Value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var hp)) + hPct = hp / 100000d; + hAlign = posH.Element(WP + "align")?.Value; + } + if (posV != null) + { + vRel = posV.Attribute("relativeFrom")?.Value ?? defaultVRel; + if (long.TryParse(posV.Element(WP + "posOffset")?.Value, out var vv)) offY = vv; + if (double.TryParse(posV.Element(WP14 + "pctPosVOffset")?.Value, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var vp)) + vPct = vp / 100000d; + vAlign = posV.Element(WP + "align")?.Value; + } + return new AnchorPlacement(hRel, vRel, hAlign, vAlign, offX, offY, hPct, vPct); + } + /// /// Reads anchor shapes (filled rectangles) from drawing elements. /// Handles both simple shapes and group shapes (wpg:wgp). @@ -2623,20 +2681,29 @@ private static List ReadAnchorShapes(XElement drawing, Dictionary Stamp(List shapes) => shapes.Select(s => s with { - var off = posV.Element(WP + "posOffset"); - if (off != null) long.TryParse(off.Value, out anchorOffsetY); - } + HRelativeFrom = placement.HRel, + VRelativeFrom = placement.VRel, + HAlign = placement.HAlign, + VAlign = placement.VAlign, + HPct = placement.HPct, + VPct = placement.VPct, + AnchorWidthEmu = anchorExtCx, + AnchorHeightEmu = anchorExtCy, + }).ToList(); // Check for group shape (wpg:wgp) var grpSp = anchor.Descendants(WPG + "wgp").FirstOrDefault(); @@ -2682,7 +2749,7 @@ private static List ReadAnchorShapes(XElement drawing, Dictionary ReadAnchorShapes(XElement drawing, Dictionary + /// For a wps:wsp that sits inside a wpg group, returns its rectangle in EMU relative to the + /// outermost group frame (top-left of the frame = 0,0), applying each group's child-to-frame scale. + /// Returns false when the shape is not inside a group or has no xfrm. + /// + private static bool TryGetGroupChildRect(XElement wsp, out long x, out long y, out long w, out long h) + { + x = y = w = h = 0; + var xfrm = wsp.Element(WPS + "spPr")?.Element(A + "xfrm"); + var off = xfrm?.Element(A + "off"); + var ext = xfrm?.Element(A + "ext"); + if (off == null || ext == null) return false; + long.TryParse(off.Attribute("x")?.Value, out var curX); + long.TryParse(off.Attribute("y")?.Value, out var curY); + long.TryParse(ext.Attribute("cx")?.Value, out var curW); + long.TryParse(ext.Attribute("cy")?.Value, out var curH); + + bool insideGroup = false; + // Walk up through each enclosing group (wgp or nested grpSp), mapping child space to its parent space. + foreach (var grp in wsp.Ancestors().Where(e => e.Name == WPG + "wgp" || e.Name == WPG + "grpSp")) + { + var gx = grp.Element(WPG + "grpSpPr")?.Element(A + "xfrm"); + if (gx == null) { insideGroup = true; continue; } + long gOffX = 0, gOffY = 0, gExtCx = 1, gExtCy = 1, cOffX = 0, cOffY = 0, cExtCx = 1, cExtCy = 1; + var gOff = gx.Element(A + "off"); + if (gOff != null) + { + long.TryParse(gOff.Attribute("x")?.Value, out gOffX); + long.TryParse(gOff.Attribute("y")?.Value, out gOffY); + } + var gExt = gx.Element(A + "ext"); + if (gExt != null) + { + long.TryParse(gExt.Attribute("cx")?.Value, out gExtCx); + long.TryParse(gExt.Attribute("cy")?.Value, out gExtCy); + } + var cOff = gx.Element(A + "chOff"); + if (cOff != null) + { + long.TryParse(cOff.Attribute("x")?.Value, out cOffX); + long.TryParse(cOff.Attribute("y")?.Value, out cOffY); + } + var cExt = gx.Element(A + "chExt"); + if (cExt != null) + { + long.TryParse(cExt.Attribute("cx")?.Value, out cExtCx); + long.TryParse(cExt.Attribute("cy")?.Value, out cExtCy); + } + if (cExtCx == 0) cExtCx = 1; + if (cExtCy == 0) cExtCy = 1; + + curX = (curX - cOffX) * gExtCx / cExtCx; + curY = (curY - cOffY) * gExtCy / cExtCy; + curW = curW * gExtCx / cExtCx; + curH = curH * gExtCy / cExtCy; + // A nested grpSp's off is in its parent's child space; the outermost wgp's off is the + // anchor position itself, so it is not added. + if (grp.Name == WPG + "grpSp") + { + curX += gOffX; + curY += gOffY; + } + insideGroup = true; + } + if (!insideGroup) return false; + x = curX; y = curY; w = curW; h = curH; + return true; + } + private static (string? PresetGeometry, float FrameThicknessRatio, List? CustomPaths) ReadShapeGeometry(XElement spPr, long widthEmu, long heightEmu) { @@ -5252,7 +5396,17 @@ internal sealed record DocxFloatingTextBox( 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 + bool IsWrapTopBottom = false, + double? HPct = null, + double? VPct = null, + // Text box inside a wpg group: size of the whole group frame and this box's offset inside it. + float AnchorWidthPt = 0, + float AnchorHeightPt = 0, + float ChildOffsetXPt = 0, + float ChildOffsetYPt = 0, + float? RightInsetPt = null, + // Width removed from the right of the text area by the preset geometry (homePlate tip). + float TextRectTrimPt = 0 ); /// Represents a text box outline border (rectangle drawn around text box content). @@ -5291,7 +5445,18 @@ internal sealed record DocxShape( PdfColor? StrokeColor = null, float StrokeWidthEmu = 0f, bool FillOnly = true, - bool HasFill = true + bool HasFill = true, + // Anchor reference for page-relative positioning (defaults keep the legacy margin-relative behaviour). + string HRelativeFrom = "margin", + string VRelativeFrom = "margin", + string? HAlign = null, + string? VAlign = null, + // posOffset-less percentage position (wp14:pctPos*Offset, 1000ths of a percent) of the anchor frame. + double? HPct = null, + double? VPct = null, + // Size of the whole anchor frame, needed to centre/align a group relative to the page or margin. + long AnchorWidthEmu = 0, + long AnchorHeightEmu = 0 ); /// Represents a table. diff --git a/src/MiniPdf/DocxToPdfConverter.cs b/src/MiniPdf/DocxToPdfConverter.cs index d1c7d402..a67eb5e9 100644 --- a/src/MiniPdf/DocxToPdfConverter.cs +++ b/src/MiniPdf/DocxToPdfConverter.cs @@ -2137,6 +2137,11 @@ private static void RenderFloatingTextBoxes(RenderState state, List 0 ? box.AnchorWidthPt : box.WidthPt; + var frameHeight = box.AnchorHeightPt > 0 ? box.AnchorHeightPt : box.HeightPt; + // Horizontal positioning with alignment support float boxLeft; if (box.HAlign != null) @@ -2145,11 +2150,18 @@ private static void RenderFloatingTextBoxes(RenderState state, List refLeft + (refWidth - box.WidthPt) / 2, - "right" => refLeft + refWidth - box.WidthPt, + "center" => refLeft + (refWidth - frameWidth) / 2, + "right" => refLeft + refWidth - frameWidth, _ => refLeft // left }; } + else if (box.HPct != null) + { + var onPage = box.HRelativeFrom == "page"; + var refLeft = onPage ? 0f : options.MarginLeft; + var refWidth = onPage ? options.PageWidth : usableW; + boxLeft = refLeft + (float)box.HPct.Value * refWidth; + } else { boxLeft = box.HRelativeFrom == "page" @@ -2168,8 +2180,8 @@ private static void RenderFloatingTextBoxes(RenderState state, List refTop - (refHeight - box.HeightPt) / 2, - "bottom" => refTop - refHeight + box.HeightPt, + "center" => refTop - (refHeight - frameHeight) / 2, + "bottom" => refTop - refHeight + frameHeight, _ => refTop // top }; } @@ -2200,6 +2212,10 @@ private static void RenderFloatingTextBoxes(RenderState state, List 0 ? box.WidthPt : state.UsableWidth) - leftInset * 2; + var textAreaWidth = (box.WidthPt > 0 ? box.WidthPt : state.UsableWidth) - box.TextRectTrimPt; + var maxWidth = textAreaWidth - leftInset - (box.RightInsetPt ?? leftInset); if (maxWidth < 10) maxWidth = box.WidthPt > 0 ? box.WidthPt : state.UsableWidth; // Use Calibri widths only when the text box paragraph actually uses Calibri; // other fonts (e.g. Century Gothic) have different glyph widths. @@ -3280,6 +3297,9 @@ private static void RenderShape(RenderState state, DocxShape shape) var x = state.Options.MarginLeft + shape.OffsetXEmu / emuPerPoint; var y = (state.Options.PageHeight - state.Options.MarginTop) - shape.OffsetYEmu / emuPerPoint - height; + if (shape.HAlign != null || shape.HPct != null || shape.VAlign != null || shape.VPct != null) + (x, y) = ResolveAnchoredShapeOrigin(state.Options, shape, x, y, height); + // Alpha-blend fill color over white background var fc = shape.FillColor; var a = shape.Alpha; @@ -3290,8 +3310,8 @@ private static void RenderShape(RenderState state, DocxShape shape) RenderShapeGeometry(state.CurrentPage!, x, y, width, height, blended, shape); - // Render stroke/outline if present - if (shape.StrokeColor != null) + // Render stroke/outline if present (the box outline is only right for rectangles) + if (shape.StrokeColor != null && shape.PresetGeometry is null or "rect") { var strokeWidth = shape.StrokeWidthEmu > 0 ? shape.StrokeWidthEmu / emuPerPoint : 0.75f; var sc = shape.StrokeColor.Value; @@ -3302,6 +3322,50 @@ private static void RenderShape(RenderState state, DocxShape shape) } } + // Places a shape whose anchor uses alignment or a percentage offset instead of an absolute posOffset; + // shape.Offset*Emu is then the shape's offset inside the anchor frame. + private static (float X, float Y) ResolveAnchoredShapeOrigin( + ConversionOptions options, DocxShape shape, float legacyX, float legacyY, float height) + { + const float emuPerPoint = 914400f / 72f; + var frameW = shape.AnchorWidthEmu / emuPerPoint; + var frameH = shape.AnchorHeightEmu / emuPerPoint; + var x = legacyX; + var y = legacyY; + + if (shape.HAlign != null || shape.HPct != null) + { + var onPage = shape.HRelativeFrom == "page"; + var refLeft = onPage ? 0f : options.MarginLeft; + var refW = onPage ? options.PageWidth : options.PageWidth - options.MarginLeft - options.MarginRight; + var frameLeft = shape.HAlign switch + { + "center" => refLeft + (refW - frameW) / 2, + "right" => refLeft + refW - frameW, + "left" => refLeft, + _ => refLeft + (float)(shape.HPct ?? 0) * refW, + }; + x = frameLeft + shape.OffsetXEmu / emuPerPoint; + } + + if (shape.VAlign != null || shape.VPct != null) + { + var onPage = shape.VRelativeFrom == "page"; + var refTop = onPage ? options.PageHeight : options.PageHeight - options.MarginTop; + var refH = onPage ? options.PageHeight : options.PageHeight - options.MarginTop - options.MarginBottom; + var frameTop = shape.VAlign switch + { + "center" => refTop - (refH - frameH) / 2, + "bottom" => refTop - refH + frameH, + "top" => refTop, + _ => refTop - (float)(shape.VPct ?? 0) * refH, + }; + y = frameTop - shape.OffsetYEmu / emuPerPoint - height; + } + + return (x, y); + } + private static void RenderHeaderFooterShape(PdfPage page, ConversionOptions options, DocxShape shape) { const float emuPerPoint = 914400f / 72f; @@ -3321,7 +3385,7 @@ private static void RenderHeaderFooterShape(PdfPage page, ConversionOptions opti RenderShapeGeometry(page, x, y, width, height, blended, shape); - if (shape.StrokeColor != null) + if (shape.StrokeColor != null && shape.PresetGeometry is null or "rect") { var strokeWidth = shape.StrokeWidthEmu > 0 ? shape.StrokeWidthEmu / emuPerPoint : 0.75f; var sc = shape.StrokeColor.Value; @@ -3401,6 +3465,22 @@ private static void RenderShapeGeometry(PdfPage page, float x, float y, float wi page.AddCompoundPolygon(mappedSubpaths, color, path.UseEvenOddFill); } } + else if (shape.PresetGeometry == "homePlate") + { + // Preset default adj=50000 of the short side gives the tip depth. + var tip = Math.Min(width, height) * 0.5f; + page.AddCompoundPolygon(new List> + { + new() + { + new PdfPoint(x, y + height), + new PdfPoint(x + width - tip, y + height), + new PdfPoint(x + width, y + height / 2), + new PdfPoint(x + width - tip, y), + new PdfPoint(x, y), + }, + }, color, false); + } else { page.AddRectangle(x, y, width, height, color); diff --git a/tests/MiniPdf.Tests/DocxGroupShapeTests.cs b/tests/MiniPdf.Tests/DocxGroupShapeTests.cs new file mode 100644 index 00000000..1ab0d778 --- /dev/null +++ b/tests/MiniPdf.Tests/DocxGroupShapeTests.cs @@ -0,0 +1,281 @@ +using System.IO.Compression; +using System.Text; + +namespace MiniSoftware.Tests; + +/// +/// Covers page-anchored wpg groups: anchor placement, preset and style-driven fills, +/// and text boxes that sit inside a group. All documents are synthetic. +/// +public class DocxGroupShapeTests +{ + private const float EmuPerPoint = 12700f; + + // 172.8pt x 718.56pt group, placed 4% from the page's left edge and centred vertically. + private const string SidebarGroup = """ + + + + 4000 + + + 310896 + + + center + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dated + + + + + + """; + + /// + /// An anchor with a wp14 percentage position and centre alignment must place the group + /// against the page, not at the margin origin. + /// + [Fact] + public void Convert_PageAnchoredGroup_PlacesShapesByPercentAndCentering() + { + using var stream = CreateDocx(SidebarGroup); + + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + + // Frame left = 4% of the 612pt page; frame top = (792 - 718.56) / 2 below the page top. + var bar = Assert.Single(page.RectBlocks, rect => Math.Abs(rect.Width - 194535 / EmuPerPoint) < 0.1f); + Assert.InRange(bar.X, 24.3f, 24.7f); + Assert.InRange(bar.Y, 36.5f, 36.9f); + Assert.InRange(bar.Height, 718.4f, 718.7f); + } + + /// + /// A homePlate preset must keep its pointed tip, and a fill that only comes from the + /// shape style's fillRef must still be painted. + /// + [Fact] + public void Convert_HomePlateWithStyleFill_DrawsPointedShapeInStyleColour() + { + using var stream = CreateDocx(SidebarGroup); + + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + + var pentagon = Assert.Single(page.PolygonBlocks); + Assert.True(pentagon.FillColor.B > 0.99f && pentagon.FillColor.R < 0.01f); + var points = pentagon.Subpaths?[0] ?? pentagon.Points; + Assert.Equal(5, points.Count); + var tip = points.OrderByDescending(point => point.X).First(); + var tipX = 24.48f + 2194560 / EmuPerPoint; + Assert.InRange(tip.X, tipX - 0.2f, tipX + 0.2f); + // The tip sits at mid-height; no other point may share its X. + Assert.Single(points, point => Math.Abs(point.X - tip.X) < 0.01f); + } + + /// + /// Text in a group child is placed by the child's own rectangle inside the group frame, + /// takes its colour from the style's fontRef, and stops short of the pentagon tip. + /// + [Fact] + public void Convert_GroupedPentagonTextBox_StaysInsideShapeTextArea() + { + using var stream = CreateDocx(SidebarGroup); + + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + + var text = Assert.Single(page.TextBlocks, block => block.Text == "Dated"); + Assert.True(text.Color.R > 0.99f && text.Color.G > 0.99f && text.Color.B > 0.99f); + + // Pentagon band: 115.5pt to 159pt below the group's top edge (755.28pt from the page bottom). + const float bandTop = 755.28f - 1466850 / EmuPerPoint; + const float bandBottom = bandTop - 552055 / EmuPerPoint; + Assert.InRange(text.Y, bandBottom, bandTop); + + // Text area right edge = left + width - tip/2 - rIns. + var textAreaRight = 24.48f + 2194560 / EmuPerPoint - 552055 / EmuPerPoint * 0.25f - 182880 / EmuPerPoint; + Assert.InRange(text.X, 24.48f, textAreaRight); + } + + /// + /// An outline on a freeform shape follows the path; the converter must not substitute a + /// rectangle around its bounding box. + /// + [Fact] + public void Convert_FreeformShapeWithOutline_DoesNotDrawBoundingBox() + { + using var stream = CreateDocx(CreateOutlinedShape(""" + + + + + + + + + """)); + + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + + Assert.Single(page.PolygonBlocks); + Assert.Empty(page.LineBlocks); + } + + /// + /// A plain rectangle keeps its outline. + /// + [Fact] + public void Convert_RectangleWithOutline_DrawsFourEdges() + { + using var stream = CreateDocx(CreateOutlinedShape("""""")); + + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + + Assert.Equal(4, page.LineBlocks.Count); + } + + [Theory] + [InlineData("homePlate", 0)] + [InlineData("rect", 8)] + public void Convert_HeaderAndFooterOutlinedShapes_OnlyRectanglesDrawBoundingBox(string geometry, int expectedLines) + { + using var stream = CreateDocx("", CreateOutlinedShape($"")); + + var page = Assert.Single(DocxToPdfConverter.Convert(stream).Pages); + + Assert.Equal(expectedLines, page.LineBlocks.Count); + } + + private static string CreateOutlinedShape(string geometry) => $""" + + 0 + 0 + + + + + + + + + + + {geometry} + + + + + + + """; + + private static MemoryStream CreateDocx(string anchorXml, string? headerFooterAnchorXml = null) + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "[Content_Types].xml", + $""" + + + + + + {(headerFooterAnchorXml == null ? "" : """ + + + """)} + + """); + AddEntry(archive, "_rels/.rels", + """ + + + + + """); + if (headerFooterAnchorXml != null) + { + AddEntry(archive, "word/_rels/document.xml.rels", + """ + + + + + + """); + foreach (var (part, tag) in new[] { ("header", "hdr"), ("footer", "ftr") }) + { + AddEntry(archive, $"word/{part}1.xml", + $""" + + + {headerFooterAnchorXml} + + """); + } + } + AddEntry(archive, "word/document.xml", + $""" + + + + {anchorXml} + {(headerFooterAnchorXml == null ? "" : """ + + + + + """)} + + + """); + } + + stream.Position = 0; + return stream; + } + + private static void AddEntry(ZipArchive archive, string path, string content) + { + var entry = archive.CreateEntry(path); + using var writer = new StreamWriter(entry.Open(), Encoding.UTF8); + writer.Write(content); + } +}