Skip to content

Commit 1d451a2

Browse files
Merge pull request #263 from SixLabors/js/fix234
Fix rendering along open paths.
2 parents cee21d9 + 41174d1 commit 1d451a2

29 files changed

+146
-134
lines changed

src/ImageSharp.Drawing/Processing/Extensions/DrawLineExtensions.cs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public static class DrawLineExtensions
1919
/// <param name="thickness">The line thickness.</param>
2020
/// <param name="points">The points.</param>
2121
/// <returns>The <see cref="IImageProcessingContext"/> to allow chaining of operations.</returns>
22-
public static IImageProcessingContext DrawLines(
22+
public static IImageProcessingContext DrawLine(
2323
this IImageProcessingContext source,
2424
DrawingOptions options,
2525
Brush brush,
@@ -35,7 +35,7 @@ public static IImageProcessingContext DrawLines(
3535
/// <param name="thickness">The line thickness.</param>
3636
/// <param name="points">The points.</param>
3737
/// <returns>The <see cref="IImageProcessingContext"/> to allow chaining of operations.</returns>
38-
public static IImageProcessingContext DrawLines(
38+
public static IImageProcessingContext DrawLine(
3939
this IImageProcessingContext source,
4040
Brush brush,
4141
float thickness,
@@ -50,12 +50,12 @@ public static IImageProcessingContext DrawLines(
5050
/// <param name="thickness">The line thickness.</param>
5151
/// <param name="points">The points.</param>
5252
/// <returns>The <see cref="IImageProcessingContext"/> to allow chaining of operations.</returns>
53-
public static IImageProcessingContext DrawLines(
53+
public static IImageProcessingContext DrawLine(
5454
this IImageProcessingContext source,
5555
Color color,
5656
float thickness,
5757
params PointF[] points) =>
58-
source.DrawLines(new SolidBrush(color), thickness, points);
58+
source.DrawLine(new SolidBrush(color), thickness, points);
5959

6060
/// <summary>
6161
/// Draws the provided points as an open linear path at the provided thickness with the supplied brush.
@@ -66,13 +66,13 @@ public static IImageProcessingContext DrawLines(
6666
/// <param name="thickness">The line thickness.</param>
6767
/// <param name="points">The points.</param>
6868
/// <returns>The <see cref="IImageProcessingContext"/> to allow chaining of operations.</returns>>
69-
public static IImageProcessingContext DrawLines(
69+
public static IImageProcessingContext DrawLine(
7070
this IImageProcessingContext source,
7171
DrawingOptions options,
7272
Color color,
7373
float thickness,
7474
params PointF[] points) =>
75-
source.DrawLines(options, new SolidBrush(color), thickness, points);
75+
source.DrawLine(options, new SolidBrush(color), thickness, points);
7676

7777
/// <summary>
7878
/// Draws the provided points as an open linear path with the supplied pen.
@@ -82,7 +82,7 @@ public static IImageProcessingContext DrawLines(
8282
/// <param name="pen">The pen.</param>
8383
/// <param name="points">The points.</param>
8484
/// <returns>The <see cref="IImageProcessingContext"/> to allow chaining of operations.</returns>
85-
public static IImageProcessingContext DrawLines(
85+
public static IImageProcessingContext DrawLine(
8686
this IImageProcessingContext source,
8787
DrawingOptions options,
8888
Pen pen,
@@ -96,7 +96,7 @@ public static IImageProcessingContext DrawLines(
9696
/// <param name="pen">The pen.</param>
9797
/// <param name="points">The points.</param>
9898
/// <returns>The <see cref="IImageProcessingContext"/> to allow chaining of operations.</returns>
99-
public static IImageProcessingContext DrawLines(
99+
public static IImageProcessingContext DrawLine(
100100
this IImageProcessingContext source,
101101
Pen pen,
102102
params PointF[] points) =>

src/ImageSharp.Drawing/Processing/Processors/Text/RichTextGlyphRenderer.cs

Lines changed: 7 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ internal sealed class RichTextGlyphRenderer : BaseGlyphBuilder, IColorGlyphRende
3030
private readonly Pen defaultPen;
3131
private readonly Brush defaultBrush;
3232
private readonly IPathInternals path;
33-
private Vector2 textPathOffset;
3433
private bool isDisposed;
3534

3635
private readonly Dictionary<Color, Brush> brushLookup = new();
@@ -96,23 +95,6 @@ protected override void BeginText(in FontRectangle bounds)
9695
}
9796

9897
this.DrawingOperations.Clear();
99-
100-
float yOffset = this.textOptions.VerticalAlignment switch
101-
{
102-
VerticalAlignment.Center => bounds.Bottom - (bounds.Height * .5F),
103-
VerticalAlignment.Bottom => bounds.Bottom,
104-
VerticalAlignment.Top => bounds.Top,
105-
_ => bounds.Top,
106-
};
107-
108-
float xOffset = this.textOptions.HorizontalAlignment switch
109-
{
110-
HorizontalAlignment.Center => bounds.Right - (bounds.Width * .5F),
111-
HorizontalAlignment.Right => bounds.Right,
112-
HorizontalAlignment.Left => bounds.Left,
113-
_ => bounds.Left,
114-
};
115-
this.textPathOffset = new(xOffset, yOffset);
11698
}
11799

118100
/// <inheritdoc/>
@@ -490,23 +472,14 @@ private Matrix3x2 ComputeTransform(in FontRectangle bounds)
490472
return Matrix3x2.Identity;
491473
}
492474

493-
// Find the intersection point.
494-
// This should be offset to ensure we rotate at the bottom-center of the glyph.
495-
float halfWidth = bounds.Width * .5F;
496-
497475
// Find the point of this intersection along the given path.
498-
SegmentInfo pathPoint = this.path.PointAlongPath(bounds.Left + halfWidth);
499-
500-
// Now offset our target point since we're aligning the bottom-left location of our glyph against the path.
501-
// This is good and accurate when we are vertically aligned to the path however the distance between
502-
// characters in multiline text scales with the angle and vertical offset.
503-
// This is expected and consistant with other libraries.
504-
// Multiple line text should be rendered using multiple paths to avoid this behavior.
505-
Vector2 targetPoint = (Vector2)pathPoint.Point + new Vector2(-halfWidth, bounds.Top) - bounds.Location - this.textPathOffset;
506-
507-
// Due to how matrix combining works you have to combine this in the reverse order of operation.
508-
return Matrix3x2.CreateTranslation(targetPoint)
509-
* Matrix3x2.CreateRotation(pathPoint.Angle - MathF.PI, pathPoint.Point);
476+
// We want to find the point on the path that is closest to the center-bottom side of the glyph.
477+
Vector2 half = new(bounds.Width * .5F, 0);
478+
SegmentInfo pathPoint = this.path.PointAlongPath(bounds.Left + half.X);
479+
480+
// Now offset to our target point since we're aligning the top-left location of our glyph against the path.
481+
Vector2 translation = (Vector2)pathPoint.Point - bounds.Location - half + new Vector2(0, bounds.Top);
482+
return Matrix3x2.CreateTranslation(translation) * Matrix3x2.CreateRotation(pathPoint.Angle - MathF.PI, (Vector2)pathPoint.Point);
510483
}
511484

512485
private Buffer2D<float> Render(IPath path)

src/ImageSharp.Drawing/Shapes/ComplexPolygon.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,12 +174,14 @@ SegmentInfo IPathInternals.PointAlongPath(float distance)
174174
distance -= p.Length;
175175
}
176176

177-
// TODO: Perf. Throwhelper
178-
throw new InvalidOperationException("Should not be possible to reach this line");
177+
ThrowOutOfRange();
178+
return default;
179179
}
180180

181181
/// <inheritdoc/>
182182
IReadOnlyList<InternalPath> IInternalPathOwner.GetRingsAsInternalPath()
183183
=> this.internalPaths;
184+
185+
private static void ThrowOutOfRange() => new InvalidOperationException("Should not be possible to reach this line");
184186
}
185187
}

src/ImageSharp.Drawing/Shapes/InternalPath.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,11 @@ private InternalPath(PointData[] points, bool isClosedPath)
141141
/// <exception cref="InvalidOperationException">Thrown if no points found.</exception>
142142
internal SegmentInfo PointAlongPath(float distanceAlongPath)
143143
{
144-
distanceAlongPath %= this.Length;
145144
int pointCount = this.PointCount;
146145
if (this.closedPath)
147146
{
147+
// Move the distance back to the beginning since this is a closed polygon.
148+
distanceAlongPath %= this.Length;
148149
pointCount--;
149150
}
150151

@@ -154,8 +155,7 @@ internal SegmentInfo PointAlongPath(float distanceAlongPath)
154155
if (distanceAlongPath < this.points[next].Length)
155156
{
156157
float t = distanceAlongPath / this.points[next].Length;
157-
Vector2 point = (this.points[i].Point * (1 - t)) + (this.points[next].Point * t);
158-
158+
var point = Vector2.Lerp(this.points[i].Point, this.points[next].Point, t);
159159
Vector2 diff = this.points[i].Point - this.points[next].Point;
160160

161161
return new SegmentInfo
@@ -168,8 +168,21 @@ internal SegmentInfo PointAlongPath(float distanceAlongPath)
168168
distanceAlongPath -= this.points[next].Length;
169169
}
170170

171-
// TODO: Perf - Throwhelper.
172-
throw new InvalidOperationException("Should always reach a point along the path.");
171+
// Closed paths will never reach this point.
172+
// For open paths we're going to create a new virtual point that extends past the path.
173+
// The position and angle for that point are calculated based upon the last two points.
174+
PointF a = this.points[Math.Max(this.points.Length - 2, 0)].Point;
175+
PointF b = this.points[this.points.Length - 1].Point;
176+
Vector2 delta = a - b;
177+
float angle = (float)(Math.Atan2(delta.Y, delta.X) % (Math.PI * 2));
178+
179+
Matrix3x2 transform = Matrix3x2.CreateRotation(angle - MathF.PI) * Matrix3x2.CreateTranslation(b.X, b.Y);
180+
181+
return new SegmentInfo
182+
{
183+
Point = Vector2.Transform(new Vector2(distanceAlongPath, 0), transform),
184+
Angle = angle
185+
};
173186
}
174187

175188
internal IMemoryOwner<PointF> ExtractVertices(MemoryAllocator allocator)
@@ -198,7 +211,7 @@ private static PointOrientation CalulateOrientation(Vector2 p, Vector2 q, Vector
198211
Vector2 rq = r - q;
199212
float val = (qp.Y * rq.X) - (qp.X * rq.Y);
200213

201-
if (val > -Epsilon && val < Epsilon)
214+
if (val is > -Epsilon and < Epsilon)
202215
{
203216
return PointOrientation.Collinear; // colinear
204217
}
@@ -228,7 +241,7 @@ private static PointOrientation CalulateOrientation(Vector2 qp, Vector2 rq)
228241
/// <param name="isClosed">Weather the path is closed or open.</param>
229242
/// <param name="removeCloseAndCollinear">Whether to remove close and collinear vertices</param>
230243
/// <returns>
231-
/// The <see cref="T:Vector2[]"/>.
244+
/// The <see cref="T:PointData[]"/>.
232245
/// </returns>
233246
private static PointData[] Simplify(IReadOnlyList<ILineSegment> segments, bool isClosed, bool removeCloseAndCollinear)
234247
{

src/ImageSharp.Drawing/Shapes/PolygonClipper/Clipper.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,11 +68,11 @@ public IPath[] GenerateClippedShapes()
6868

6969
for (int i = 0; i < openPaths.Count; i++)
7070
{
71-
var points = new PointF[closedPaths[i].Count];
71+
var points = new PointF[openPaths[i].Count];
7272

73-
for (int j = 0; j < closedPaths[i].Count; j++)
73+
for (int j = 0; j < openPaths[i].Count; j++)
7474
{
75-
Point64 p = closedPaths[i][j];
75+
Point64 p = openPaths[i][j];
7676

7777
// to make the floating point polygons compatable with clipper we had
7878
// to scale them up to make them ints but still retain some level of precision

src/ImageSharp.Drawing/Shapes/Text/PathGlyphBuilder.cs

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,12 @@ namespace SixLabors.ImageSharp.Drawing.Text
1414
internal sealed class PathGlyphBuilder : GlyphBuilder
1515
{
1616
private readonly IPathInternals path;
17-
private Vector2 textOffset;
18-
private readonly TextOptions textOptions;
1917

2018
/// <summary>
2119
/// Initializes a new instance of the <see cref="PathGlyphBuilder"/> class.
2220
/// </summary>
2321
/// <param name="path">The path to render the glyphs along.</param>
24-
/// <param name="textOptions">The text rendering options.</param>
25-
public PathGlyphBuilder(IPath path, TextOptions textOptions)
22+
public PathGlyphBuilder(IPath path)
2623
{
2724
if (path is IPathInternals internals)
2825
{
@@ -32,29 +29,6 @@ public PathGlyphBuilder(IPath path, TextOptions textOptions)
3229
{
3330
this.path = new ComplexPolygon(path);
3431
}
35-
36-
this.textOptions = textOptions;
37-
}
38-
39-
/// <inheritdoc/>
40-
protected override void BeginText(in FontRectangle bounds)
41-
{
42-
float yOffset = this.textOptions.VerticalAlignment switch
43-
{
44-
VerticalAlignment.Center => bounds.Bottom - (bounds.Height * .5F),
45-
VerticalAlignment.Bottom => bounds.Bottom,
46-
VerticalAlignment.Top => bounds.Top,
47-
_ => bounds.Top,
48-
};
49-
50-
float xOffset = this.textOptions.HorizontalAlignment switch
51-
{
52-
HorizontalAlignment.Center => bounds.Right - (bounds.Width * .5F),
53-
HorizontalAlignment.Right => bounds.Right,
54-
HorizontalAlignment.Left => bounds.Left,
55-
_ => bounds.Left,
56-
};
57-
this.textOffset = new(xOffset, yOffset);
5832
}
5933

6034
/// <inheritdoc/>
@@ -64,21 +38,15 @@ protected override void BeginGlyph(in FontRectangle bounds, in GlyphRendererPara
6438
[MethodImpl(MethodImplOptions.AggressiveInlining)]
6539
private void TransformGlyph(in FontRectangle bounds)
6640
{
67-
// Find the intersection point.
68-
// This should be offset to ensure we rotate at the bottom-center of the glyph.
69-
float halfWidth = bounds.Width * .5F;
70-
7141
// Find the point of this intersection along the given path.
72-
SegmentInfo pathPoint = this.path.PointAlongPath(bounds.Left + halfWidth);
42+
// We want to find the point on the path that is closest to the center-bottom side of the glyph.
43+
Vector2 half = new(bounds.Width * .5F, 0);
44+
SegmentInfo pathPoint = this.path.PointAlongPath(bounds.Left + half.X);
7345

74-
// Now offset our target point since we're aligning the bottom-left location of our glyph against the path.
75-
// This is good and accurate when we are vertically aligned to the path however the distance between
76-
// characters in multiline text scales with the angle and vertical offset.
77-
// This is expected and consistant with other libraries. Multiple line text should be rendered using multiple paths to avoid this behavior.
78-
Vector2 targetPoint = (Vector2)pathPoint.Point + new Vector2(-halfWidth, bounds.Top) - bounds.Location - this.textOffset;
46+
// Now offset to our target point since we're aligning the top-left location of our glyph against the path.
47+
Vector2 translation = (Vector2)pathPoint.Point - bounds.Location - half + new Vector2(0, bounds.Top);
48+
Matrix3x2 matrix = Matrix3x2.CreateTranslation(translation) * Matrix3x2.CreateRotation(pathPoint.Angle - MathF.PI, (Vector2)pathPoint.Point);
7949

80-
// Due to how matrix combining works you have to combine this in the reverse order of operation.
81-
Matrix3x2 matrix = Matrix3x2.CreateTranslation(targetPoint) * Matrix3x2.CreateRotation(pathPoint.Angle - MathF.PI, pathPoint.Point);
8250
this.Builder.SetTransform(matrix);
8351
}
8452
}

src/ImageSharp.Drawing/Shapes/Text/TextBuilder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public static IPathCollection GenerateGlyphs(string text, TextOptions textOption
3838
public static IPathCollection GenerateGlyphs(string text, IPath path, TextOptions textOptions)
3939
{
4040
(IPath Path, TextOptions TextOptions) transformed = ConfigureOptions(textOptions, path);
41-
PathGlyphBuilder glyphBuilder = new(transformed.Path, transformed.TextOptions);
41+
PathGlyphBuilder glyphBuilder = new(transformed.Path);
4242
TextRenderer renderer = new(glyphBuilder);
4343

4444
renderer.RenderText(text, transformed.TextOptions);

tests/ImageSharp.Drawing.Tests/Drawing/DrawLinesTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ private static void DrawLinesImpl<TPixel>(
153153
FormattableString outputDetails = $"{colorName}_A({alpha})_T({thickness}){aa}";
154154

155155
provider.RunValidatingProcessorTest(
156-
c => c.SetGraphicsOptions(options).DrawLines(pen, simplePath),
156+
c => c.SetGraphicsOptions(options).DrawLine(pen, simplePath),
157157
outputDetails,
158158
appendSourceFileOrDescription: false);
159159
}

tests/ImageSharp.Drawing.Tests/Drawing/DrawPathTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ public void PathExtendingOffEdgeOfImageShouldNotBeCropped<TPixel>(TestImageProvi
7070
for (int i = 0; i < 300; i += 20)
7171
{
7272
var points = new PointF[] { new Vector2(100, 2), new Vector2(-10, i) };
73-
x.DrawLines(pen, points);
73+
x.DrawLine(pen, points);
7474
}
7575
},
7676
appendPixelTypeToFileName: false,

tests/ImageSharp.Drawing.Tests/Drawing/DrawingRobustnessTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ public void LargeGeoJson_Lines(TestImageProvider<Rgba32> provider, string geoJso
9595
};
9696
foreach (PointF[] loop in points)
9797
{
98-
image.Mutate(c => c.DrawLines(options, Color.White, 1.0f, loop));
98+
image.Mutate(c => c.DrawLine(options, Color.White, 1.0f, loop));
9999
}
100100

101101
string details = $"_{System.IO.Path.GetFileName(geoJsonFile)}_{sx}x{sy}_aa{aa}";
@@ -162,7 +162,7 @@ public void LargeGeoJson_Mississippi_Lines(TestImageProvider<Rgba32> provider, i
162162

163163
foreach (PointF[] loop in points)
164164
{
165-
image.Mutate(c => c.DrawLines(Color.White, 1.0f, loop));
165+
image.Mutate(c => c.DrawLine(Color.White, 1.0f, loop));
166166
}
167167

168168
// Very strict tolerance, since the image is sparse (relaxed on .NET Framework)

0 commit comments

Comments
 (0)