From f006d077124d6b838d94843ddf3fc6b5c15be1b5 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Mon, 25 May 2026 16:16:48 +1000 Subject: [PATCH] Translate clip paths for text with RenderLocation. Fix #397 --- .../Processing/DrawingCanvas{TPixel}.cs | 19 +++- .../Issues/Issue_397.cs | 105 ++++++++++++++++++ ...ectingClip_Difference_IntersectingClip.png | 3 + ...tingClip_Intersection_IntersectingClip.png | 3 + ...ntersectingClip_Union_IntersectingClip.png | 3 + ...hIntersectingClip_Xor_IntersectingClip.png | 3 + ...ingClip_Difference_NonIntersectingClip.png | 3 + ...gClip_Intersection_NonIntersectingClip.png | 3 + ...rsectingClip_Union_NonIntersectingClip.png | 3 + ...tersectingClip_Xor_NonIntersectingClip.png | 3 + 10 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 tests/ImageSharp.Drawing.Tests/Issues/Issue_397.cs create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Difference_IntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Intersection_IntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Union_IntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Xor_IntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Difference_NonIntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Intersection_NonIntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Union_NonIntersectingClip.png create mode 100644 tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Xor_NonIntersectingClip.png diff --git a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs index dc4bb784..d9cf63ff 100644 --- a/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs +++ b/src/ImageSharp.Drawing/Processing/DrawingCanvas{TPixel}.cs @@ -1341,6 +1341,21 @@ private CompositionSceneCommand CreateTextCompositionCommand( ? drawingOptions : new DrawingOptions(graphicsOptions, shapeOptions, Matrix4x4.Identity); + IReadOnlyList? operationClipPaths = clipPaths; + if (clipPaths != null && clipPaths.Count > 0 && (operation.RenderLocation.X != 0 || operation.RenderLocation.Y != 0)) + { + IPath[] translatedClipPaths = new IPath[clipPaths.Count]; + + // Text glyph paths are queued in glyph-local coordinates and placed with RenderLocation, + // so canvas-space clip paths must be moved into that same local space before clipping. + for (int i = 0; i < clipPaths.Count; i++) + { + translatedClipPaths[i] = clipPaths[i].Translate(-operation.RenderLocation); + } + + operationClipPaths = translatedClipPaths; + } + if (pen is null) { return new PathCompositionSceneCommand( @@ -1351,7 +1366,7 @@ private CompositionSceneCommand CreateTextCompositionCommand( in rasterizerOptions, state.TargetBounds, destinationOffset, - clipPaths, + operationClipPaths, state.IsLayer)); } @@ -1364,7 +1379,7 @@ private CompositionSceneCommand CreateTextCompositionCommand( state.TargetBounds, destinationOffset, pen, - clipPaths, + operationClipPaths, state.IsLayer)); } diff --git a/tests/ImageSharp.Drawing.Tests/Issues/Issue_397.cs b/tests/ImageSharp.Drawing.Tests/Issues/Issue_397.cs new file mode 100644 index 00000000..a222c8e4 --- /dev/null +++ b/tests/ImageSharp.Drawing.Tests/Issues/Issue_397.cs @@ -0,0 +1,105 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.Fonts; +using SixLabors.ImageSharp.Drawing.Processing; +using SixLabors.ImageSharp.PixelFormats; + +namespace SixLabors.ImageSharp.Drawing.Tests.Issues; + +public class Issue_397 +{ + [Theory] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Intersection)] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Union)] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Difference)] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Xor)] + public void DrawTextWithIntersectingClip( + TestImageProvider provider, + BooleanOperation operation) + where TPixel : unmanaged, IPixel + { + PointF textOrigin = new(54, 78); + PointF clipCenter = new(104, 70); + DrawingOptions clipOptions = CreateClipOptions(operation); + Font font = TestFontUtilities.GetFont("OpenSans-Regular.ttf", 18); + + // Expected output: + // - Intersection shows only red text inside the moved star. + // - Difference shows only red text outside the moved star. + // - Union and Xor can show a red star because the boolean-combined path includes the clip path, + // and DrawText fills that combined result with the text brush. + provider.RunValidatingProcessorTest( + x => x.Paint(canvas => DrawIssue397Sample(canvas, clipOptions, clipCenter, textOrigin, font)), + testOutputDetails: $"{operation}_IntersectingClip", + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); + } + + [Theory] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Intersection)] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Union)] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Difference)] + [WithBlankImage(240, 160, PixelTypes.Rgba32, BooleanOperation.Xor)] + public void DrawTextWithNonIntersectingClip( + TestImageProvider provider, + BooleanOperation operation) + where TPixel : unmanaged, IPixel + { + PointF textOrigin = new(54, 78); + PointF clipCenter = new(192, 116); + DrawingOptions clipOptions = CreateClipOptions(operation); + Font font = TestFontUtilities.GetFont("OpenSans-Regular.ttf", 18); + + // Expected output: + // - Intersection shows no red text because the moved star and text do not overlap. + // - Difference shows the full red text because the moved star removes nothing from it. + // - Union and Xor show both the full red text and a red star because disjoint Xor matches Union, + // and DrawText fills the boolean-combined result with the text brush. + provider.RunValidatingProcessorTest( + x => x.Paint(canvas => DrawIssue397Sample(canvas, clipOptions, clipCenter, textOrigin, font)), + testOutputDetails: $"{operation}_NonIntersectingClip", + appendPixelTypeToFileName: false, + appendSourceFileOrDescription: false); + } + + private static void DrawIssue397Sample( + DrawingCanvas canvas, + DrawingOptions clipOptions, + PointF clipCenter, + PointF textOrigin, + Font font) + { + canvas.Clear(Brushes.Solid(Color.White)); + StarPolygon clipPath = new(clipCenter, 7, 16, 38, 18); + RichTextOptions textOptions = new(font) + { + Origin = textOrigin + }; + + // The gray outline is the unclipped text guide; the red draw below shows the boolean clip result. + canvas.DrawText(textOptions, "This is a test", brush: null, Pens.Solid(Color.LightGray, 1F)); + + // The blue outline marks the moved clipping path without adding a filled shape behind the text. + canvas.Draw(Pens.Solid(Color.DarkBlue, 1F), clipPath); + canvas.Save(clipOptions, clipPath); + + canvas.DrawText( + textOptions, + "This is a test", + Brushes.Solid(Color.Crimson), + pen: null); + + canvas.Restore(); + canvas.Draw(Pens.Solid(Color.DarkBlue, 1F), clipPath); + } + + private static DrawingOptions CreateClipOptions(BooleanOperation operation) + => new() + { + ShapeOptions = new() + { + BooleanOperation = operation + } + }; +} diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Difference_IntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Difference_IntersectingClip.png new file mode 100644 index 00000000..e7666e65 --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Difference_IntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43fb26b0590be0f4f41fa82445e9c8754bbaab493e1dbb8d93002ffdc5d34dab +size 4675 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Intersection_IntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Intersection_IntersectingClip.png new file mode 100644 index 00000000..0127da8c --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Intersection_IntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e2a5d7e5d6b53950f7bd79a2bdbba48c2d373103ef9d3ffa7025388f34472df +size 4277 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Union_IntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Union_IntersectingClip.png new file mode 100644 index 00000000..434278b6 --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Union_IntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff89de4bec0856bd6e05bba4c26bbf77de2d978eaeb0aa76772bf2d41863cf9d +size 6205 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Xor_IntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Xor_IntersectingClip.png new file mode 100644 index 00000000..8affc7be --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithIntersectingClip_Xor_IntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ea6f78b35838ac8e03f9ed74ae974349805a15f1f74f55a16fb2407ec20c911 +size 6205 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Difference_NonIntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Difference_NonIntersectingClip.png new file mode 100644 index 00000000..32398229 --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Difference_NonIntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3125d65a6a97d1b0e7752fe63cab529007e2acbd6658c06905ccef0de4a52bd +size 4820 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Intersection_NonIntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Intersection_NonIntersectingClip.png new file mode 100644 index 00000000..b34ec1d7 --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Intersection_NonIntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:305803eab9be305f26cbed0b55dadbe022830d577c65d36624a32bc17395d009 +size 2823 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Union_NonIntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Union_NonIntersectingClip.png new file mode 100644 index 00000000..27a5357b --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Union_NonIntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d763346286309775c8c5d2a6353cf387ad6b44ac29900a036de43c49106a68ef +size 6922 diff --git a/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Xor_NonIntersectingClip.png b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Xor_NonIntersectingClip.png new file mode 100644 index 00000000..27a5357b --- /dev/null +++ b/tests/Images/ReferenceOutput/Issue_397/DrawTextWithNonIntersectingClip_Xor_NonIntersectingClip.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d763346286309775c8c5d2a6353cf387ad6b44ac29900a036de43c49106a68ef +size 6922